@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
@@ -13,8 +13,10 @@ import * as path from 'path';
13
13
  import React from 'react';
14
14
  import { renderToString } from 'react-dom/server';
15
15
  import { SynapseProvider } from '../client/context';
16
+ import { ToastProvider } from '../client/toast';
16
17
  import {
17
18
  buildClientBundle,
19
+ buildCssBundle,
18
20
  buildVendorBundle,
19
21
  type ClientManifest,
20
22
  freshBundleFrom,
@@ -27,24 +29,36 @@ import {
27
29
  type ActionContext,
28
30
  AnonymousSession,
29
31
  BoundedLruCache,
32
+ buildOAuthAuthorizationUrl,
30
33
  createActionContext,
31
34
  createDefaultLogger,
32
35
  createSession,
33
36
  type DatabaseClient,
37
+ discordOAuthPreset,
38
+ exchangeOAuthCode,
39
+ fetchOAuthUserProfile,
40
+ generateOAuthState,
41
+ generatePkceChallenge,
34
42
  getDatabase,
43
+ githubOAuthPreset,
44
+ googleOAuthPreset,
45
+ hasAnyRole,
35
46
  loadSynapseConfig,
36
47
  normalizeCacheKey,
48
+ type OAuthProviderConfig,
37
49
  optimizeImage,
38
50
  resetDatabaseInstance,
39
51
  type SessionContext,
40
52
  type SliceCacheConfig,
41
53
  type SliceSocketDefinition,
42
54
  type SynapseConfig,
43
- type SynapseWebSocket,
44
- serializeCookie
55
+ serializeCookie,
56
+ signSessionToken,
57
+ verifyOAuthState
45
58
  } from '../core/index';
46
59
  import { ROLES_COOKIE, SESSION_COOKIE } from '../core/session-cookie';
47
- import { verifySessionToken } from '../core/session-token';
60
+ import { isSessionTokenRevoked, isSessionTokenRevokedInDb, verifySessionToken } from '../core/session-token';
61
+ import { declaredTopic, type RealtimeRejectionCode, scopedTopic } from '../core/topics';
48
62
  import { SYNAPSE_VERSION } from '../version';
49
63
  import { serializeClientProps } from './client-entry';
50
64
  import { isAction, isCache, isComponent, isJob, isLoader, isMeta, isSocket, isWebhook } from './discovery-rules';
@@ -118,20 +132,17 @@ export interface SliceLoadError {
118
132
  message: string;
119
133
  }
120
134
 
121
- /**
122
- * Domain error codes are the contract; the HTTP status is a transport detail
123
- * derived from them. Before this, every failure was 400, so a client could not
124
- * tell an expired session (401) from a missing row (404) or a duplicate (409).
125
- * Unknown codes keep 400: the domain, not the transport, decides.
126
- */
127
- const ERROR_STATUS: Array<{ pattern: RegExp; status: number }> = [
128
- { pattern: /^UNAUTHORIZED$/, status: 401 },
129
- { pattern: /^FORBIDDEN$/, status: 403 },
130
- { pattern: /_NOT_FOUND$/, status: 404 },
131
- { pattern: /^(DUPLICATE_|ALREADY_|CONFLICT)/, status: 409 },
132
- { pattern: /^(INVALID_|MALFORMED)/, status: 422 },
133
- { pattern: /^(NO_DATABASE|PERSISTENCE_FAILED)/, status: 500 },
134
- { pattern: /_FAILED$/, status: 500 }
135
+ import { ERROR_STATUS, escapeHtml, httpStatusForError } from './security-headers';
136
+
137
+ export { ERROR_STATUS, escapeHtml, httpStatusForError };
138
+
139
+ const RATE_LIMITED_PREFIXES = [
140
+ '/_synapse/rpc/',
141
+ '/_synapse/files/',
142
+ '/_synapse/webhooks/',
143
+ '/_synapse/sse/',
144
+ '/_synapse/images/',
145
+ '/_synapse/api/'
135
146
  ];
136
147
 
137
148
  export const TURBO_ROUTER_SCRIPT = `(function() {
@@ -147,8 +158,58 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
147
158
  const newRoot = doc.querySelector('#synapse-root');
148
159
  const curRoot = document.querySelector('#synapse-root');
149
160
  if (newRoot && curRoot) {
150
- curRoot.innerHTML = newRoot.innerHTML;
151
- document.title = doc.title;
161
+ const updateDom = function() {
162
+ var preserved = document.querySelectorAll('[data-synapse-preserve]');
163
+ var preservedMap = new Map();
164
+
165
+ preserved.forEach(function(el) {
166
+ var key = el.getAttribute('data-synapse-preserve') || el.id || el.getAttribute('name');
167
+ if (!key) return;
168
+ preservedMap.set(key, {
169
+ value: el.value,
170
+ checked: el.checked,
171
+ selectedIndex: el.selectedIndex,
172
+ scrollTop: el.scrollTop,
173
+ innerHTML: el.innerHTML,
174
+ focused: document.activeElement === el
175
+ });
176
+ });
177
+
178
+ curRoot.innerHTML = newRoot.innerHTML;
179
+ document.title = doc.title;
180
+
181
+ var findTarget = function(key) {
182
+ var candidates = document.querySelectorAll('[data-synapse-preserve], [id], [name]');
183
+ for (var i = 0; i < candidates.length; i++) {
184
+ var candidate = candidates[i];
185
+ var candidateKey =
186
+ candidate.getAttribute('data-synapse-preserve') || candidate.id || candidate.getAttribute('name');
187
+ if (candidateKey === key) return candidate;
188
+ }
189
+ return null;
190
+ };
191
+
192
+ preservedMap.forEach(function(state, key) {
193
+ var target = findTarget(key);
194
+ if (!target) return;
195
+
196
+ if (target.value !== undefined && state.value !== undefined) target.value = state.value;
197
+ if (target.checked !== undefined && state.checked !== undefined) target.checked = state.checked;
198
+ if (target.selectedIndex !== undefined && state.selectedIndex !== undefined) {
199
+ target.selectedIndex = state.selectedIndex;
200
+ }
201
+ if (state.scrollTop !== undefined) target.scrollTop = state.scrollTop;
202
+ if (target.value === undefined && state.innerHTML !== undefined) target.innerHTML = state.innerHTML;
203
+ if (state.focused && typeof target.focus === 'function') target.focus();
204
+ });
205
+ };
206
+
207
+ if (document.startViewTransition) {
208
+ await document.startViewTransition(updateDom).finished;
209
+ } else {
210
+ updateDom();
211
+ }
212
+
152
213
  var newPropsScript = doc.querySelector('script[data-synapse-props]');
153
214
  if (newPropsScript && newPropsScript.textContent) {
154
215
  try {
@@ -157,6 +218,7 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
157
218
  }
158
219
  if (push) history.pushState({}, '', href);
159
220
  window.dispatchEvent(new CustomEvent('synapse:morphed', { detail: { url: href } }));
221
+ window.__SYNAPSE_URL__ = href;
160
222
  var newClientScript = doc.querySelector('script[data-synapse-client]');
161
223
  if (newClientScript && newClientScript.src) {
162
224
  import(newClientScript.src).then(function(mod) {
@@ -190,28 +252,6 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
190
252
  });
191
253
  })();`;
192
254
 
193
- export function httpStatusForError(error: unknown): number {
194
- const code = typeof error === 'string' ? error : '';
195
-
196
- for (const entry of ERROR_STATUS) {
197
- if (entry.pattern.test(code)) {
198
- return entry.status;
199
- }
200
- }
201
-
202
- return 400;
203
- }
204
-
205
- /** Escapes special HTML characters to prevent Reflected/Stored XSS. */
206
- export function escapeHtml(str: string): string {
207
- return str
208
- .replace(/&/g, '&amp;')
209
- .replace(/</g, '&lt;')
210
- .replace(/>/g, '&gt;')
211
- .replace(/"/g, '&quot;')
212
- .replace(/'/g, '&#39;');
213
- }
214
-
215
255
  /** Parses Cookie request header safely into key-value pairs. */
216
256
  export function parseCookies(header: string | null | undefined): Record<string, string> {
217
257
  if (!header) {
@@ -249,6 +289,150 @@ export function formatLogLine(entry: {
249
289
  return JSON.stringify({ ts: new Date().toISOString(), ...entry });
250
290
  }
251
291
 
292
+ /** Backstop ceiling for any request body: 12 MiB, above every per-route limit. */
293
+ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 12 * 1024 * 1024;
294
+
295
+ /** The runtime mode, from SYNAPSE_ENV or NODE_ENV. */
296
+ function runtimeMode(): string {
297
+ return (process.env.SYNAPSE_ENV ?? process.env.NODE_ENV ?? '').trim().toLowerCase();
298
+ }
299
+
300
+ /**
301
+ * True when the process declares a mode other than the development ones — an exact
302
+ * `production` spelling, but also `prod`, `staging` or any other value an operator
303
+ * clearly intends as a real deployment. An unset value is not production-like here:
304
+ * the request layer still requires the explicit development opt-in below.
305
+ */
306
+ export function isProductionLikeMode(): boolean {
307
+ const mode = runtimeMode();
308
+
309
+ if (!mode) {
310
+ return false;
311
+ }
312
+
313
+ return !['development', 'dev', 'test'].includes(mode);
314
+ }
315
+
316
+ /**
317
+ * Caller-supplied identity (x-user-id, x-user-roles, the roles cookie) is honoured
318
+ * only under an explicit development opt-in: `SYNAPSE_DEV_HEADERS=true`, or a
319
+ * development/test runtime mode. Any other state — unset, staging, production —
320
+ * resolves to an anonymous session, so a mistyped variable cannot grant identity,
321
+ * and the explicit flag is the acknowledgement an operator gives by name.
322
+ */
323
+ export function headerTrustEnabled(): boolean {
324
+ const explicit = (process.env.SYNAPSE_DEV_HEADERS ?? '').trim().toLowerCase();
325
+
326
+ if (explicit === 'true') {
327
+ return true;
328
+ }
329
+
330
+ if (explicit === 'false') {
331
+ return false;
332
+ }
333
+
334
+ if (isProductionLikeMode()) {
335
+ return false;
336
+ }
337
+
338
+ const mode = runtimeMode();
339
+
340
+ return mode === 'development' || mode === 'dev' || mode === 'test';
341
+ }
342
+
343
+ /**
344
+ * The process-level body ceiling: explicit config first, then
345
+ * SYNAPSE_MAX_REQUEST_BODY_BYTES, then the default.
346
+ */
347
+ export function maxRequestBodyBytes(config?: SynapseConfig): number {
348
+ const configured = config?.maxRequestBodyBytes;
349
+
350
+ if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) {
351
+ return Math.floor(configured);
352
+ }
353
+
354
+ const raw = Number(process.env.SYNAPSE_MAX_REQUEST_BODY_BYTES);
355
+
356
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_REQUEST_BODY_BYTES;
357
+ }
358
+
359
+ export interface ExternalFetchResult {
360
+ ok: boolean;
361
+ body?: Uint8Array;
362
+ contentType?: string;
363
+ status?: number;
364
+ code?: string;
365
+ }
366
+
367
+ /**
368
+ * Fetches a URL whose host has already passed `validateExternalUrl`, connecting to the
369
+ * address that was validated rather than resolving the hostname a second time: the URL
370
+ * is rewritten to the pinned address, the original Host header is preserved and TLS
371
+ * validation still checks the certificate against the real hostname (serverName).
372
+ *
373
+ * The response is read through the same streaming ceiling as every other body reader,
374
+ * the whole fetch is bounded by a timeout, and redirects are refused rather than
375
+ * followed (a hop would have to be revalidated, and refusing cannot be defeated).
376
+ */
377
+ export async function fetchPinnedExternal(
378
+ rawUrl: string,
379
+ resolvedIp: string | undefined,
380
+ options: { maxBytes: number; timeoutMs: number }
381
+ ): Promise<ExternalFetchResult> {
382
+ const parsed = new URL(rawUrl);
383
+ const hostname = parsed.hostname;
384
+ const pinnedHost = resolvedIp?.includes(':') ? `[${resolvedIp}]` : resolvedIp;
385
+ const port = parsed.port ? `:${parsed.port}` : '';
386
+ const target = pinnedHost ? `${parsed.protocol}//${pinnedHost}${port}${parsed.pathname}${parsed.search}` : rawUrl;
387
+
388
+ const controller = new AbortController();
389
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
390
+
391
+ try {
392
+ const response = await fetch(target, {
393
+ headers: { host: parsed.host },
394
+ redirect: 'manual',
395
+ signal: controller.signal,
396
+ ...(parsed.protocol === 'https:' ? { tls: { serverName: hostname } } : {})
397
+ } as RequestInit);
398
+
399
+ if (!response.ok) {
400
+ return { ok: false, status: 502, code: 'UPSTREAM_RESPONSE_FAILED' };
401
+ }
402
+
403
+ const body = await readBodyWithinLimit(response.body, options.maxBytes);
404
+
405
+ if (!body.ok) {
406
+ return { ok: false, status: 413, code: 'UPSTREAM_RESPONSE_TOO_LARGE' };
407
+ }
408
+
409
+ return {
410
+ ok: true,
411
+ body: body.value,
412
+ contentType: response.headers.get('content-type') || 'application/octet-stream'
413
+ };
414
+ } catch (err) {
415
+ const aborted = err instanceof Error && (err.name === 'AbortError' || err.message.includes('aborted'));
416
+
417
+ return aborted
418
+ ? { ok: false, status: 504, code: 'UPSTREAM_TIMEOUT' }
419
+ : { ok: false, status: 502, code: 'UPSTREAM_FETCH_FAILED' };
420
+ } finally {
421
+ clearTimeout(timer);
422
+ }
423
+ }
424
+
425
+ export interface ShutdownSummary {
426
+ /** Requests that were still being handled when the drain began and finished in time. */
427
+ drained: number;
428
+ /** Requests still running when the drain timeout expired; each received 503 DRAINING. */
429
+ aborted: number;
430
+ /** Jobs returned to the queue by the shutdown, exactly one attempt counted. */
431
+ jobsRequeued: number;
432
+ /** Jobs whose attempt budget was already exhausted; marked failed. */
433
+ jobsFailed: number;
434
+ }
435
+
252
436
  export class SynapseServer {
253
437
  private slices: Map<string, DiscoveredSlice> = new Map();
254
438
  private baseDir: string;
@@ -304,35 +488,152 @@ export class SynapseServer {
304
488
  if (config) {
305
489
  this.config = { ...config };
306
490
  }
307
- this.rateLimiter = new TokenBucketRateLimiter({ maxBuckets: 10_000 });
491
+ this.rateLimiter = new TokenBucketRateLimiter({
492
+ maxBuckets: config?.rateLimit?.maxBuckets ?? 10_000,
493
+ capacity: config?.rateLimit?.capacity,
494
+ refillRate: config?.rateLimit?.refillRate
495
+ });
308
496
  this.ssrCache = new BoundedLruCache(this.config.cache?.maxEntries || 1000);
309
497
  this.queueEngine = new QueueEngine({
310
498
  dbPath: path.join(this.baseDir, '.synapse/queue.sqlite'),
311
499
  logger: createDefaultLogger('QueueEngine')
312
500
  });
501
+
502
+ // Pending until shutdown reaches its drain deadline; every request races it.
503
+ this.drainSignal = new Promise<Response>((resolve) => {
504
+ this.drainSignalResolve = resolve;
505
+ });
313
506
  }
314
507
 
508
+ /**
509
+ * Resolves when in-flight requests must stop waiting — the drain deadline. It stays
510
+ * pending while the server is running, so every handler races it from the moment it
511
+ * begins and a request that is still running when the deadline fires is answered
512
+ * with 503 DRAINING instead of being cut off with no response.
513
+ */
514
+ private drainSignal: Promise<Response>;
515
+ private drainSignalResolve: ((response: Response) => void) | null = null;
516
+ private inFlightCount = 0;
517
+ private abortedCount = 0;
518
+ private drainedCount = 0;
519
+ private shutdownPromise: Promise<ShutdownSummary> | null = null;
520
+ private openStreams = new Set<ReadableStreamDefaultController<Uint8Array>>();
521
+
522
+ /** What a `stop()` call reports and logs: the observable result of the drain. */
523
+ private lastShutdownSummary: ShutdownSummary | null = null;
524
+
315
525
  /**
316
526
  * Gracefully shuts down the HTTP server and closes database connections.
527
+ *
528
+ * Waits for the promise Bun's own drain returns, raced against the configured
529
+ * timeout — the value is honoured instead of being replaced by a shorter fixed
530
+ * wait — and only then closes the rate limiter, the queue engine and the
531
+ * database, so a request that queries the database late in the drain still can.
532
+ * A second call is safe: it resolves with the same summary and closes nothing twice.
317
533
  */
318
- async stop(drainTimeoutMs = 5000): Promise<void> {
319
- if (this.httpServer) {
320
- this.httpServer.stop(false);
321
- this.httpServer = null;
534
+ async stop(drainTimeoutMs = 5000): Promise<ShutdownSummary> {
535
+ if (this.shutdownPromise) {
536
+ return this.shutdownPromise;
322
537
  }
538
+
539
+ this.shutdownPromise = this.performShutdown(drainTimeoutMs);
540
+
541
+ return this.shutdownPromise;
542
+ }
543
+
544
+ private async performShutdown(drainTimeoutMs: number): Promise<ShutdownSummary> {
545
+ // No new background work is claimed from this point on: a job enqueued by an
546
+ // in-flight request stays pending for the next process.
547
+ this.queueEngine.stopClaiming();
548
+
549
+ const inFlightAtStart = this.inFlightCount;
550
+ const stopPromise: Promise<unknown> = this.httpServer ? this.httpServer.stop(false) : Promise.resolve();
551
+ this.httpServer = null;
552
+
553
+ let timedOut = false;
554
+
323
555
  if (drainTimeoutMs > 0) {
324
- await new Promise((resolve) => setTimeout(resolve, Math.min(drainTimeoutMs, 200)));
556
+ let timer: ReturnType<typeof setTimeout> | undefined;
557
+ const deadline = new Promise<'timeout'>((resolve) => {
558
+ timer = setTimeout(() => resolve('timeout'), drainTimeoutMs);
559
+ });
560
+
561
+ // Fires only when the deadline expires: everything still outstanding is then
562
+ // answered with 503 DRAINING, deliberately, before persistence is closed.
563
+ deadline.then(() => {
564
+ this.abortedCount = this.inFlightCount;
565
+ this.closeOpenStreams();
566
+
567
+ this.drainSignalResolve?.(
568
+ Response.json(
569
+ { ok: false, error: 'DRAINING', message: 'O servidor está encerrando e não concluiu esta requisição.' },
570
+ { status: 503 }
571
+ )
572
+ );
573
+ });
574
+
575
+ // The wait is always bounded: a connection that never finishes producing its
576
+ // request must not hold the shutdown open past the configured timeout.
577
+ const outcome = await Promise.race([stopPromise.then(() => 'drained' as const), deadline]);
578
+
579
+ if (timer) {
580
+ clearTimeout(timer);
581
+ }
582
+
583
+ timedOut = outcome === 'timeout';
584
+ }
585
+ // A configured timeout of zero skips the wait entirely.
586
+
587
+ if (timedOut) {
588
+ // What outlived the deadline is terminated deliberately and reported; the
589
+ // race in the request path has already answered each one with 503 DRAINING.
590
+ this.closeOpenStreams();
325
591
  }
592
+
593
+ this.drainedCount = inFlightAtStart - this.abortedCount;
594
+
595
+ const jobs = this.queueEngine.interruptRunningJobs();
596
+
326
597
  this.rateLimiter.close();
327
598
  this.queueEngine.close();
328
599
  await this.db.close?.();
329
600
  resetDatabaseInstance();
601
+
602
+ this.lastShutdownSummary = {
603
+ drained: Math.max(0, this.drainedCount),
604
+ aborted: this.abortedCount,
605
+ jobsRequeued: jobs.requeued,
606
+ jobsFailed: jobs.failed
607
+ };
608
+
609
+ return this.lastShutdownSummary;
610
+ }
611
+
612
+ private closeOpenStreams(): void {
613
+ for (const controller of this.openStreams) {
614
+ try {
615
+ controller.close();
616
+ } catch {
617
+ // already closed
618
+ }
619
+ }
620
+
621
+ this.openStreams.clear();
622
+ }
623
+
624
+ get shutdownSummary(): ShutdownSummary | null {
625
+ return this.lastShutdownSummary;
330
626
  }
331
627
 
332
628
  get database(): DatabaseClient {
333
629
  return this.db;
334
630
  }
335
631
 
632
+ /** How many slices were discovered and registered — the benchmark's own sanity check. */
633
+ get loadedSliceCount(): number {
634
+ return this.slices.size;
635
+ }
636
+
336
637
  get queue(): QueueEngine {
337
638
  return this.queueEngine;
338
639
  }
@@ -349,7 +650,13 @@ export class SynapseServer {
349
650
  rpcSuccessCount: this.metrics.rpcSuccessCount,
350
651
  rpcErrorCount: this.metrics.rpcErrorCount,
351
652
  ssrRenderCount: this.metrics.ssrRenderCount,
352
- staticFileCount: this.metrics.staticFileCount
653
+ staticFileCount: this.metrics.staticFileCount,
654
+ /** Requests that have begun handling and not yet produced a response. */
655
+ inFlightRequests: this.inFlightCount,
656
+ /** Open event streams: reported apart from drainable work. */
657
+ openStreams: this.openStreams.size,
658
+ shuttingDown: this.shutdownPromise !== null,
659
+ shutdown: this.lastShutdownSummary
353
660
  };
354
661
  }
355
662
 
@@ -357,13 +664,15 @@ export class SynapseServer {
357
664
  return this.config;
358
665
  }
359
666
 
360
- private createActionContext(sliceName: string, session: SessionContext): ActionContext {
667
+ private createActionContext(sliceKey: string, session: SessionContext): ActionContext {
361
668
  return createActionContext({
362
669
  db: this.db,
363
670
  session,
364
671
  tenantId: session.tenantId,
365
672
  services: (this.config.services as Record<string, unknown>) || {},
366
- logger: createDefaultLogger(sliceName),
673
+ logger: createDefaultLogger(sliceKey),
674
+ // Publishing is owner-checked: only the slice that declared a topic may write to it.
675
+ sliceOwner: sliceKey,
367
676
  enqueue: (jobOrName, payload, options) => this.queueEngine.enqueue(jobOrName, payload, options),
368
677
  invalidateCache: (tags) => this.invalidateCache(tags)
369
678
  });
@@ -588,6 +897,57 @@ export class SynapseServer {
588
897
  };
589
898
  }
590
899
 
900
+ /**
901
+ * Client identity for rate limiting. Forwarding headers are honoured only when the
902
+ * deployment explicitly declares a trusted proxy (SYNAPSE_TRUST_PROXY=true or
903
+ * config.trustProxy), so a direct client rotating X-Forwarded-For cannot mint a
904
+ * fresh allowance out of thin air.
905
+ */
906
+ private clientIdentity(req: Request): string {
907
+ const peer = this.httpServer?.requestIP(req)?.address || '127.0.0.1';
908
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY === 'true' || this.config.trustProxy === true;
909
+
910
+ if (!trustProxy) {
911
+ return peer;
912
+ }
913
+
914
+ return req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || peer;
915
+ }
916
+
917
+ /**
918
+ * Consumes one token for the request's client identity. Returns a 429 response when
919
+ * the allowance is exhausted, or null when the request may proceed.
920
+ */
921
+ private rateLimitRefusal(req: Request, pathname: string, startedAt: number): Response | null {
922
+ const rateResult = this.rateLimiter.consume(this.clientIdentity(req));
923
+
924
+ if (rateResult.allowed) {
925
+ return null;
926
+ }
927
+
928
+ const cors = this.corsHeaders(req);
929
+ this.logRequest(req, pathname, 429, startedAt);
930
+
931
+ return new Response(
932
+ JSON.stringify({
933
+ ok: false,
934
+ error: 'RATE_LIMIT_EXCEEDED',
935
+ message: 'Muitas requisições. Tente novamente mais tarde.'
936
+ }),
937
+ {
938
+ status: 429,
939
+ headers: {
940
+ 'Content-Type': 'application/json; charset=utf-8',
941
+ 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000)),
942
+ 'X-RateLimit-Limit': String(rateResult.limit),
943
+ 'X-RateLimit-Remaining': String(rateResult.remaining),
944
+ 'X-RateLimit-Reset': String(Math.ceil((Date.now() + rateResult.resetMs) / 1000)),
945
+ ...(cors ?? {})
946
+ }
947
+ }
948
+ );
949
+ }
950
+
591
951
  /** Records request metrics and writes one JSON line per request when SYNAPSE_LOG=json. */
592
952
  private logRequest(
593
953
  request: Request,
@@ -647,21 +1007,26 @@ export class SynapseServer {
647
1007
 
648
1008
  if (secret) {
649
1009
  if (!token) {
650
- return AnonymousSession(tenantId);
1010
+ return AnonymousSession();
651
1011
  }
652
1012
  const verified = verifySessionToken(token, secret);
1013
+
1014
+ // The tenant comes exclusively from the verified claims: with a secret
1015
+ // configured, neither the tenant header nor the subdomain can supply one.
653
1016
  return verified.ok
654
1017
  ? createSession({
655
1018
  userId: verified.value.userId,
656
- tenantId: verified.value.tenantId || tenantId,
1019
+ tenantId: verified.value.tenantId,
657
1020
  roles: verified.value.roles,
1021
+ permissions: verified.value.permissions,
658
1022
  token
659
1023
  })
660
- : AnonymousSession(tenantId);
1024
+ : AnonymousSession();
661
1025
  }
662
1026
 
663
- // Em produção sem SYNAPSE_SESSION_SECRET, forjamento de headers é terminantemente proibido (CWE-287)
664
- if (process.env.NODE_ENV === 'production') {
1027
+ // Sem segredo, a identidade forjável por header só é aceita sob opt-in explícito
1028
+ // de desenvolvimento; qualquer outro estado resolve para anônimo.
1029
+ if (!headerTrustEnabled()) {
665
1030
  return AnonymousSession(tenantId);
666
1031
  }
667
1032
 
@@ -688,6 +1053,60 @@ export class SynapseServer {
688
1053
  return AnonymousSession(tenantId);
689
1054
  }
690
1055
 
1056
+ /** Signature -> memoised revocation verdict, bounded by TTL and entry cap. */
1057
+ private revocationMemo = new Map<string, { revoked: boolean; checkedAt: number }>();
1058
+
1059
+ /**
1060
+ * The session for a request, with revocation consulted on every request. A
1061
+ * signature is queried against the persisted store at most once per TTL window,
1062
+ * so the common case costs nothing; the in-process set short-circuits it entirely.
1063
+ */
1064
+ async requestSession(request: Request): Promise<SessionContext> {
1065
+ const session = this.sessionFrom(request);
1066
+
1067
+ if (!session.isAuthenticated || !session.token) {
1068
+ return session;
1069
+ }
1070
+
1071
+ return (await this.isTokenRevoked(session.token)) ? AnonymousSession() : session;
1072
+ }
1073
+
1074
+ private async isTokenRevoked(token: string): Promise<boolean> {
1075
+ if (isSessionTokenRevoked(token)) {
1076
+ return true;
1077
+ }
1078
+
1079
+ const signature = token.split('.')[1];
1080
+
1081
+ if (!signature) {
1082
+ return true;
1083
+ }
1084
+
1085
+ const ttlMs = Number(process.env.SYNAPSE_REVOCATION_MEMO_TTL_MS ?? '');
1086
+ const ttl = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : 30_000;
1087
+ const cached = this.revocationMemo.get(signature);
1088
+
1089
+ if (cached && Date.now() - cached.checkedAt < ttl) {
1090
+ return cached.revoked;
1091
+ }
1092
+
1093
+ const revoked = await isSessionTokenRevokedInDb(this.db, token);
1094
+
1095
+ while (this.revocationMemo.size >= 5000) {
1096
+ const oldest = this.revocationMemo.keys().next().value;
1097
+
1098
+ if (oldest === undefined) {
1099
+ break;
1100
+ }
1101
+
1102
+ this.revocationMemo.delete(oldest);
1103
+ }
1104
+
1105
+ this.revocationMemo.set(signature, { revoked, checkedAt: Date.now() });
1106
+
1107
+ return revoked;
1108
+ }
1109
+
691
1110
  /**
692
1111
  * Builds the browser bundle for a slice from the splitter's client artifact.
693
1112
  *
@@ -746,28 +1165,403 @@ export class SynapseServer {
746
1165
  }
747
1166
 
748
1167
  /**
749
- * Returns stylesheet links, respecting SYNAPSE_DISABLE_CDN and detecting public/synapse.css
1168
+ * The third-party origins, opt-in only. Enabling them without an integrity hash
1169
+ * emits nothing: an unpinned third-party script (or stylesheet) in an origin that
1170
+ * holds a script-readable session cookie is the risk this switch exists to gate.
1171
+ */
1172
+ private thirdPartyOrigins(): { enabled: boolean; script?: string; fontStyles?: string; fontFiles?: string } {
1173
+ const enabled =
1174
+ this.config.cdn?.enabled === true ||
1175
+ process.env.SYNAPSE_ENABLE_CDN === 'true' ||
1176
+ process.env.SYNAPSE_ENABLE_CDN === '1';
1177
+
1178
+ if (!enabled) {
1179
+ return { enabled: false };
1180
+ }
1181
+
1182
+ return {
1183
+ enabled: true,
1184
+ script: this.config.cdn?.scriptIntegrity || process.env.SYNAPSE_CDN_INTEGRITY || undefined,
1185
+ fontStyles: this.config.cdn?.fontIntegrity || process.env.SYNAPSE_FONTS_INTEGRITY || undefined,
1186
+ fontFiles: 'https://fonts.gstatic.com'
1187
+ };
1188
+ }
1189
+
1190
+ /**
1191
+ * The Content-Security-Policy every HTML response carries. An operator-supplied
1192
+ * policy replaces it (never weakened); otherwise the policy is same-origin, with
1193
+ * third-party origins appearing only when explicitly enabled and pinned.
1194
+ */
1195
+ private contentSecurityPolicy(): string {
1196
+ const operatorPolicy = this.config.contentSecurityPolicy || process.env.SYNAPSE_CSP;
1197
+
1198
+ if (operatorPolicy) {
1199
+ return operatorPolicy;
1200
+ }
1201
+
1202
+ const thirdParty = this.thirdPartyOrigins();
1203
+ const scriptSources = ["'self'", "'unsafe-inline'"];
1204
+ const styleSources = ["'self'", "'unsafe-inline'"];
1205
+ const fontSources = ["'self'", 'data:'];
1206
+
1207
+ if (thirdParty.script) {
1208
+ scriptSources.push('https://cdn.tailwindcss.com');
1209
+ }
1210
+
1211
+ if (thirdParty.fontStyles) {
1212
+ styleSources.push('https://fonts.googleapis.com');
1213
+ fontSources.push('https://fonts.gstatic.com');
1214
+ }
1215
+
1216
+ return [
1217
+ "default-src 'self'",
1218
+ `script-src ${scriptSources.join(' ')}`,
1219
+ `style-src ${styleSources.join(' ')}`,
1220
+ "img-src 'self' data: https:",
1221
+ `font-src ${fontSources.join(' ')}`,
1222
+ "connect-src 'self'",
1223
+ "object-src 'none'",
1224
+ "frame-ancestors 'none'",
1225
+ "base-uri 'self'",
1226
+ "form-action 'self'"
1227
+ ].join('; ');
1228
+ }
1229
+
1230
+ /** The baseline headers every response carries — routed, unrouted, static or error. */
1231
+ private baselineHeaders(req: Request): Record<string, string> {
1232
+ const headers: Record<string, string> = {
1233
+ 'X-Content-Type-Options': 'nosniff',
1234
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
1235
+ 'X-Frame-Options': 'DENY',
1236
+ 'Content-Security-Policy': this.contentSecurityPolicy()
1237
+ };
1238
+
1239
+ const forwardedProto = req.headers.get('x-forwarded-proto');
1240
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY === 'true' || this.config.trustProxy === true;
1241
+ const isTls =
1242
+ new URL(req.url).protocol === 'https:' || (trustProxy && forwardedProto?.split(',')[0]?.trim() === 'https');
1243
+
1244
+ if (isTls) {
1245
+ headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
1246
+ }
1247
+
1248
+ return headers;
1249
+ }
1250
+
1251
+ /** Adds the baseline headers to a response without overriding what it already sets. */
1252
+ private withBaselineHeaders(response: Response, req: Request): Response {
1253
+ for (const [name, value] of Object.entries(this.baselineHeaders(req))) {
1254
+ if (!response.headers.has(name)) {
1255
+ response.headers.set(name, value);
1256
+ }
1257
+ }
1258
+
1259
+ // The response is returned as it came, so a `Bun.file` body keeps its
1260
+ // zero-copy send instead of being re-wrapped as a stream.
1261
+ return response;
1262
+ }
1263
+
1264
+ /**
1265
+ * A realtime refusal: the same machine-readable shape on both transports, with the
1266
+ * code always drawn from the set the machine contract enumerates.
1267
+ */
1268
+ private realtimeRefusal(
1269
+ code: RealtimeRejectionCode,
1270
+ status: number,
1271
+ message: string,
1272
+ cors?: Record<string, string> | null
1273
+ ): Response {
1274
+ return Response.json({ ok: false, error: code, message }, { status, headers: cors ?? undefined });
1275
+ }
1276
+
1277
+ /** Concurrent realtime connections held per client identity. */
1278
+ private realtimeConnections = new Map<string, number>();
1279
+
1280
+ private releaseRealtimeConnection(clientKey: string): void {
1281
+ const open = this.realtimeConnections.get(clientKey) ?? 0;
1282
+
1283
+ if (open <= 1) {
1284
+ this.realtimeConnections.delete(clientKey);
1285
+ } else {
1286
+ this.realtimeConnections.set(clientKey, open - 1);
1287
+ }
1288
+ }
1289
+
1290
+ private realtimeBounds(): { maxTopics: number; maxSubscriptions: number; maxConnectionsPerClient: number } {
1291
+ return {
1292
+ maxTopics: this.config.realtime?.maxTopics ?? 1000,
1293
+ maxSubscriptions: this.config.realtime?.maxSubscriptions ?? 1000,
1294
+ maxConnectionsPerClient: this.config.realtime?.maxConnectionsPerClient ?? 8
1295
+ };
1296
+ }
1297
+
1298
+ /**
1299
+ * The origin check for a WebSocket upgrade, fail-closed: an allow-list must be
1300
+ * configured and must contain the request's origin (or the wildcard). An absent
1301
+ * origin and the opaque `null` origin are refused. The one carve-out is a loopback
1302
+ * origin, so local development works without a production-grade allow-list.
1303
+ */
1304
+ private webSocketOriginAllowed(req: Request): boolean {
1305
+ const origin = req.headers.get('origin');
1306
+
1307
+ if (!origin || origin === 'null') {
1308
+ return false;
1309
+ }
1310
+
1311
+ if (this.isLoopbackOrigin(origin)) {
1312
+ return true;
1313
+ }
1314
+
1315
+ const allowed = (process.env.SYNAPSE_ALLOWED_ORIGINS ?? '')
1316
+ .split(',')
1317
+ .map((entry) => entry.trim())
1318
+ .filter((entry) => entry.length > 0);
1319
+
1320
+ return allowed.includes('*') || allowed.includes(origin);
1321
+ }
1322
+
1323
+ private isLoopbackOrigin(origin: string): boolean {
1324
+ try {
1325
+ const parsed = new URL(origin);
1326
+
1327
+ return (
1328
+ parsed.hostname === 'localhost' ||
1329
+ parsed.hostname === '127.0.0.1' ||
1330
+ parsed.hostname === '::1' ||
1331
+ parsed.hostname === '[::1]'
1332
+ );
1333
+ } catch {
1334
+ return false;
1335
+ }
1336
+ }
1337
+
1338
+ /**
1339
+ * The response for an unexpected internal failure: a stable code plus a
1340
+ * correlation identifier that also appears on the log record. Outside development
1341
+ * the exception text is never part of the body — it goes to the log only.
1342
+ */
1343
+ private internalFailure(err: unknown, pathname: string, cors?: Record<string, string> | null): Response {
1344
+ const correlationId = crypto.randomUUID();
1345
+ const message = err instanceof Error ? err.message : String(err);
1346
+
1347
+ console.error(`[SynapseServer] Falha interna [${correlationId}] em ${pathname}: ${message}`);
1348
+
1349
+ return Response.json(
1350
+ {
1351
+ ok: false,
1352
+ error: 'INTERNAL_ERROR',
1353
+ correlationId,
1354
+ ...(isProductionLikeMode() ? {} : { message })
1355
+ },
1356
+ { status: 500, headers: cors ?? undefined }
1357
+ );
1358
+ }
1359
+
1360
+ /**
1361
+ * Returns stylesheet links. The application's own stylesheet always wins; nothing
1362
+ * third-party is referenced unless explicitly enabled and integrity-pinned.
750
1363
  */
751
1364
  private renderStylesheets(): string {
752
- const disableCdn = process.env.SYNAPSE_DISABLE_CDN === 'true' || process.env.SYNAPSE_DISABLE_CDN === '1';
753
1365
  const localCssPath = path.join(this.baseDir, 'public', 'synapse.css');
754
- const hasLocalCss = fs.existsSync(localCssPath);
1366
+
1367
+ if (fs.existsSync(localCssPath)) {
1368
+ return ' <link rel="stylesheet" href="/synapse.css">';
1369
+ }
1370
+
1371
+ const compiledCssPath = path.join(this.baseDir, '.synapse', 'client', 'synapse.css');
1372
+ if (fs.existsSync(compiledCssPath)) {
1373
+ return ' <link rel="stylesheet" href="/_synapse/client/synapse.css">';
1374
+ }
1375
+
1376
+ const thirdParty = this.thirdPartyOrigins();
1377
+
1378
+ if (!thirdParty.enabled) {
1379
+ return '';
1380
+ }
755
1381
 
756
1382
  const elements: string[] = [];
757
- if (!disableCdn) {
758
- elements.push(' <script src="https://cdn.tailwindcss.com"></script>');
1383
+
1384
+ if (thirdParty.script) {
1385
+ elements.push(
1386
+ ` <script src="https://cdn.tailwindcss.com" integrity="${thirdParty.script}" crossorigin="anonymous"></script>`
1387
+ );
1388
+ } else {
1389
+ console.warn(
1390
+ '[SynapseServer] CDN habilitado sem um hash de integridade (SYNAPSE_CDN_INTEGRITY): o script de terceiros não será emitido.'
1391
+ );
1392
+ }
1393
+
1394
+ if (thirdParty.fontStyles) {
759
1395
  elements.push(' <link rel="preconnect" href="https://fonts.googleapis.com">');
760
1396
  elements.push(' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
761
1397
  elements.push(
762
- ' <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">'
1398
+ ` <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" integrity="${thirdParty.fontStyles}" crossorigin="anonymous">`
763
1399
  );
764
1400
  }
765
- if (hasLocalCss) {
766
- elements.push(' <link rel="stylesheet" href="/synapse.css">');
767
- }
1401
+
768
1402
  return elements.join('\n');
769
1403
  }
770
1404
 
1405
+ /**
1406
+ * Unified Social & Enterprise OAuth 2.0 Handler (RFC 7636 PKCE + Constant-Time State Verification)
1407
+ */
1408
+ private async handleOAuthRequest(
1409
+ req: Request,
1410
+ providerName: string,
1411
+ isCallback: boolean,
1412
+ url: URL,
1413
+ startedAt: number
1414
+ ): Promise<Response> {
1415
+ const cors = this.corsHeaders(req);
1416
+ const validProviders = ['google', 'github', 'discord'];
1417
+
1418
+ if (!validProviders.includes(providerName)) {
1419
+ this.logRequest(req, url.pathname, 404, startedAt);
1420
+ return Response.json(
1421
+ { ok: false, error: `Provedor OAuth "${providerName}" não suportado. Aceitos: ${validProviders.join(', ')}` },
1422
+ { status: 404, headers: cors ?? undefined }
1423
+ );
1424
+ }
1425
+
1426
+ const redirectUri = `${url.origin}/_synapse/auth/${providerName}/callback`;
1427
+ const clientIdEnv = process.env[`${providerName.toUpperCase()}_CLIENT_ID`] || '';
1428
+ const clientSecretEnv = process.env[`${providerName.toUpperCase()}_CLIENT_SECRET`] || '';
1429
+
1430
+ if (!clientIdEnv) {
1431
+ this.logRequest(req, url.pathname, 500, startedAt);
1432
+ return Response.json(
1433
+ {
1434
+ ok: false,
1435
+ error: `Variável de ambiente ${providerName.toUpperCase()}_CLIENT_ID não configurada no servidor.`
1436
+ },
1437
+ { status: 500, headers: cors ?? undefined }
1438
+ );
1439
+ }
1440
+
1441
+ let config: OAuthProviderConfig;
1442
+ if (providerName === 'google') {
1443
+ config = googleOAuthPreset({ clientId: clientIdEnv, clientSecret: clientSecretEnv, redirectUri });
1444
+ } else if (providerName === 'github') {
1445
+ config = githubOAuthPreset({ clientId: clientIdEnv, clientSecret: clientSecretEnv, redirectUri });
1446
+ } else {
1447
+ config = discordOAuthPreset({ clientId: clientIdEnv, clientSecret: clientSecretEnv, redirectUri });
1448
+ }
1449
+
1450
+ // 1. INICIAR FLUXO: GET /_synapse/auth/:provider
1451
+ if (!isCallback) {
1452
+ const pkce = generatePkceChallenge();
1453
+ const state = generateOAuthState();
1454
+ const authUrl = buildOAuthAuthorizationUrl({
1455
+ config,
1456
+ state,
1457
+ codeChallenge: pkce.codeChallenge
1458
+ });
1459
+
1460
+ const statePayload = JSON.stringify({ state, codeVerifier: pkce.codeVerifier, provider: providerName });
1461
+ const stateCookie = serializeCookie('__synapse_oauth_state', statePayload, {
1462
+ httpOnly: true,
1463
+ secure: !url.hostname.includes('localhost') && !url.hostname.includes('127.0.0.1'),
1464
+ sameSite: 'lax',
1465
+ maxAge: 600,
1466
+ path: '/'
1467
+ });
1468
+
1469
+ this.logRequest(req, url.pathname, 302, startedAt);
1470
+ return new Response(null, {
1471
+ status: 302,
1472
+ headers: {
1473
+ Location: authUrl,
1474
+ 'Set-Cookie': stateCookie,
1475
+ ...(cors ?? {})
1476
+ }
1477
+ });
1478
+ }
1479
+
1480
+ // 2. CALLBACK: GET /_synapse/auth/:provider/callback
1481
+ const code = url.searchParams.get('code');
1482
+ const receivedState = url.searchParams.get('state');
1483
+
1484
+ const cookieHeader = req.headers.get('cookie') || '';
1485
+ const match = cookieHeader.match(/__synapse_oauth_state=([^;]+)/);
1486
+ let cookieStateData: { state: string; codeVerifier: string; provider: string } | null = null;
1487
+
1488
+ if (match) {
1489
+ try {
1490
+ cookieStateData = JSON.parse(decodeURIComponent(match[1]));
1491
+ } catch {}
1492
+ }
1493
+
1494
+ if (!code || !receivedState || !cookieStateData || !verifyOAuthState(cookieStateData.state, receivedState)) {
1495
+ this.logRequest(req, url.pathname, 400, startedAt);
1496
+ return Response.json(
1497
+ { ok: false, error: 'INVALID_OAUTH_STATE', message: 'Parâmetro de estado OAuth inválido ou expirado.' },
1498
+ { status: 400, headers: cors ?? undefined }
1499
+ );
1500
+ }
1501
+
1502
+ const exchangeRes = await exchangeOAuthCode({
1503
+ config,
1504
+ code,
1505
+ codeVerifier: cookieStateData.codeVerifier,
1506
+ timeoutMs: 10_000
1507
+ });
1508
+
1509
+ if (!exchangeRes.ok) {
1510
+ this.logRequest(req, url.pathname, 502, startedAt);
1511
+ return Response.json(
1512
+ { ok: false, error: 'OAUTH_EXCHANGE_FAILED', message: 'Falha ao trocar código de autorização por token.' },
1513
+ { status: 502, headers: cors ?? undefined }
1514
+ );
1515
+ }
1516
+
1517
+ const profileRes = await fetchOAuthUserProfile(
1518
+ providerName as 'google' | 'github' | 'discord',
1519
+ exchangeRes.value.accessToken
1520
+ );
1521
+
1522
+ if (!profileRes.ok) {
1523
+ this.logRequest(req, url.pathname, 502, startedAt);
1524
+ return Response.json(
1525
+ { ok: false, error: 'OAUTH_PROFILE_FAILED', message: 'Falha ao recuperar perfil do usuário no provedor.' },
1526
+ { status: 502, headers: cors ?? undefined }
1527
+ );
1528
+ }
1529
+
1530
+ const profile = profileRes.value;
1531
+ const secret = process.env.SYNAPSE_SESSION_SECRET || 'dev_secret_insecure_change_in_production';
1532
+ const sessionToken = signSessionToken(
1533
+ {
1534
+ userId: `${providerName}:${profile.id}`,
1535
+ roles: ['user'],
1536
+ permissions: ['read', 'write']
1537
+ },
1538
+ secret
1539
+ );
1540
+
1541
+ const sessionCookieHeader = serializeCookie(SESSION_COOKIE, sessionToken, {
1542
+ httpOnly: true,
1543
+ secure: !url.hostname.includes('localhost') && !url.hostname.includes('127.0.0.1'),
1544
+ sameSite: 'lax',
1545
+ maxAge: 86400,
1546
+ path: '/'
1547
+ });
1548
+ const clearStateCookie = serializeCookie('__synapse_oauth_state', '', {
1549
+ httpOnly: true,
1550
+ maxAge: 0,
1551
+ path: '/'
1552
+ });
1553
+
1554
+ this.logRequest(req, url.pathname, 302, startedAt);
1555
+ return new Response(null, {
1556
+ status: 302,
1557
+ headers: {
1558
+ Location: '/',
1559
+ 'Set-Cookie': `${sessionCookieHeader}, ${clearStateCookie}`,
1560
+ ...(cors ?? {})
1561
+ }
1562
+ });
1563
+ }
1564
+
771
1565
  /**
772
1566
  * Renders the slice SSR component tree and packages the complete HTML shell
773
1567
  */
@@ -775,7 +1569,8 @@ export class SynapseServer {
775
1569
  slice: DiscoveredSlice,
776
1570
  req: Request,
777
1571
  url: URL,
778
- matchedLocale?: string
1572
+ matchedLocale?: string,
1573
+ routeParams: Record<string, string> = {}
779
1574
  ): Promise<{
780
1575
  html: string;
781
1576
  dataProps: Record<string, unknown>;
@@ -783,11 +1578,13 @@ export class SynapseServer {
783
1578
  hasError?: boolean;
784
1579
  error?: string;
785
1580
  }> {
786
- const session = this.sessionFrom(req);
787
- const actionCtx = this.createActionContext(slice.name, session);
788
- const params = Object.fromEntries(url.searchParams.entries());
1581
+ const session = await this.requestSession(req);
1582
+ const actionCtx = this.createActionContext(slice.key, session);
1583
+ const searchParams = Object.fromEntries(url.searchParams.entries());
1584
+ const params = { ...searchParams, ...routeParams };
789
1585
  const dataProps: Record<string, unknown> = {
790
1586
  ...params,
1587
+ __synapse_params: routeParams,
791
1588
  ...(matchedLocale ? { locale: matchedLocale } : {})
792
1589
  };
793
1590
  let loaderError: string | null = null;
@@ -835,9 +1632,10 @@ export class SynapseServer {
835
1632
  let hasError = false;
836
1633
  let renderError: string | null = loaderError;
837
1634
 
838
- const isProduction = process.env.NODE_ENV === 'production';
1635
+ const isProduction = isProductionLikeMode();
839
1636
  if (loaderError) {
840
1637
  hasError = true;
1638
+ console.error(`[SynapseServer] Loader de ${slice.key} falhou: ${loaderError}`);
841
1639
  const displayMsg = isProduction ? 'Ocorreu um erro interno ao carregar os dados.' : escapeHtml(loaderError);
842
1640
  contentHtml = `<div class="text-rose-400">Falha no loader de ${escapeHtml(slice.key)}: ${displayMsg}</div>`;
843
1641
  } else if (slice.componentFn) {
@@ -850,12 +1648,17 @@ export class SynapseServer {
850
1648
  const treeWithLayout = this.layoutComponent
851
1649
  ? React.createElement(this.layoutComponent, { session, url: url.toString() }, treeWithDomainLayout)
852
1650
  : treeWithDomainLayout;
853
- const rootEl = React.createElement(SynapseProvider, { props: dataProps, session }, treeWithLayout);
1651
+ const treeWithToast = React.createElement(ToastProvider, null, treeWithLayout);
1652
+ const rootEl = React.createElement(
1653
+ SynapseProvider,
1654
+ { props: dataProps, session, url: url.toString() },
1655
+ treeWithToast
1656
+ );
854
1657
  contentHtml = renderToString(rootEl);
855
1658
  } catch (e: any) {
856
- if (!isProduction) console.error('[SSR Render Error]', e);
857
1659
  hasError = true;
858
1660
  const errMessage = e instanceof Error ? e.message : String(e);
1661
+ console.error(`[SynapseServer] Erro na renderização SSR de ${slice.key}: ${errMessage}`);
859
1662
  renderError = errMessage;
860
1663
  const displayMsg = isProduction ? 'Ocorreu um erro interno durante a renderização.' : escapeHtml(errMessage);
861
1664
  contentHtml = `<div class="text-rose-400">Erro na renderização SSR: ${displayMsg}</div>`;
@@ -875,7 +1678,9 @@ export class SynapseServer {
875
1678
  clientUrl,
876
1679
  meta,
877
1680
  matchedLocale || 'pt-BR',
878
- hasCustomLayout
1681
+ hasCustomLayout,
1682
+ routeParams,
1683
+ url.toString()
879
1684
  );
880
1685
 
881
1686
  return { html, dataProps, meta, hasError, error: renderError || undefined };
@@ -892,7 +1697,9 @@ export class SynapseServer {
892
1697
  clientUrl: string | null,
893
1698
  meta?: SliceMetadata,
894
1699
  locale = 'pt-BR',
895
- hasLayout = false
1700
+ hasLayout = false,
1701
+ routeParams: Record<string, string> = {},
1702
+ currentUrl = '/'
896
1703
  ): string {
897
1704
  const safeTitle = escapeHtml(title);
898
1705
  const safeSliceName = escapeHtml(sliceName);
@@ -960,7 +1767,7 @@ ${importMapScript}
960
1767
  <div id="synapse-root" class="min-h-full flex-1 flex flex-col">${contentHtml}</div>
961
1768
 
962
1769
  <script data-synapse-props id="__SYNAPSE_PROPS_DATA__" type="application/json">${propsJson}</script>
963
- <script>globalThis.__SYNAPSE_PROPS__ = JSON.parse(document.getElementById('__SYNAPSE_PROPS_DATA__').textContent || '{}');</script>
1770
+ <script>globalThis.__SYNAPSE_PROPS__ = JSON.parse((document.querySelector('script[data-synapse-props][id="__SYNAPSE_PROPS_DATA__"]') || {}).textContent || '{}'); globalThis.__SYNAPSE_ROUTE_PARAMS__ = ${JSON.stringify(routeParams)}; globalThis.__SYNAPSE_URL__ = ${JSON.stringify(currentUrl)};</script>
964
1771
  ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
965
1772
 
966
1773
  <script type="module" src="/_synapse/turbo-router.js"></script>
@@ -1017,7 +1824,7 @@ ${importMapScript}
1017
1824
 
1018
1825
  <!-- Props de hidratacao e a entrada de cliente gerada -->
1019
1826
  <script data-synapse-props id="__SYNAPSE_PROPS_DATA__" type="application/json">${propsJson}</script>
1020
- <script>globalThis.__SYNAPSE_PROPS__ = JSON.parse(document.getElementById('__SYNAPSE_PROPS_DATA__').textContent || '{}');</script>
1827
+ <script>globalThis.__SYNAPSE_PROPS__ = JSON.parse((document.querySelector('script[data-synapse-props][id="__SYNAPSE_PROPS_DATA__"]') || {}).textContent || '{}'); globalThis.__SYNAPSE_ROUTE_PARAMS__ = ${JSON.stringify(routeParams)}; globalThis.__SYNAPSE_URL__ = ${JSON.stringify(currentUrl)};</script>
1021
1828
  ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
1022
1829
 
1023
1830
  <!-- Turbo Morphing SPA Navigation (<1.5kb, Zero Dependencies) -->
@@ -1150,8 +1957,17 @@ ${this.renderStylesheets()}
1150
1957
  }
1151
1958
  }
1152
1959
 
1960
+ /**
1961
+ * Process-level ceiling on any request body. It is the backstop behind the
1962
+ * per-route limits (RPC 5 MiB, webhook 10 MiB, upload 5 MiB): every body-reading
1963
+ * path is bounded even before its own guard runs, so no single request can
1964
+ * exhaust process memory.
1965
+ */
1966
+ const processBodyCeiling = maxRequestBodyBytes(this.config);
1967
+
1153
1968
  this.httpServer = Bun.serve({
1154
1969
  port: this.port,
1970
+ maxRequestBodySize: processBodyCeiling,
1155
1971
  websocket: {
1156
1972
  open: async (ws: any) => {
1157
1973
  const socketDef = ws.data?.socketDef;
@@ -1182,6 +1998,10 @@ ${this.renderStylesheets()}
1182
1998
  }
1183
1999
  },
1184
2000
  close: async (ws: any, code: number, reason: string) => {
2001
+ if (ws.data?.clientKey) {
2002
+ this.releaseRealtimeConnection(ws.data.clientKey);
2003
+ }
2004
+
1185
2005
  const socketDef = ws.data?.socketDef;
1186
2006
  if (socketDef?.onClose) {
1187
2007
  try {
@@ -1193,11 +2013,24 @@ ${this.renderStylesheets()}
1193
2013
  }
1194
2014
  },
1195
2015
  fetch: async (req: Request) => {
2016
+ // Every request that has begun handling and has not produced a response is
2017
+ // outstanding work the drain waits for. An unbounded event stream is not:
2018
+ // it is tracked separately and closed when shutdown begins.
2019
+ this.inFlightCount++;
2020
+ let released = false;
2021
+ const releaseInFlight = () => {
2022
+ if (!released) {
2023
+ released = true;
2024
+ this.inFlightCount--;
2025
+ }
2026
+ };
2027
+
1196
2028
  if (this.config.plugins) {
1197
2029
  for (const plugin of this.config.plugins) {
1198
2030
  if (plugin.onRequest) {
1199
2031
  const intercepted = await plugin.onRequest(req);
1200
2032
  if (intercepted instanceof Response) {
2033
+ releaseInFlight();
1201
2034
  return intercepted;
1202
2035
  }
1203
2036
  }
@@ -1211,50 +2044,43 @@ ${this.renderStylesheets()}
1211
2044
 
1212
2045
  // WebSocket Upgrade for slices: /_synapse/ws/:domain/:name
1213
2046
  if (pathname.startsWith('/_synapse/ws/')) {
1214
- const origin = req.headers.get('origin');
1215
- const allowed = (process.env.SYNAPSE_ALLOWED_ORIGINS ?? '')
1216
- .split(',')
1217
- .map((entry) => entry.trim())
1218
- .filter((entry) => entry.length > 0);
1219
- if (origin && allowed.length > 0 && !allowed.includes(origin) && !allowed.includes('*')) {
1220
- return new Response(JSON.stringify({ error: 'Origin não permitida para conexão WebSocket' }), {
1221
- status: 403,
1222
- headers: { 'Content-Type': 'application/json' }
1223
- });
2047
+ // Fail-closed: no allow-list means no upgrade, with a loopback carve-out
2048
+ // for local development. An absent or opaque origin is refused too.
2049
+ if (!this.webSocketOriginAllowed(req)) {
2050
+ this.logRequest(req, pathname, 403, startedAt);
2051
+ return this.realtimeRefusal(
2052
+ 'ORIGIN_NOT_ALLOWED',
2053
+ 403,
2054
+ 'Origem não permitida para conexão WebSocket.',
2055
+ this.corsHeaders(req)
2056
+ );
1224
2057
  }
1225
2058
 
1226
- const trustProxy = process.env.SYNAPSE_TRUST_PROXY !== 'false' && this.config.trustProxy !== false;
1227
- let clientIp: string;
1228
- if (trustProxy) {
1229
- clientIp =
1230
- req.headers.get('cf-connecting-ip') ||
1231
- req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
1232
- this.httpServer?.requestIP(req)?.address ||
1233
- '127.0.0.1';
1234
- } else {
1235
- clientIp = this.httpServer?.requestIP(req)?.address || '127.0.0.1';
1236
- }
1237
- const rateResult = this.rateLimiter.consume(clientIp);
1238
- if (!rateResult.allowed) {
1239
- return new Response(
1240
- JSON.stringify({
1241
- ok: false,
1242
- error: 'RATE_LIMIT_EXCEEDED',
1243
- message: 'Muitas conexões WebSocket. Tente novamente mais tarde.'
1244
- }),
1245
- {
1246
- status: 429,
1247
- headers: {
1248
- 'Content-Type': 'application/json; charset=utf-8',
1249
- 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000))
1250
- }
1251
- }
2059
+ const rateRefusal = this.rateLimitRefusal(req, pathname, startedAt);
2060
+
2061
+ if (rateRefusal) {
2062
+ return rateRefusal;
2063
+ }
2064
+
2065
+ const wsBounds = this.realtimeBounds();
2066
+ const wsClientKey = this.clientIdentity(req);
2067
+ const wsOpen = this.realtimeConnections.get(wsClientKey) ?? 0;
2068
+
2069
+ if (wsOpen >= wsBounds.maxConnectionsPerClient) {
2070
+ this.logRequest(req, pathname, 429, startedAt);
2071
+ return this.realtimeRefusal(
2072
+ 'REALTIME_LIMIT_EXCEEDED',
2073
+ 429,
2074
+ 'Limite de conexões simultâneas por cliente atingido.'
1252
2075
  );
1253
2076
  }
1254
2077
 
2078
+ this.realtimeConnections.set(wsClientKey, wsOpen + 1);
2079
+
1255
2080
  const target = pathname.slice('/_synapse/ws/'.length);
1256
2081
  const resolved = this.resolveSlice(target);
1257
2082
  if (!resolved.ok) {
2083
+ this.releaseRealtimeConnection(wsClientKey);
1258
2084
  return new Response(JSON.stringify({ error: resolved.message }), {
1259
2085
  status: resolved.status,
1260
2086
  headers: { 'Content-Type': 'application/json' }
@@ -1262,23 +2088,27 @@ ${this.renderStylesheets()}
1262
2088
  }
1263
2089
  const socketDef = resolved.slice.socketDef;
1264
2090
  if (!socketDef) {
2091
+ this.releaseRealtimeConnection(wsClientKey);
1265
2092
  return new Response(JSON.stringify({ error: `Fatia ${resolved.slice.key} não declara sliceSocket` }), {
1266
2093
  status: 404,
1267
2094
  headers: { 'Content-Type': 'application/json' }
1268
2095
  });
1269
2096
  }
1270
- const session = this.sessionFrom(req);
2097
+ const session = await this.requestSession(req);
1271
2098
  const upgraded = (this.httpServer as any)?.upgrade(req, {
1272
2099
  data: {
1273
2100
  id: crypto.randomUUID(),
1274
2101
  sliceKey: resolved.slice.key,
1275
2102
  socketDef,
1276
- session
2103
+ session,
2104
+ clientKey: wsClientKey
1277
2105
  }
1278
2106
  });
1279
2107
  if (upgraded) {
1280
2108
  return undefined as any;
1281
2109
  }
2110
+
2111
+ this.releaseRealtimeConnection(wsClientKey);
1282
2112
  return new Response('Falha no upgrade de WebSocket', { status: 400 });
1283
2113
  }
1284
2114
 
@@ -1308,6 +2138,18 @@ ${this.renderStylesheets()}
1308
2138
  });
1309
2139
  }
1310
2140
 
2141
+ // 1.5 Rate Limiter for every accepting surface (machine endpoints, RPC,
2142
+ // uploads, webhooks, SSE, images). Server-rendered page loads are covered
2143
+ // separately, right before the render begins. Static files, the dashboard
2144
+ // and the generated client bundles are not part of this budget.
2145
+ if (RATE_LIMITED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
2146
+ const refusal = this.rateLimitRefusal(req, pathname, startedAt);
2147
+
2148
+ if (refusal) {
2149
+ return refusal;
2150
+ }
2151
+ }
2152
+
1311
2153
  // 2. Machine Endpoints for AI
1312
2154
  if (pathname === '/_synapse/api/repo-map') {
1313
2155
  const repoMapPath = path.join(this.baseDir, '.codebase/repo-map.d.ts');
@@ -1335,28 +2177,34 @@ ${this.renderStylesheets()}
1335
2177
  const healthy = dbHealthy && !this.discoveryError;
1336
2178
  const status = healthy ? 'OK' : 'DEGRADED';
1337
2179
  const httpStatus = healthy ? 200 : 503;
2180
+ const session = await this.requestSession(req);
2181
+
2182
+ // This endpoint requires no authentication, so it discloses only the
2183
+ // aggregate verdict: no absolute path, no discovery candidate, no load-error
2184
+ // text and no route inventory. The detailed inventory is authenticated.
2185
+ const payload: Record<string, unknown> = {
2186
+ status,
2187
+ framework: 'SynapseJS',
2188
+ version: SYNAPSE_VERSION,
2189
+ uptime: process.uptime(),
2190
+ database: dbHealthy ? 'connected' : 'disconnected',
2191
+ slicesLoaded: this.slices.size
2192
+ };
1338
2193
 
1339
- this.logRequest(req, pathname, httpStatus, startedAt);
1340
- return Response.json(
1341
- {
1342
- status,
1343
- framework: 'SynapseJS',
1344
- version: SYNAPSE_VERSION,
1345
- uptime: process.uptime(),
1346
- database: dbHealthy ? 'connected' : 'disconnected',
1347
- ...(dbError ? { databaseError: dbError } : {}),
1348
- slicesLoaded: this.slices.size,
1349
- slicesResolutionError: this.discoveryError,
1350
- loadErrors: this.loadErrors,
1351
- slices: Array.from(this.slices.values()).map((s) => ({
1352
- domain: s.domain,
1353
- name: s.name,
1354
- route: s.routePath,
1355
- rpc: s.rpcPath
1356
- }))
1357
- },
1358
- { status: httpStatus }
1359
- );
2194
+ if (session.isAuthenticated) {
2195
+ payload.databaseError = dbError;
2196
+ payload.slicesResolutionError = this.discoveryError;
2197
+ payload.loadErrors = this.loadErrors;
2198
+ payload.slices = Array.from(this.slices.values()).map((s) => ({
2199
+ domain: s.domain,
2200
+ name: s.name,
2201
+ route: s.routePath,
2202
+ rpc: s.rpcPath
2203
+ }));
2204
+ }
2205
+
2206
+ this.logRequest(req, pathname, httpStatus, startedAt, session);
2207
+ return Response.json(payload, { status: httpStatus });
1360
2208
  }
1361
2209
 
1362
2210
  if (pathname === '/_synapse/api/metrics') {
@@ -1367,26 +2215,27 @@ ${this.renderStylesheets()}
1367
2215
  this.logRequest(req, pathname, 200, startedAt);
1368
2216
 
1369
2217
  if (formatParam === 'prometheus' || acceptHeader.includes('text/plain')) {
1370
- const promText =
1371
- [
1372
- '# HELP synapse_uptime_seconds SynapseJS server uptime in seconds',
1373
- '# TYPE synapse_uptime_seconds gauge',
1374
- `synapse_uptime_seconds ${uptimeSeconds}`,
1375
- '# HELP synapse_requests_total Total number of HTTP requests received',
1376
- '# TYPE synapse_requests_total counter',
1377
- `synapse_requests_total ${this.metrics.totalRequests}`,
1378
- '# HELP synapse_rpc_success_total Total successful RPC action calls',
1379
- '# TYPE synapse_rpc_success_total counter',
1380
- `synapse_rpc_success_total ${this.metrics.rpcSuccessCount}`,
1381
- '# HELP synapse_rpc_error_total Total failed RPC action calls',
1382
- '# TYPE synapse_rpc_error_total counter',
1383
- `synapse_rpc_error_total ${this.metrics.rpcErrorCount}`,
1384
- '# HELP synapse_ssr_renders_total Total server-side rendered pages',
1385
- '# TYPE synapse_ssr_renders_total counter',
1386
- `synapse_ssr_renders_total ${this.metrics.ssrRenderCount}`
1387
- ].join('\n') + '\n';
1388
-
1389
- return new Response(promText, {
2218
+ const promText = [
2219
+ '# HELP synapse_uptime_seconds SynapseJS server uptime in seconds',
2220
+ '# TYPE synapse_uptime_seconds gauge',
2221
+ `synapse_uptime_seconds ${uptimeSeconds}`,
2222
+ '# HELP synapse_requests_total Total number of HTTP requests received',
2223
+ '# TYPE synapse_requests_total counter',
2224
+ `synapse_requests_total ${this.metrics.totalRequests}`,
2225
+ '# HELP synapse_rpc_success_total Total successful RPC action calls',
2226
+ '# TYPE synapse_rpc_success_total counter',
2227
+ `synapse_rpc_success_total ${this.metrics.rpcSuccessCount}`,
2228
+ '# HELP synapse_rpc_error_total Total failed RPC action calls',
2229
+ '# TYPE synapse_rpc_error_total counter',
2230
+ `synapse_rpc_error_total ${this.metrics.rpcErrorCount}`,
2231
+ '# HELP synapse_ssr_renders_total Total server-side rendered pages',
2232
+ '# TYPE synapse_ssr_renders_total counter',
2233
+ `synapse_ssr_renders_total ${this.metrics.ssrRenderCount}`
2234
+ ].join('\n');
2235
+
2236
+ const promTextWithNewline = `${promText}\n`;
2237
+
2238
+ return new Response(promTextWithNewline, {
1390
2239
  headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }
1391
2240
  });
1392
2241
  }
@@ -1402,49 +2251,15 @@ ${this.renderStylesheets()}
1402
2251
  });
1403
2252
  }
1404
2253
 
1405
- // 3. Rate Limiter for Sensitive Endpoints (RPC, Uploads, Webhooks, SSE)
1406
- if (
1407
- pathname.startsWith('/_synapse/rpc/') ||
1408
- pathname.startsWith('/_synapse/files/') ||
1409
- pathname.startsWith('/_synapse/webhooks/') ||
1410
- pathname.startsWith('/_synapse/sse/')
1411
- ) {
1412
- const trustProxy = process.env.SYNAPSE_TRUST_PROXY !== 'false' && this.config.trustProxy !== false;
1413
- let clientIp: string;
1414
- if (trustProxy) {
1415
- clientIp =
1416
- req.headers.get('cf-connecting-ip') ||
1417
- req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
1418
- this.httpServer?.requestIP(req)?.address ||
1419
- '127.0.0.1';
1420
- } else {
1421
- clientIp = this.httpServer?.requestIP(req)?.address || '127.0.0.1';
1422
- }
1423
- const clientKey = clientIp;
1424
-
1425
- const rateResult = this.rateLimiter.consume(clientKey);
1426
- if (!rateResult.allowed) {
1427
- const cors = this.corsHeaders(req);
1428
- this.logRequest(req, pathname, 429, startedAt);
1429
- return new Response(
1430
- JSON.stringify({
1431
- ok: false,
1432
- error: 'RATE_LIMIT_EXCEEDED',
1433
- message: 'Muitas requisições. Tente novamente mais tarde.'
1434
- }),
1435
- {
1436
- status: 429,
1437
- headers: {
1438
- 'Content-Type': 'application/json; charset=utf-8',
1439
- 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000)),
1440
- 'X-RateLimit-Limit': String(rateResult.limit),
1441
- 'X-RateLimit-Remaining': String(rateResult.remaining),
1442
- 'X-RateLimit-Reset': String(Math.ceil((Date.now() + rateResult.resetMs) / 1000)),
1443
- ...(cors ?? {})
1444
- }
1445
- }
1446
- );
1447
- }
2254
+ // Unified Social & Enterprise OAuth 2.0 Dispatcher: /_synapse/auth/:provider and /_synapse/auth/:provider/callback
2255
+ if (pathname.startsWith('/_synapse/auth/')) {
2256
+ const authPath = pathname.slice('/_synapse/auth/'.length);
2257
+ const isCallback = authPath.endsWith('/callback');
2258
+ const providerName = isCallback
2259
+ ? authPath.slice(0, -'/callback'.length).toLowerCase()
2260
+ : authPath.toLowerCase();
2261
+
2262
+ return await this.handleOAuthRequest(req, providerName, isCallback, url, startedAt);
1448
2263
  }
1449
2264
 
1450
2265
  // 3. RPC Actions Dispatcher (POST /_synapse/rpc/:sliceName)
@@ -1534,8 +2349,8 @@ ${this.renderStylesheets()}
1534
2349
  }
1535
2350
 
1536
2351
  try {
1537
- const session = this.sessionFrom(req);
1538
- const actionCtx = this.createActionContext(slice.name, session);
2352
+ const session = await this.requestSession(req);
2353
+ const actionCtx = this.createActionContext(slice.key, session);
1539
2354
 
1540
2355
  const result = await slice.actionFn(body, actionCtx, session, actionCtx);
1541
2356
  const status = result.ok ? 200 : httpStatusForError(result.error);
@@ -1552,9 +2367,9 @@ ${this.renderStylesheets()}
1552
2367
  }
1553
2368
 
1554
2369
  return new Response(JSON.stringify(result), { status, headers });
1555
- } catch (err: any) {
2370
+ } catch (err: unknown) {
1556
2371
  this.logRequest(req, pathname, 500, startedAt, undefined, 'rpc_error');
1557
- return Response.json({ ok: false, error: `Falha interna no RPC: ${err.message}` }, { status: 500 });
2372
+ return this.internalFailure(err, pathname, cors);
1558
2373
  }
1559
2374
  }
1560
2375
 
@@ -1628,8 +2443,8 @@ ${this.renderStylesheets()}
1628
2443
  json = null;
1629
2444
  }
1630
2445
 
1631
- const session = this.sessionFrom(req);
1632
- const actionCtx = this.createActionContext(slice.name, session);
2446
+ const session = await this.requestSession(req);
2447
+ const actionCtx = this.createActionContext(slice.key, session);
1633
2448
 
1634
2449
  const result = await slice.webhookFn(
1635
2450
  {
@@ -1649,12 +2464,8 @@ ${this.renderStylesheets()}
1649
2464
  }
1650
2465
  return Response.json(result, { status, headers: cors ?? undefined });
1651
2466
  } catch (err: unknown) {
1652
- const message = err instanceof Error ? err.message : String(err);
1653
2467
  this.logRequest(req, pathname, 500, startedAt);
1654
- return Response.json(
1655
- { ok: false, error: `Falha no processamento de webhook: ${message}` },
1656
- { status: 500 }
1657
- );
2468
+ return this.internalFailure(err, pathname, cors);
1658
2469
  }
1659
2470
  }
1660
2471
 
@@ -1674,11 +2485,64 @@ ${this.renderStylesheets()}
1674
2485
  const topic = decodeURIComponent(rawTopic);
1675
2486
 
1676
2487
  if (!topic) {
1677
- return Response.json({ ok: false, error: 'Tópico de subscrição inválido.' }, { status: 400 });
2488
+ return this.realtimeRefusal('TOPIC_NOT_FOUND', 404, 'Tópico de subscrição inválido.', cors);
2489
+ }
2490
+
2491
+ const session = await this.requestSession(req);
2492
+
2493
+ // Access is declared, not inferred: a name is not a permission.
2494
+ const declaration = declaredTopic(topic, session.tenantId);
2495
+
2496
+ if (!session.isAuthenticated && !declaration?.public) {
2497
+ return this.realtimeRefusal('UNAUTHENTICATED', 401, 'Subscrição requer uma sessão autenticada.', cors);
2498
+ }
2499
+
2500
+ if (!declaration) {
2501
+ return this.realtimeRefusal('TOPIC_NOT_FOUND', 404, 'Tópico não declarado.', cors);
2502
+ }
2503
+
2504
+ if (declaration.readRoles?.length && !hasAnyRole(session, declaration.readRoles)) {
2505
+ return this.realtimeRefusal('TOPIC_FORBIDDEN', 403, 'Sessão sem papel para este tópico.', cors);
2506
+ }
2507
+
2508
+ const bounds = this.realtimeBounds();
2509
+
2510
+ if (this.openStreams.size >= bounds.maxSubscriptions) {
2511
+ return this.realtimeRefusal(
2512
+ 'REALTIME_LIMIT_EXCEEDED',
2513
+ 429,
2514
+ 'Limite de subscrições simultâneas atingido.',
2515
+ cors
2516
+ );
1678
2517
  }
1679
2518
 
2519
+ const effectiveTopic = scopedTopic(topic, session.tenantId);
1680
2520
  const eventHub = getEventHub();
1681
- const session = this.sessionFrom(req);
2521
+ const isNewTopic = eventHub.listenerCount(effectiveTopic) === 0;
2522
+
2523
+ if (isNewTopic && eventHub.activeTopics().length >= bounds.maxTopics) {
2524
+ return this.realtimeRefusal(
2525
+ 'REALTIME_LIMIT_EXCEEDED',
2526
+ 429,
2527
+ 'Limite de tópicos registrados atingido.',
2528
+ cors
2529
+ );
2530
+ }
2531
+
2532
+ const clientKey = this.clientIdentity(req);
2533
+ const openForClient = this.realtimeConnections.get(clientKey) ?? 0;
2534
+
2535
+ if (openForClient >= bounds.maxConnectionsPerClient) {
2536
+ return this.realtimeRefusal(
2537
+ 'REALTIME_LIMIT_EXCEEDED',
2538
+ 429,
2539
+ 'Limite de conexões simultâneas por cliente atingido.',
2540
+ cors
2541
+ );
2542
+ }
2543
+
2544
+ this.realtimeConnections.set(clientKey, openForClient + 1);
2545
+
1682
2546
  let unsubscribe: (() => void) | null = null;
1683
2547
  let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
1684
2548
 
@@ -1691,12 +2555,26 @@ ${this.renderStylesheets()}
1691
2555
  unsubscribe();
1692
2556
  unsubscribe = null;
1693
2557
  }
2558
+ const open = this.realtimeConnections.get(clientKey) ?? 0;
2559
+ if (open <= 1) {
2560
+ this.realtimeConnections.delete(clientKey);
2561
+ } else {
2562
+ this.realtimeConnections.set(clientKey, open - 1);
2563
+ }
1694
2564
  };
1695
2565
 
1696
2566
  req.signal.addEventListener('abort', cleanup);
1697
2567
 
1698
- const stream = new ReadableStream({
2568
+ let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
2569
+ const server = this;
2570
+ const stream = new ReadableStream<Uint8Array>({
1699
2571
  start(controller) {
2572
+ streamController = controller;
2573
+ // An open stream is not drainable work: it never completes on its own.
2574
+ // It is tracked separately and closed deliberately when shutdown begins.
2575
+ server.openStreams.add(controller);
2576
+ releaseInFlight();
2577
+
1700
2578
  const encoder = new TextEncoder();
1701
2579
  controller.enqueue(encoder.encode(': connected\n\n'));
1702
2580
 
@@ -1708,16 +2586,19 @@ ${this.renderStylesheets()}
1708
2586
  }
1709
2587
  }, 15000);
1710
2588
 
1711
- unsubscribe = eventHub.subscribe(topic, (data) => {
2589
+ unsubscribe = eventHub.subscribe(effectiveTopic, (data) => {
1712
2590
  try {
1713
2591
  const eventData = JSON.stringify(data);
1714
2592
  controller.enqueue(encoder.encode(`event: message\ndata: ${eventData}\n\n`));
1715
2593
  } catch (err) {
1716
- console.error(`[SSE] Erro ao serializar evento para tópico "${topic}":`, err);
2594
+ console.error(`[SSE] Erro ao serializar evento para tópico "${effectiveTopic}":`, err);
1717
2595
  }
1718
2596
  });
1719
2597
  },
1720
2598
  cancel() {
2599
+ if (streamController) {
2600
+ server.openStreams.delete(streamController);
2601
+ }
1721
2602
  cleanup();
1722
2603
  }
1723
2604
  });
@@ -1756,7 +2637,7 @@ ${this.renderStylesheets()}
1756
2637
  const outcome = await saveUpload(req, {
1757
2638
  baseDir: this.baseDir,
1758
2639
  domain: resolvedUpload.slice.domain,
1759
- session: this.sessionFrom(req)
2640
+ session: await this.requestSession(req)
1760
2641
  });
1761
2642
 
1762
2643
  this.logRequest(req, pathname, outcome.status, startedAt);
@@ -1771,6 +2652,11 @@ ${this.renderStylesheets()}
1771
2652
  return new Response(null, { status: cors ? 204 : 404, headers: cors ?? undefined });
1772
2653
  }
1773
2654
 
2655
+ const session = await this.requestSession(req);
2656
+ if (!session.isAuthenticated) {
2657
+ return Response.json({ ok: false, error: 'UNAUTHORIZED' }, { status: 401, headers: cors ?? undefined });
2658
+ }
2659
+
1774
2660
  const fileUrl = url.searchParams.get('url');
1775
2661
  const widthParam = url.searchParams.get('w');
1776
2662
  const heightParam = url.searchParams.get('h');
@@ -1800,15 +2686,20 @@ ${this.renderStylesheets()}
1800
2686
  );
1801
2687
  }
1802
2688
 
1803
- const res = await fetch(fileUrl);
1804
- if (!res.ok) {
2689
+ const fetched = await fetchPinnedExternal(fileUrl, validation.resolvedIp, {
2690
+ maxBytes: this.config.imageOptimizer?.maxResponseBytes ?? 10 * 1024 * 1024,
2691
+ timeoutMs: this.config.imageOptimizer?.timeoutMs ?? 10_000
2692
+ });
2693
+
2694
+ if (!fetched.ok || !fetched.body) {
1805
2695
  return Response.json(
1806
- { ok: false, error: 'FETCH_IMAGE_FAILED' },
1807
- { status: 502, headers: cors ?? undefined }
2696
+ { ok: false, error: fetched.code || 'FETCH_IMAGE_FAILED' },
2697
+ { status: fetched.status ?? 502, headers: cors ?? undefined }
1808
2698
  );
1809
2699
  }
1810
- buffer = new Uint8Array(await res.arrayBuffer());
1811
- mimeType = res.headers.get('content-type') || mimeType;
2700
+
2701
+ buffer = fetched.body;
2702
+ mimeType = fetched.contentType || mimeType;
1812
2703
  } else {
1813
2704
  const publicDir = path.resolve(this.baseDir, 'public');
1814
2705
  const cleanRel = fileUrl.replace(/^\/+/, '');
@@ -1846,8 +2737,7 @@ ${this.renderStylesheets()}
1846
2737
  }
1847
2738
  });
1848
2739
  } catch (err: unknown) {
1849
- const msg = err instanceof Error ? err.message : String(err);
1850
- return Response.json({ ok: false, error: msg }, { status: 500 });
2740
+ return this.internalFailure(err, pathname, cors);
1851
2741
  }
1852
2742
  }
1853
2743
 
@@ -1862,22 +2752,29 @@ ${this.renderStylesheets()}
1862
2752
  if (pathname.startsWith('/_synapse/client/')) {
1863
2753
  const bundleFilename = path.basename(pathname);
1864
2754
  const bundleFile = path.join(this.baseDir, '.synapse/client', bundleFilename);
1865
- if (!fs.existsSync(bundleFile) && bundleFilename === VENDOR_BUNDLE_NAME) {
2755
+ if (bundleFilename === VENDOR_BUNDLE_NAME) {
1866
2756
  await buildVendorBundle(this.baseDir, this.config.compiler?.vendorPackages);
1867
2757
  }
2758
+ if (!fs.existsSync(bundleFile) && bundleFilename === 'synapse.css') {
2759
+ await buildCssBundle(this.baseDir);
2760
+ }
1868
2761
  if (!fs.existsSync(bundleFile)) {
1869
2762
  this.logRequest(req, pathname, 404, startedAt);
1870
2763
  return new Response('Client bundle not found', { status: 404 });
1871
2764
  }
1872
2765
  this.logRequest(req, pathname, 200, startedAt, undefined, 'static');
2766
+ const contentType = bundleFilename.endsWith('.css')
2767
+ ? 'text/css; charset=utf-8'
2768
+ : 'application/javascript; charset=utf-8';
1873
2769
  return new Response(Bun.file(bundleFile), {
1874
- headers: { 'Content-Type': 'application/javascript; charset=utf-8' }
2770
+ headers: { 'Content-Type': contentType }
1875
2771
  });
1876
2772
  }
1877
2773
 
1878
- // 5. UI Route Dispatcher (GET /:domain/:sliceName with optional i18n locale prefix)
2774
+ // 5. UI Route Dispatcher (GET /:domain/:sliceName with optional i18n locale prefix or dynamic path segments)
1879
2775
  let resolvedSlice: DiscoveredSlice | undefined;
1880
2776
  let matchedLocale: string | undefined;
2777
+ const routeParams: Record<string, string> = {};
1881
2778
 
1882
2779
  for (const slice of this.slices.values()) {
1883
2780
  if (pathname === slice.routePath) {
@@ -1888,21 +2785,46 @@ ${this.renderStylesheets()}
1888
2785
 
1889
2786
  if (!resolvedSlice && !pathname.startsWith('/_synapse/')) {
1890
2787
  const localeMatch = pathname.match(I18N_LOCALE_REGEX);
2788
+ const strippedPath = localeMatch ? pathname.replace(I18N_LOCALE_REGEX, '/') : pathname;
1891
2789
  if (localeMatch) {
1892
- const strippedPath = pathname.replace(I18N_LOCALE_REGEX, '/');
1893
- for (const slice of this.slices.values()) {
1894
- if (strippedPath === slice.routePath) {
1895
- resolvedSlice = slice;
1896
- matchedLocale = localeMatch[1];
1897
- break;
2790
+ matchedLocale = localeMatch[1];
2791
+ }
2792
+
2793
+ for (const slice of this.slices.values()) {
2794
+ if (strippedPath === slice.routePath) {
2795
+ resolvedSlice = slice;
2796
+ break;
2797
+ }
2798
+ // Suporte a segmentos dinâmicos de rota: /:domain/:sliceName/:param...
2799
+ if (strippedPath.startsWith(`${slice.routePath}/`)) {
2800
+ resolvedSlice = slice;
2801
+ const remainingSegments = strippedPath
2802
+ .slice(slice.routePath.length + 1)
2803
+ .split('/')
2804
+ .filter(Boolean);
2805
+ if (remainingSegments.length > 0) {
2806
+ routeParams.id = remainingSegments[0];
2807
+ for (let i = 0; i < remainingSegments.length; i++) {
2808
+ routeParams[`param${i + 1}`] = remainingSegments[i];
2809
+ }
1898
2810
  }
2811
+ break;
1899
2812
  }
1900
2813
  }
1901
2814
  }
1902
2815
 
1903
2816
  if (resolvedSlice) {
1904
2817
  const slice = resolvedSlice;
1905
- const session = this.sessionFrom(req);
2818
+
2819
+ // A rendered page load is an accepting surface too, and it is the most
2820
+ // expensive one: refuse before any loader or action runs.
2821
+ const pageRefusal = this.rateLimitRefusal(req, pathname, startedAt);
2822
+
2823
+ if (pageRefusal) {
2824
+ return pageRefusal;
2825
+ }
2826
+
2827
+ const session = await this.requestSession(req);
1906
2828
  const cacheConfig = slice.cacheConfig;
1907
2829
  const scopePrefix = `${session.tenantId || 'global'}:${session.userId !== 'anon' ? session.userId : 'public'}`;
1908
2830
  const normalizedKey = cacheConfig
@@ -1931,7 +2853,7 @@ ${this.renderStylesheets()}
1931
2853
  this.revalidatingKeys.add(cacheKey);
1932
2854
  (async () => {
1933
2855
  try {
1934
- const fresh = await this.renderSliceHtml(slice, req, url, matchedLocale);
2856
+ const fresh = await this.renderSliceHtml(slice, req, url, matchedLocale, routeParams);
1935
2857
  if (!fresh.hasError) {
1936
2858
  const ttl = cacheConfig.ttlSeconds;
1937
2859
  const swr = cacheConfig.staleWhileRevalidateSeconds ?? 0;
@@ -1963,7 +2885,7 @@ ${this.renderStylesheets()}
1963
2885
  }
1964
2886
  }
1965
2887
 
1966
- const { html, hasError } = await this.renderSliceHtml(slice, req, url, matchedLocale);
2888
+ const { html, hasError } = await this.renderSliceHtml(slice, req, url, matchedLocale, routeParams);
1967
2889
 
1968
2890
  if (hasError) {
1969
2891
  this.logRequest(req, pathname, 500, startedAt, session, 'ssr');
@@ -2003,7 +2925,20 @@ ${this.renderStylesheets()}
2003
2925
  return new Response('404 Not Found in SynapseJS Router', { status: 404 });
2004
2926
  };
2005
2927
 
2006
- let response = await handleRequest();
2928
+ let response: Response | undefined;
2929
+
2930
+ try {
2931
+ response = await Promise.race([handleRequest(), this.drainSignal]);
2932
+ } finally {
2933
+ releaseInFlight();
2934
+ }
2935
+
2936
+ // A successful WebSocket upgrade returned nothing: the connection is no
2937
+ // longer an HTTP response, so neither plugins nor headers apply to it.
2938
+ if (!(response instanceof Response)) {
2939
+ return response as unknown as Response;
2940
+ }
2941
+
2007
2942
  if (this.config.plugins) {
2008
2943
  for (const plugin of this.config.plugins) {
2009
2944
  if (plugin.onResponse) {
@@ -2011,7 +2946,10 @@ ${this.renderStylesheets()}
2011
2946
  }
2012
2947
  }
2013
2948
  }
2014
- return response;
2949
+
2950
+ // The baseline security headers are applied here, so every response — routed,
2951
+ // unrouted, static or an error page — carries the same set.
2952
+ return this.withBaselineHeaders(response, req);
2015
2953
  }
2016
2954
  });
2017
2955