@ismaelsoilet/synapsejs 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,7 @@ import {
32
32
  createSession,
33
33
  type DatabaseClient,
34
34
  getDatabase,
35
+ hasAnyRole,
35
36
  loadSynapseConfig,
36
37
  normalizeCacheKey,
37
38
  optimizeImage,
@@ -40,11 +41,11 @@ import {
40
41
  type SliceCacheConfig,
41
42
  type SliceSocketDefinition,
42
43
  type SynapseConfig,
43
- type SynapseWebSocket,
44
44
  serializeCookie
45
45
  } from '../core/index';
46
46
  import { ROLES_COOKIE, SESSION_COOKIE } from '../core/session-cookie';
47
- import { verifySessionToken } from '../core/session-token';
47
+ import { isSessionTokenRevoked, isSessionTokenRevokedInDb, verifySessionToken } from '../core/session-token';
48
+ import { declaredTopic, type RealtimeRejectionCode, scopedTopic } from '../core/topics';
48
49
  import { SYNAPSE_VERSION } from '../version';
49
50
  import { serializeClientProps } from './client-entry';
50
51
  import { isAction, isCache, isComponent, isJob, isLoader, isMeta, isSocket, isWebhook } from './discovery-rules';
@@ -134,6 +135,15 @@ const ERROR_STATUS: Array<{ pattern: RegExp; status: number }> = [
134
135
  { pattern: /_FAILED$/, status: 500 }
135
136
  ];
136
137
 
138
+ const RATE_LIMITED_PREFIXES = [
139
+ '/_synapse/rpc/',
140
+ '/_synapse/files/',
141
+ '/_synapse/webhooks/',
142
+ '/_synapse/sse/',
143
+ '/_synapse/images/',
144
+ '/_synapse/api/'
145
+ ];
146
+
137
147
  export const TURBO_ROUTER_SCRIPT = `(function() {
138
148
  if (window.__synapseNavInstalled) return;
139
149
  window.__synapseNavInstalled = true;
@@ -249,6 +259,150 @@ export function formatLogLine(entry: {
249
259
  return JSON.stringify({ ts: new Date().toISOString(), ...entry });
250
260
  }
251
261
 
262
+ /** Backstop ceiling for any request body: 12 MiB, above every per-route limit. */
263
+ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 12 * 1024 * 1024;
264
+
265
+ /** The runtime mode, from SYNAPSE_ENV or NODE_ENV. */
266
+ function runtimeMode(): string {
267
+ return (process.env.SYNAPSE_ENV ?? process.env.NODE_ENV ?? '').trim().toLowerCase();
268
+ }
269
+
270
+ /**
271
+ * True when the process declares a mode other than the development ones — an exact
272
+ * `production` spelling, but also `prod`, `staging` or any other value an operator
273
+ * clearly intends as a real deployment. An unset value is not production-like here:
274
+ * the request layer still requires the explicit development opt-in below.
275
+ */
276
+ export function isProductionLikeMode(): boolean {
277
+ const mode = runtimeMode();
278
+
279
+ if (!mode) {
280
+ return false;
281
+ }
282
+
283
+ return !['development', 'dev', 'test'].includes(mode);
284
+ }
285
+
286
+ /**
287
+ * Caller-supplied identity (x-user-id, x-user-roles, the roles cookie) is honoured
288
+ * only under an explicit development opt-in: `SYNAPSE_DEV_HEADERS=true`, or a
289
+ * development/test runtime mode. Any other state — unset, staging, production —
290
+ * resolves to an anonymous session, so a mistyped variable cannot grant identity,
291
+ * and the explicit flag is the acknowledgement an operator gives by name.
292
+ */
293
+ export function headerTrustEnabled(): boolean {
294
+ const explicit = (process.env.SYNAPSE_DEV_HEADERS ?? '').trim().toLowerCase();
295
+
296
+ if (explicit === 'true') {
297
+ return true;
298
+ }
299
+
300
+ if (explicit === 'false') {
301
+ return false;
302
+ }
303
+
304
+ if (isProductionLikeMode()) {
305
+ return false;
306
+ }
307
+
308
+ const mode = runtimeMode();
309
+
310
+ return mode === 'development' || mode === 'dev' || mode === 'test';
311
+ }
312
+
313
+ /**
314
+ * The process-level body ceiling: explicit config first, then
315
+ * SYNAPSE_MAX_REQUEST_BODY_BYTES, then the default.
316
+ */
317
+ export function maxRequestBodyBytes(config?: SynapseConfig): number {
318
+ const configured = config?.maxRequestBodyBytes;
319
+
320
+ if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) {
321
+ return Math.floor(configured);
322
+ }
323
+
324
+ const raw = Number(process.env.SYNAPSE_MAX_REQUEST_BODY_BYTES);
325
+
326
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_REQUEST_BODY_BYTES;
327
+ }
328
+
329
+ export interface ExternalFetchResult {
330
+ ok: boolean;
331
+ body?: Uint8Array;
332
+ contentType?: string;
333
+ status?: number;
334
+ code?: string;
335
+ }
336
+
337
+ /**
338
+ * Fetches a URL whose host has already passed `validateExternalUrl`, connecting to the
339
+ * address that was validated rather than resolving the hostname a second time: the URL
340
+ * is rewritten to the pinned address, the original Host header is preserved and TLS
341
+ * validation still checks the certificate against the real hostname (serverName).
342
+ *
343
+ * The response is read through the same streaming ceiling as every other body reader,
344
+ * the whole fetch is bounded by a timeout, and redirects are refused rather than
345
+ * followed (a hop would have to be revalidated, and refusing cannot be defeated).
346
+ */
347
+ export async function fetchPinnedExternal(
348
+ rawUrl: string,
349
+ resolvedIp: string | undefined,
350
+ options: { maxBytes: number; timeoutMs: number }
351
+ ): Promise<ExternalFetchResult> {
352
+ const parsed = new URL(rawUrl);
353
+ const hostname = parsed.hostname;
354
+ const pinnedHost = resolvedIp?.includes(':') ? `[${resolvedIp}]` : resolvedIp;
355
+ const port = parsed.port ? `:${parsed.port}` : '';
356
+ const target = pinnedHost ? `${parsed.protocol}//${pinnedHost}${port}${parsed.pathname}${parsed.search}` : rawUrl;
357
+
358
+ const controller = new AbortController();
359
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
360
+
361
+ try {
362
+ const response = await fetch(target, {
363
+ headers: { host: parsed.host },
364
+ redirect: 'manual',
365
+ signal: controller.signal,
366
+ ...(parsed.protocol === 'https:' ? { tls: { serverName: hostname } } : {})
367
+ } as RequestInit);
368
+
369
+ if (!response.ok) {
370
+ return { ok: false, status: 502, code: 'UPSTREAM_RESPONSE_FAILED' };
371
+ }
372
+
373
+ const body = await readBodyWithinLimit(response.body, options.maxBytes);
374
+
375
+ if (!body.ok) {
376
+ return { ok: false, status: 413, code: 'UPSTREAM_RESPONSE_TOO_LARGE' };
377
+ }
378
+
379
+ return {
380
+ ok: true,
381
+ body: body.value,
382
+ contentType: response.headers.get('content-type') || 'application/octet-stream'
383
+ };
384
+ } catch (err) {
385
+ const aborted = err instanceof Error && (err.name === 'AbortError' || err.message.includes('aborted'));
386
+
387
+ return aborted
388
+ ? { ok: false, status: 504, code: 'UPSTREAM_TIMEOUT' }
389
+ : { ok: false, status: 502, code: 'UPSTREAM_FETCH_FAILED' };
390
+ } finally {
391
+ clearTimeout(timer);
392
+ }
393
+ }
394
+
395
+ export interface ShutdownSummary {
396
+ /** Requests that were still being handled when the drain began and finished in time. */
397
+ drained: number;
398
+ /** Requests still running when the drain timeout expired; each received 503 DRAINING. */
399
+ aborted: number;
400
+ /** Jobs returned to the queue by the shutdown, exactly one attempt counted. */
401
+ jobsRequeued: number;
402
+ /** Jobs whose attempt budget was already exhausted; marked failed. */
403
+ jobsFailed: number;
404
+ }
405
+
252
406
  export class SynapseServer {
253
407
  private slices: Map<string, DiscoveredSlice> = new Map();
254
408
  private baseDir: string;
@@ -304,35 +458,152 @@ export class SynapseServer {
304
458
  if (config) {
305
459
  this.config = { ...config };
306
460
  }
307
- this.rateLimiter = new TokenBucketRateLimiter({ maxBuckets: 10_000 });
461
+ this.rateLimiter = new TokenBucketRateLimiter({
462
+ maxBuckets: config?.rateLimit?.maxBuckets ?? 10_000,
463
+ capacity: config?.rateLimit?.capacity,
464
+ refillRate: config?.rateLimit?.refillRate
465
+ });
308
466
  this.ssrCache = new BoundedLruCache(this.config.cache?.maxEntries || 1000);
309
467
  this.queueEngine = new QueueEngine({
310
468
  dbPath: path.join(this.baseDir, '.synapse/queue.sqlite'),
311
469
  logger: createDefaultLogger('QueueEngine')
312
470
  });
471
+
472
+ // Pending until shutdown reaches its drain deadline; every request races it.
473
+ this.drainSignal = new Promise<Response>((resolve) => {
474
+ this.drainSignalResolve = resolve;
475
+ });
313
476
  }
314
477
 
478
+ /**
479
+ * Resolves when in-flight requests must stop waiting — the drain deadline. It stays
480
+ * pending while the server is running, so every handler races it from the moment it
481
+ * begins and a request that is still running when the deadline fires is answered
482
+ * with 503 DRAINING instead of being cut off with no response.
483
+ */
484
+ private drainSignal: Promise<Response>;
485
+ private drainSignalResolve: ((response: Response) => void) | null = null;
486
+ private inFlightCount = 0;
487
+ private abortedCount = 0;
488
+ private drainedCount = 0;
489
+ private shutdownPromise: Promise<ShutdownSummary> | null = null;
490
+ private openStreams = new Set<ReadableStreamDefaultController<Uint8Array>>();
491
+
492
+ /** What a `stop()` call reports and logs: the observable result of the drain. */
493
+ private lastShutdownSummary: ShutdownSummary | null = null;
494
+
315
495
  /**
316
496
  * Gracefully shuts down the HTTP server and closes database connections.
497
+ *
498
+ * Waits for the promise Bun's own drain returns, raced against the configured
499
+ * timeout — the value is honoured instead of being replaced by a shorter fixed
500
+ * wait — and only then closes the rate limiter, the queue engine and the
501
+ * database, so a request that queries the database late in the drain still can.
502
+ * A second call is safe: it resolves with the same summary and closes nothing twice.
317
503
  */
318
- async stop(drainTimeoutMs = 5000): Promise<void> {
319
- if (this.httpServer) {
320
- this.httpServer.stop(false);
321
- this.httpServer = null;
504
+ async stop(drainTimeoutMs = 5000): Promise<ShutdownSummary> {
505
+ if (this.shutdownPromise) {
506
+ return this.shutdownPromise;
322
507
  }
508
+
509
+ this.shutdownPromise = this.performShutdown(drainTimeoutMs);
510
+
511
+ return this.shutdownPromise;
512
+ }
513
+
514
+ private async performShutdown(drainTimeoutMs: number): Promise<ShutdownSummary> {
515
+ // No new background work is claimed from this point on: a job enqueued by an
516
+ // in-flight request stays pending for the next process.
517
+ this.queueEngine.stopClaiming();
518
+
519
+ const inFlightAtStart = this.inFlightCount;
520
+ const stopPromise: Promise<unknown> = this.httpServer ? this.httpServer.stop(false) : Promise.resolve();
521
+ this.httpServer = null;
522
+
523
+ let timedOut = false;
524
+
323
525
  if (drainTimeoutMs > 0) {
324
- await new Promise((resolve) => setTimeout(resolve, Math.min(drainTimeoutMs, 200)));
526
+ let timer: ReturnType<typeof setTimeout> | undefined;
527
+ const deadline = new Promise<'timeout'>((resolve) => {
528
+ timer = setTimeout(() => resolve('timeout'), drainTimeoutMs);
529
+ });
530
+
531
+ // Fires only when the deadline expires: everything still outstanding is then
532
+ // answered with 503 DRAINING, deliberately, before persistence is closed.
533
+ deadline.then(() => {
534
+ this.abortedCount = this.inFlightCount;
535
+ this.closeOpenStreams();
536
+
537
+ this.drainSignalResolve?.(
538
+ Response.json(
539
+ { ok: false, error: 'DRAINING', message: 'O servidor está encerrando e não concluiu esta requisição.' },
540
+ { status: 503 }
541
+ )
542
+ );
543
+ });
544
+
545
+ // The wait is always bounded: a connection that never finishes producing its
546
+ // request must not hold the shutdown open past the configured timeout.
547
+ const outcome = await Promise.race([stopPromise.then(() => 'drained' as const), deadline]);
548
+
549
+ if (timer) {
550
+ clearTimeout(timer);
551
+ }
552
+
553
+ timedOut = outcome === 'timeout';
554
+ }
555
+ // A configured timeout of zero skips the wait entirely.
556
+
557
+ if (timedOut) {
558
+ // What outlived the deadline is terminated deliberately and reported; the
559
+ // race in the request path has already answered each one with 503 DRAINING.
560
+ this.closeOpenStreams();
325
561
  }
562
+
563
+ this.drainedCount = inFlightAtStart - this.abortedCount;
564
+
565
+ const jobs = this.queueEngine.interruptRunningJobs();
566
+
326
567
  this.rateLimiter.close();
327
568
  this.queueEngine.close();
328
569
  await this.db.close?.();
329
570
  resetDatabaseInstance();
571
+
572
+ this.lastShutdownSummary = {
573
+ drained: Math.max(0, this.drainedCount),
574
+ aborted: this.abortedCount,
575
+ jobsRequeued: jobs.requeued,
576
+ jobsFailed: jobs.failed
577
+ };
578
+
579
+ return this.lastShutdownSummary;
580
+ }
581
+
582
+ private closeOpenStreams(): void {
583
+ for (const controller of this.openStreams) {
584
+ try {
585
+ controller.close();
586
+ } catch {
587
+ // already closed
588
+ }
589
+ }
590
+
591
+ this.openStreams.clear();
592
+ }
593
+
594
+ get shutdownSummary(): ShutdownSummary | null {
595
+ return this.lastShutdownSummary;
330
596
  }
331
597
 
332
598
  get database(): DatabaseClient {
333
599
  return this.db;
334
600
  }
335
601
 
602
+ /** How many slices were discovered and registered — the benchmark's own sanity check. */
603
+ get loadedSliceCount(): number {
604
+ return this.slices.size;
605
+ }
606
+
336
607
  get queue(): QueueEngine {
337
608
  return this.queueEngine;
338
609
  }
@@ -349,7 +620,13 @@ export class SynapseServer {
349
620
  rpcSuccessCount: this.metrics.rpcSuccessCount,
350
621
  rpcErrorCount: this.metrics.rpcErrorCount,
351
622
  ssrRenderCount: this.metrics.ssrRenderCount,
352
- staticFileCount: this.metrics.staticFileCount
623
+ staticFileCount: this.metrics.staticFileCount,
624
+ /** Requests that have begun handling and not yet produced a response. */
625
+ inFlightRequests: this.inFlightCount,
626
+ /** Open event streams: reported apart from drainable work. */
627
+ openStreams: this.openStreams.size,
628
+ shuttingDown: this.shutdownPromise !== null,
629
+ shutdown: this.lastShutdownSummary
353
630
  };
354
631
  }
355
632
 
@@ -357,13 +634,15 @@ export class SynapseServer {
357
634
  return this.config;
358
635
  }
359
636
 
360
- private createActionContext(sliceName: string, session: SessionContext): ActionContext {
637
+ private createActionContext(sliceKey: string, session: SessionContext): ActionContext {
361
638
  return createActionContext({
362
639
  db: this.db,
363
640
  session,
364
641
  tenantId: session.tenantId,
365
642
  services: (this.config.services as Record<string, unknown>) || {},
366
- logger: createDefaultLogger(sliceName),
643
+ logger: createDefaultLogger(sliceKey),
644
+ // Publishing is owner-checked: only the slice that declared a topic may write to it.
645
+ sliceOwner: sliceKey,
367
646
  enqueue: (jobOrName, payload, options) => this.queueEngine.enqueue(jobOrName, payload, options),
368
647
  invalidateCache: (tags) => this.invalidateCache(tags)
369
648
  });
@@ -588,6 +867,57 @@ export class SynapseServer {
588
867
  };
589
868
  }
590
869
 
870
+ /**
871
+ * Client identity for rate limiting. Forwarding headers are honoured only when the
872
+ * deployment explicitly declares a trusted proxy (SYNAPSE_TRUST_PROXY=true or
873
+ * config.trustProxy), so a direct client rotating X-Forwarded-For cannot mint a
874
+ * fresh allowance out of thin air.
875
+ */
876
+ private clientIdentity(req: Request): string {
877
+ const peer = this.httpServer?.requestIP(req)?.address || '127.0.0.1';
878
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY === 'true' || this.config.trustProxy === true;
879
+
880
+ if (!trustProxy) {
881
+ return peer;
882
+ }
883
+
884
+ return req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || peer;
885
+ }
886
+
887
+ /**
888
+ * Consumes one token for the request's client identity. Returns a 429 response when
889
+ * the allowance is exhausted, or null when the request may proceed.
890
+ */
891
+ private rateLimitRefusal(req: Request, pathname: string, startedAt: number): Response | null {
892
+ const rateResult = this.rateLimiter.consume(this.clientIdentity(req));
893
+
894
+ if (rateResult.allowed) {
895
+ return null;
896
+ }
897
+
898
+ const cors = this.corsHeaders(req);
899
+ this.logRequest(req, pathname, 429, startedAt);
900
+
901
+ return new Response(
902
+ JSON.stringify({
903
+ ok: false,
904
+ error: 'RATE_LIMIT_EXCEEDED',
905
+ message: 'Muitas requisições. Tente novamente mais tarde.'
906
+ }),
907
+ {
908
+ status: 429,
909
+ headers: {
910
+ 'Content-Type': 'application/json; charset=utf-8',
911
+ 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000)),
912
+ 'X-RateLimit-Limit': String(rateResult.limit),
913
+ 'X-RateLimit-Remaining': String(rateResult.remaining),
914
+ 'X-RateLimit-Reset': String(Math.ceil((Date.now() + rateResult.resetMs) / 1000)),
915
+ ...(cors ?? {})
916
+ }
917
+ }
918
+ );
919
+ }
920
+
591
921
  /** Records request metrics and writes one JSON line per request when SYNAPSE_LOG=json. */
592
922
  private logRequest(
593
923
  request: Request,
@@ -647,21 +977,25 @@ export class SynapseServer {
647
977
 
648
978
  if (secret) {
649
979
  if (!token) {
650
- return AnonymousSession(tenantId);
980
+ return AnonymousSession();
651
981
  }
652
982
  const verified = verifySessionToken(token, secret);
983
+
984
+ // The tenant comes exclusively from the verified claims: with a secret
985
+ // configured, neither the tenant header nor the subdomain can supply one.
653
986
  return verified.ok
654
987
  ? createSession({
655
988
  userId: verified.value.userId,
656
- tenantId: verified.value.tenantId || tenantId,
989
+ tenantId: verified.value.tenantId,
657
990
  roles: verified.value.roles,
658
991
  token
659
992
  })
660
- : AnonymousSession(tenantId);
993
+ : AnonymousSession();
661
994
  }
662
995
 
663
- // Em produção sem SYNAPSE_SESSION_SECRET, forjamento de headers é terminantemente proibido (CWE-287)
664
- if (process.env.NODE_ENV === 'production') {
996
+ // Sem segredo, a identidade forjável por header só é aceita sob opt-in explícito
997
+ // de desenvolvimento; qualquer outro estado resolve para anônimo.
998
+ if (!headerTrustEnabled()) {
665
999
  return AnonymousSession(tenantId);
666
1000
  }
667
1001
 
@@ -688,6 +1022,60 @@ export class SynapseServer {
688
1022
  return AnonymousSession(tenantId);
689
1023
  }
690
1024
 
1025
+ /** Signature -> memoised revocation verdict, bounded by TTL and entry cap. */
1026
+ private revocationMemo = new Map<string, { revoked: boolean; checkedAt: number }>();
1027
+
1028
+ /**
1029
+ * The session for a request, with revocation consulted on every request. A
1030
+ * signature is queried against the persisted store at most once per TTL window,
1031
+ * so the common case costs nothing; the in-process set short-circuits it entirely.
1032
+ */
1033
+ async requestSession(request: Request): Promise<SessionContext> {
1034
+ const session = this.sessionFrom(request);
1035
+
1036
+ if (!session.isAuthenticated || !session.token) {
1037
+ return session;
1038
+ }
1039
+
1040
+ return (await this.isTokenRevoked(session.token)) ? AnonymousSession() : session;
1041
+ }
1042
+
1043
+ private async isTokenRevoked(token: string): Promise<boolean> {
1044
+ if (isSessionTokenRevoked(token)) {
1045
+ return true;
1046
+ }
1047
+
1048
+ const signature = token.split('.')[1];
1049
+
1050
+ if (!signature) {
1051
+ return true;
1052
+ }
1053
+
1054
+ const ttlMs = Number(process.env.SYNAPSE_REVOCATION_MEMO_TTL_MS ?? '');
1055
+ const ttl = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : 30_000;
1056
+ const cached = this.revocationMemo.get(signature);
1057
+
1058
+ if (cached && Date.now() - cached.checkedAt < ttl) {
1059
+ return cached.revoked;
1060
+ }
1061
+
1062
+ const revoked = await isSessionTokenRevokedInDb(this.db, token);
1063
+
1064
+ while (this.revocationMemo.size >= 5000) {
1065
+ const oldest = this.revocationMemo.keys().next().value;
1066
+
1067
+ if (oldest === undefined) {
1068
+ break;
1069
+ }
1070
+
1071
+ this.revocationMemo.delete(oldest);
1072
+ }
1073
+
1074
+ this.revocationMemo.set(signature, { revoked, checkedAt: Date.now() });
1075
+
1076
+ return revoked;
1077
+ }
1078
+
691
1079
  /**
692
1080
  * Builds the browser bundle for a slice from the splitter's client artifact.
693
1081
  *
@@ -746,25 +1134,235 @@ export class SynapseServer {
746
1134
  }
747
1135
 
748
1136
  /**
749
- * Returns stylesheet links, respecting SYNAPSE_DISABLE_CDN and detecting public/synapse.css
1137
+ * The third-party origins, opt-in only. Enabling them without an integrity hash
1138
+ * emits nothing: an unpinned third-party script (or stylesheet) in an origin that
1139
+ * holds a script-readable session cookie is the risk this switch exists to gate.
1140
+ */
1141
+ private thirdPartyOrigins(): { enabled: boolean; script?: string; fontStyles?: string; fontFiles?: string } {
1142
+ const enabled =
1143
+ this.config.cdn?.enabled === true ||
1144
+ process.env.SYNAPSE_ENABLE_CDN === 'true' ||
1145
+ process.env.SYNAPSE_ENABLE_CDN === '1';
1146
+
1147
+ if (!enabled) {
1148
+ return { enabled: false };
1149
+ }
1150
+
1151
+ return {
1152
+ enabled: true,
1153
+ script: this.config.cdn?.scriptIntegrity || process.env.SYNAPSE_CDN_INTEGRITY || undefined,
1154
+ fontStyles: this.config.cdn?.fontIntegrity || process.env.SYNAPSE_FONTS_INTEGRITY || undefined,
1155
+ fontFiles: 'https://fonts.gstatic.com'
1156
+ };
1157
+ }
1158
+
1159
+ /**
1160
+ * The Content-Security-Policy every HTML response carries. An operator-supplied
1161
+ * policy replaces it (never weakened); otherwise the policy is same-origin, with
1162
+ * third-party origins appearing only when explicitly enabled and pinned.
1163
+ */
1164
+ private contentSecurityPolicy(): string {
1165
+ const operatorPolicy = this.config.contentSecurityPolicy || process.env.SYNAPSE_CSP;
1166
+
1167
+ if (operatorPolicy) {
1168
+ return operatorPolicy;
1169
+ }
1170
+
1171
+ const thirdParty = this.thirdPartyOrigins();
1172
+ const scriptSources = ["'self'", "'unsafe-inline'"];
1173
+ const styleSources = ["'self'", "'unsafe-inline'"];
1174
+ const fontSources = ["'self'", 'data:'];
1175
+
1176
+ if (thirdParty.script) {
1177
+ scriptSources.push('https://cdn.tailwindcss.com');
1178
+ }
1179
+
1180
+ if (thirdParty.fontStyles) {
1181
+ styleSources.push('https://fonts.googleapis.com');
1182
+ fontSources.push('https://fonts.gstatic.com');
1183
+ }
1184
+
1185
+ return [
1186
+ "default-src 'self'",
1187
+ `script-src ${scriptSources.join(' ')}`,
1188
+ `style-src ${styleSources.join(' ')}`,
1189
+ "img-src 'self' data: https:",
1190
+ `font-src ${fontSources.join(' ')}`,
1191
+ "connect-src 'self'",
1192
+ "object-src 'none'",
1193
+ "frame-ancestors 'none'",
1194
+ "base-uri 'self'",
1195
+ "form-action 'self'"
1196
+ ].join('; ');
1197
+ }
1198
+
1199
+ /** The baseline headers every response carries — routed, unrouted, static or error. */
1200
+ private baselineHeaders(req: Request): Record<string, string> {
1201
+ const headers: Record<string, string> = {
1202
+ 'X-Content-Type-Options': 'nosniff',
1203
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
1204
+ 'X-Frame-Options': 'DENY',
1205
+ 'Content-Security-Policy': this.contentSecurityPolicy()
1206
+ };
1207
+
1208
+ const forwardedProto = req.headers.get('x-forwarded-proto');
1209
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY === 'true' || this.config.trustProxy === true;
1210
+ const isTls =
1211
+ new URL(req.url).protocol === 'https:' || (trustProxy && forwardedProto?.split(',')[0]?.trim() === 'https');
1212
+
1213
+ if (isTls) {
1214
+ headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
1215
+ }
1216
+
1217
+ return headers;
1218
+ }
1219
+
1220
+ /** Adds the baseline headers to a response without overriding what it already sets. */
1221
+ private withBaselineHeaders(response: Response, req: Request): Response {
1222
+ for (const [name, value] of Object.entries(this.baselineHeaders(req))) {
1223
+ if (!response.headers.has(name)) {
1224
+ response.headers.set(name, value);
1225
+ }
1226
+ }
1227
+
1228
+ // The response is returned as it came, so a `Bun.file` body keeps its
1229
+ // zero-copy send instead of being re-wrapped as a stream.
1230
+ return response;
1231
+ }
1232
+
1233
+ /**
1234
+ * A realtime refusal: the same machine-readable shape on both transports, with the
1235
+ * code always drawn from the set the machine contract enumerates.
1236
+ */
1237
+ private realtimeRefusal(
1238
+ code: RealtimeRejectionCode,
1239
+ status: number,
1240
+ message: string,
1241
+ cors?: Record<string, string> | null
1242
+ ): Response {
1243
+ return Response.json({ ok: false, error: code, message }, { status, headers: cors ?? undefined });
1244
+ }
1245
+
1246
+ /** Concurrent realtime connections held per client identity. */
1247
+ private realtimeConnections = new Map<string, number>();
1248
+
1249
+ private releaseRealtimeConnection(clientKey: string): void {
1250
+ const open = this.realtimeConnections.get(clientKey) ?? 0;
1251
+
1252
+ if (open <= 1) {
1253
+ this.realtimeConnections.delete(clientKey);
1254
+ } else {
1255
+ this.realtimeConnections.set(clientKey, open - 1);
1256
+ }
1257
+ }
1258
+
1259
+ private realtimeBounds(): { maxTopics: number; maxSubscriptions: number; maxConnectionsPerClient: number } {
1260
+ return {
1261
+ maxTopics: this.config.realtime?.maxTopics ?? 1000,
1262
+ maxSubscriptions: this.config.realtime?.maxSubscriptions ?? 1000,
1263
+ maxConnectionsPerClient: this.config.realtime?.maxConnectionsPerClient ?? 8
1264
+ };
1265
+ }
1266
+
1267
+ /**
1268
+ * The origin check for a WebSocket upgrade, fail-closed: an allow-list must be
1269
+ * configured and must contain the request's origin (or the wildcard). An absent
1270
+ * origin and the opaque `null` origin are refused. The one carve-out is a loopback
1271
+ * origin, so local development works without a production-grade allow-list.
1272
+ */
1273
+ private webSocketOriginAllowed(req: Request): boolean {
1274
+ const origin = req.headers.get('origin');
1275
+
1276
+ if (!origin || origin === 'null') {
1277
+ return false;
1278
+ }
1279
+
1280
+ if (this.isLoopbackOrigin(origin)) {
1281
+ return true;
1282
+ }
1283
+
1284
+ const allowed = (process.env.SYNAPSE_ALLOWED_ORIGINS ?? '')
1285
+ .split(',')
1286
+ .map((entry) => entry.trim())
1287
+ .filter((entry) => entry.length > 0);
1288
+
1289
+ return allowed.includes('*') || allowed.includes(origin);
1290
+ }
1291
+
1292
+ private isLoopbackOrigin(origin: string): boolean {
1293
+ try {
1294
+ const parsed = new URL(origin);
1295
+
1296
+ return (
1297
+ parsed.hostname === 'localhost' ||
1298
+ parsed.hostname === '127.0.0.1' ||
1299
+ parsed.hostname === '::1' ||
1300
+ parsed.hostname === '[::1]'
1301
+ );
1302
+ } catch {
1303
+ return false;
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * The response for an unexpected internal failure: a stable code plus a
1309
+ * correlation identifier that also appears on the log record. Outside development
1310
+ * the exception text is never part of the body — it goes to the log only.
1311
+ */
1312
+ private internalFailure(err: unknown, pathname: string, cors?: Record<string, string> | null): Response {
1313
+ const correlationId = crypto.randomUUID();
1314
+ const message = err instanceof Error ? err.message : String(err);
1315
+
1316
+ console.error(`[SynapseServer] Falha interna [${correlationId}] em ${pathname}: ${message}`);
1317
+
1318
+ return Response.json(
1319
+ {
1320
+ ok: false,
1321
+ error: 'INTERNAL_ERROR',
1322
+ correlationId,
1323
+ ...(isProductionLikeMode() ? {} : { message })
1324
+ },
1325
+ { status: 500, headers: cors ?? undefined }
1326
+ );
1327
+ }
1328
+
1329
+ /**
1330
+ * Returns stylesheet links. The application's own stylesheet always wins; nothing
1331
+ * third-party is referenced unless explicitly enabled and integrity-pinned.
750
1332
  */
751
1333
  private renderStylesheets(): string {
752
- const disableCdn = process.env.SYNAPSE_DISABLE_CDN === 'true' || process.env.SYNAPSE_DISABLE_CDN === '1';
753
1334
  const localCssPath = path.join(this.baseDir, 'public', 'synapse.css');
754
- const hasLocalCss = fs.existsSync(localCssPath);
1335
+
1336
+ if (fs.existsSync(localCssPath)) {
1337
+ return ' <link rel="stylesheet" href="/synapse.css">';
1338
+ }
1339
+
1340
+ const thirdParty = this.thirdPartyOrigins();
1341
+
1342
+ if (!thirdParty.enabled) {
1343
+ return '';
1344
+ }
755
1345
 
756
1346
  const elements: string[] = [];
757
- if (!disableCdn) {
758
- elements.push(' <script src="https://cdn.tailwindcss.com"></script>');
1347
+
1348
+ if (thirdParty.script) {
1349
+ elements.push(
1350
+ ` <script src="https://cdn.tailwindcss.com" integrity="${thirdParty.script}" crossorigin="anonymous"></script>`
1351
+ );
1352
+ } else {
1353
+ console.warn(
1354
+ '[SynapseServer] CDN habilitado sem um hash de integridade (SYNAPSE_CDN_INTEGRITY): o script de terceiros não será emitido.'
1355
+ );
1356
+ }
1357
+
1358
+ if (thirdParty.fontStyles) {
759
1359
  elements.push(' <link rel="preconnect" href="https://fonts.googleapis.com">');
760
1360
  elements.push(' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
761
1361
  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">'
1362
+ ` <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
1363
  );
764
1364
  }
765
- if (hasLocalCss) {
766
- elements.push(' <link rel="stylesheet" href="/synapse.css">');
767
- }
1365
+
768
1366
  return elements.join('\n');
769
1367
  }
770
1368
 
@@ -783,8 +1381,8 @@ export class SynapseServer {
783
1381
  hasError?: boolean;
784
1382
  error?: string;
785
1383
  }> {
786
- const session = this.sessionFrom(req);
787
- const actionCtx = this.createActionContext(slice.name, session);
1384
+ const session = await this.requestSession(req);
1385
+ const actionCtx = this.createActionContext(slice.key, session);
788
1386
  const params = Object.fromEntries(url.searchParams.entries());
789
1387
  const dataProps: Record<string, unknown> = {
790
1388
  ...params,
@@ -835,9 +1433,10 @@ export class SynapseServer {
835
1433
  let hasError = false;
836
1434
  let renderError: string | null = loaderError;
837
1435
 
838
- const isProduction = process.env.NODE_ENV === 'production';
1436
+ const isProduction = isProductionLikeMode();
839
1437
  if (loaderError) {
840
1438
  hasError = true;
1439
+ console.error(`[SynapseServer] Loader de ${slice.key} falhou: ${loaderError}`);
841
1440
  const displayMsg = isProduction ? 'Ocorreu um erro interno ao carregar os dados.' : escapeHtml(loaderError);
842
1441
  contentHtml = `<div class="text-rose-400">Falha no loader de ${escapeHtml(slice.key)}: ${displayMsg}</div>`;
843
1442
  } else if (slice.componentFn) {
@@ -853,9 +1452,9 @@ export class SynapseServer {
853
1452
  const rootEl = React.createElement(SynapseProvider, { props: dataProps, session }, treeWithLayout);
854
1453
  contentHtml = renderToString(rootEl);
855
1454
  } catch (e: any) {
856
- if (!isProduction) console.error('[SSR Render Error]', e);
857
1455
  hasError = true;
858
1456
  const errMessage = e instanceof Error ? e.message : String(e);
1457
+ console.error(`[SynapseServer] Erro na renderização SSR de ${slice.key}: ${errMessage}`);
859
1458
  renderError = errMessage;
860
1459
  const displayMsg = isProduction ? 'Ocorreu um erro interno durante a renderização.' : escapeHtml(errMessage);
861
1460
  contentHtml = `<div class="text-rose-400">Erro na renderização SSR: ${displayMsg}</div>`;
@@ -1150,8 +1749,17 @@ ${this.renderStylesheets()}
1150
1749
  }
1151
1750
  }
1152
1751
 
1752
+ /**
1753
+ * Process-level ceiling on any request body. It is the backstop behind the
1754
+ * per-route limits (RPC 5 MiB, webhook 10 MiB, upload 5 MiB): every body-reading
1755
+ * path is bounded even before its own guard runs, so no single request can
1756
+ * exhaust process memory.
1757
+ */
1758
+ const processBodyCeiling = maxRequestBodyBytes(this.config);
1759
+
1153
1760
  this.httpServer = Bun.serve({
1154
1761
  port: this.port,
1762
+ maxRequestBodySize: processBodyCeiling,
1155
1763
  websocket: {
1156
1764
  open: async (ws: any) => {
1157
1765
  const socketDef = ws.data?.socketDef;
@@ -1182,6 +1790,10 @@ ${this.renderStylesheets()}
1182
1790
  }
1183
1791
  },
1184
1792
  close: async (ws: any, code: number, reason: string) => {
1793
+ if (ws.data?.clientKey) {
1794
+ this.releaseRealtimeConnection(ws.data.clientKey);
1795
+ }
1796
+
1185
1797
  const socketDef = ws.data?.socketDef;
1186
1798
  if (socketDef?.onClose) {
1187
1799
  try {
@@ -1193,11 +1805,24 @@ ${this.renderStylesheets()}
1193
1805
  }
1194
1806
  },
1195
1807
  fetch: async (req: Request) => {
1808
+ // Every request that has begun handling and has not produced a response is
1809
+ // outstanding work the drain waits for. An unbounded event stream is not:
1810
+ // it is tracked separately and closed when shutdown begins.
1811
+ this.inFlightCount++;
1812
+ let released = false;
1813
+ const releaseInFlight = () => {
1814
+ if (!released) {
1815
+ released = true;
1816
+ this.inFlightCount--;
1817
+ }
1818
+ };
1819
+
1196
1820
  if (this.config.plugins) {
1197
1821
  for (const plugin of this.config.plugins) {
1198
1822
  if (plugin.onRequest) {
1199
1823
  const intercepted = await plugin.onRequest(req);
1200
1824
  if (intercepted instanceof Response) {
1825
+ releaseInFlight();
1201
1826
  return intercepted;
1202
1827
  }
1203
1828
  }
@@ -1211,50 +1836,43 @@ ${this.renderStylesheets()}
1211
1836
 
1212
1837
  // WebSocket Upgrade for slices: /_synapse/ws/:domain/:name
1213
1838
  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
- });
1839
+ // Fail-closed: no allow-list means no upgrade, with a loopback carve-out
1840
+ // for local development. An absent or opaque origin is refused too.
1841
+ if (!this.webSocketOriginAllowed(req)) {
1842
+ this.logRequest(req, pathname, 403, startedAt);
1843
+ return this.realtimeRefusal(
1844
+ 'ORIGIN_NOT_ALLOWED',
1845
+ 403,
1846
+ 'Origem não permitida para conexão WebSocket.',
1847
+ this.corsHeaders(req)
1848
+ );
1224
1849
  }
1225
1850
 
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';
1851
+ const rateRefusal = this.rateLimitRefusal(req, pathname, startedAt);
1852
+
1853
+ if (rateRefusal) {
1854
+ return rateRefusal;
1236
1855
  }
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
- }
1856
+
1857
+ const wsBounds = this.realtimeBounds();
1858
+ const wsClientKey = this.clientIdentity(req);
1859
+ const wsOpen = this.realtimeConnections.get(wsClientKey) ?? 0;
1860
+
1861
+ if (wsOpen >= wsBounds.maxConnectionsPerClient) {
1862
+ this.logRequest(req, pathname, 429, startedAt);
1863
+ return this.realtimeRefusal(
1864
+ 'REALTIME_LIMIT_EXCEEDED',
1865
+ 429,
1866
+ 'Limite de conexões simultâneas por cliente atingido.'
1252
1867
  );
1253
1868
  }
1254
1869
 
1870
+ this.realtimeConnections.set(wsClientKey, wsOpen + 1);
1871
+
1255
1872
  const target = pathname.slice('/_synapse/ws/'.length);
1256
1873
  const resolved = this.resolveSlice(target);
1257
1874
  if (!resolved.ok) {
1875
+ this.releaseRealtimeConnection(wsClientKey);
1258
1876
  return new Response(JSON.stringify({ error: resolved.message }), {
1259
1877
  status: resolved.status,
1260
1878
  headers: { 'Content-Type': 'application/json' }
@@ -1262,23 +1880,27 @@ ${this.renderStylesheets()}
1262
1880
  }
1263
1881
  const socketDef = resolved.slice.socketDef;
1264
1882
  if (!socketDef) {
1883
+ this.releaseRealtimeConnection(wsClientKey);
1265
1884
  return new Response(JSON.stringify({ error: `Fatia ${resolved.slice.key} não declara sliceSocket` }), {
1266
1885
  status: 404,
1267
1886
  headers: { 'Content-Type': 'application/json' }
1268
1887
  });
1269
1888
  }
1270
- const session = this.sessionFrom(req);
1889
+ const session = await this.requestSession(req);
1271
1890
  const upgraded = (this.httpServer as any)?.upgrade(req, {
1272
1891
  data: {
1273
1892
  id: crypto.randomUUID(),
1274
1893
  sliceKey: resolved.slice.key,
1275
1894
  socketDef,
1276
- session
1895
+ session,
1896
+ clientKey: wsClientKey
1277
1897
  }
1278
1898
  });
1279
1899
  if (upgraded) {
1280
1900
  return undefined as any;
1281
1901
  }
1902
+
1903
+ this.releaseRealtimeConnection(wsClientKey);
1282
1904
  return new Response('Falha no upgrade de WebSocket', { status: 400 });
1283
1905
  }
1284
1906
 
@@ -1308,6 +1930,18 @@ ${this.renderStylesheets()}
1308
1930
  });
1309
1931
  }
1310
1932
 
1933
+ // 1.5 Rate Limiter for every accepting surface (machine endpoints, RPC,
1934
+ // uploads, webhooks, SSE, images). Server-rendered page loads are covered
1935
+ // separately, right before the render begins. Static files, the dashboard
1936
+ // and the generated client bundles are not part of this budget.
1937
+ if (RATE_LIMITED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
1938
+ const refusal = this.rateLimitRefusal(req, pathname, startedAt);
1939
+
1940
+ if (refusal) {
1941
+ return refusal;
1942
+ }
1943
+ }
1944
+
1311
1945
  // 2. Machine Endpoints for AI
1312
1946
  if (pathname === '/_synapse/api/repo-map') {
1313
1947
  const repoMapPath = path.join(this.baseDir, '.codebase/repo-map.d.ts');
@@ -1335,28 +1969,34 @@ ${this.renderStylesheets()}
1335
1969
  const healthy = dbHealthy && !this.discoveryError;
1336
1970
  const status = healthy ? 'OK' : 'DEGRADED';
1337
1971
  const httpStatus = healthy ? 200 : 503;
1972
+ const session = await this.requestSession(req);
1973
+
1974
+ // This endpoint requires no authentication, so it discloses only the
1975
+ // aggregate verdict: no absolute path, no discovery candidate, no load-error
1976
+ // text and no route inventory. The detailed inventory is authenticated.
1977
+ const payload: Record<string, unknown> = {
1978
+ status,
1979
+ framework: 'SynapseJS',
1980
+ version: SYNAPSE_VERSION,
1981
+ uptime: process.uptime(),
1982
+ database: dbHealthy ? 'connected' : 'disconnected',
1983
+ slicesLoaded: this.slices.size
1984
+ };
1338
1985
 
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
- );
1986
+ if (session.isAuthenticated) {
1987
+ payload.databaseError = dbError;
1988
+ payload.slicesResolutionError = this.discoveryError;
1989
+ payload.loadErrors = this.loadErrors;
1990
+ payload.slices = Array.from(this.slices.values()).map((s) => ({
1991
+ domain: s.domain,
1992
+ name: s.name,
1993
+ route: s.routePath,
1994
+ rpc: s.rpcPath
1995
+ }));
1996
+ }
1997
+
1998
+ this.logRequest(req, pathname, httpStatus, startedAt, session);
1999
+ return Response.json(payload, { status: httpStatus });
1360
2000
  }
1361
2001
 
1362
2002
  if (pathname === '/_synapse/api/metrics') {
@@ -1367,26 +2007,27 @@ ${this.renderStylesheets()}
1367
2007
  this.logRequest(req, pathname, 200, startedAt);
1368
2008
 
1369
2009
  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, {
2010
+ const promText = [
2011
+ '# HELP synapse_uptime_seconds SynapseJS server uptime in seconds',
2012
+ '# TYPE synapse_uptime_seconds gauge',
2013
+ `synapse_uptime_seconds ${uptimeSeconds}`,
2014
+ '# HELP synapse_requests_total Total number of HTTP requests received',
2015
+ '# TYPE synapse_requests_total counter',
2016
+ `synapse_requests_total ${this.metrics.totalRequests}`,
2017
+ '# HELP synapse_rpc_success_total Total successful RPC action calls',
2018
+ '# TYPE synapse_rpc_success_total counter',
2019
+ `synapse_rpc_success_total ${this.metrics.rpcSuccessCount}`,
2020
+ '# HELP synapse_rpc_error_total Total failed RPC action calls',
2021
+ '# TYPE synapse_rpc_error_total counter',
2022
+ `synapse_rpc_error_total ${this.metrics.rpcErrorCount}`,
2023
+ '# HELP synapse_ssr_renders_total Total server-side rendered pages',
2024
+ '# TYPE synapse_ssr_renders_total counter',
2025
+ `synapse_ssr_renders_total ${this.metrics.ssrRenderCount}`
2026
+ ].join('\n');
2027
+
2028
+ const promTextWithNewline = `${promText}\n`;
2029
+
2030
+ return new Response(promTextWithNewline, {
1390
2031
  headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }
1391
2032
  });
1392
2033
  }
@@ -1402,51 +2043,6 @@ ${this.renderStylesheets()}
1402
2043
  });
1403
2044
  }
1404
2045
 
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
- }
1448
- }
1449
-
1450
2046
  // 3. RPC Actions Dispatcher (POST /_synapse/rpc/:sliceName)
1451
2047
  if (pathname.startsWith('/_synapse/rpc/')) {
1452
2048
  const cors = this.corsHeaders(req);
@@ -1534,8 +2130,8 @@ ${this.renderStylesheets()}
1534
2130
  }
1535
2131
 
1536
2132
  try {
1537
- const session = this.sessionFrom(req);
1538
- const actionCtx = this.createActionContext(slice.name, session);
2133
+ const session = await this.requestSession(req);
2134
+ const actionCtx = this.createActionContext(slice.key, session);
1539
2135
 
1540
2136
  const result = await slice.actionFn(body, actionCtx, session, actionCtx);
1541
2137
  const status = result.ok ? 200 : httpStatusForError(result.error);
@@ -1552,9 +2148,9 @@ ${this.renderStylesheets()}
1552
2148
  }
1553
2149
 
1554
2150
  return new Response(JSON.stringify(result), { status, headers });
1555
- } catch (err: any) {
2151
+ } catch (err: unknown) {
1556
2152
  this.logRequest(req, pathname, 500, startedAt, undefined, 'rpc_error');
1557
- return Response.json({ ok: false, error: `Falha interna no RPC: ${err.message}` }, { status: 500 });
2153
+ return this.internalFailure(err, pathname, cors);
1558
2154
  }
1559
2155
  }
1560
2156
 
@@ -1628,8 +2224,8 @@ ${this.renderStylesheets()}
1628
2224
  json = null;
1629
2225
  }
1630
2226
 
1631
- const session = this.sessionFrom(req);
1632
- const actionCtx = this.createActionContext(slice.name, session);
2227
+ const session = await this.requestSession(req);
2228
+ const actionCtx = this.createActionContext(slice.key, session);
1633
2229
 
1634
2230
  const result = await slice.webhookFn(
1635
2231
  {
@@ -1649,12 +2245,8 @@ ${this.renderStylesheets()}
1649
2245
  }
1650
2246
  return Response.json(result, { status, headers: cors ?? undefined });
1651
2247
  } catch (err: unknown) {
1652
- const message = err instanceof Error ? err.message : String(err);
1653
2248
  this.logRequest(req, pathname, 500, startedAt);
1654
- return Response.json(
1655
- { ok: false, error: `Falha no processamento de webhook: ${message}` },
1656
- { status: 500 }
1657
- );
2249
+ return this.internalFailure(err, pathname, cors);
1658
2250
  }
1659
2251
  }
1660
2252
 
@@ -1674,11 +2266,64 @@ ${this.renderStylesheets()}
1674
2266
  const topic = decodeURIComponent(rawTopic);
1675
2267
 
1676
2268
  if (!topic) {
1677
- return Response.json({ ok: false, error: 'Tópico de subscrição inválido.' }, { status: 400 });
2269
+ return this.realtimeRefusal('TOPIC_NOT_FOUND', 404, 'Tópico de subscrição inválido.', cors);
2270
+ }
2271
+
2272
+ const session = await this.requestSession(req);
2273
+
2274
+ // Access is declared, not inferred: a name is not a permission.
2275
+ const declaration = declaredTopic(topic, session.tenantId);
2276
+
2277
+ if (!session.isAuthenticated && !declaration?.public) {
2278
+ return this.realtimeRefusal('UNAUTHENTICATED', 401, 'Subscrição requer uma sessão autenticada.', cors);
2279
+ }
2280
+
2281
+ if (!declaration) {
2282
+ return this.realtimeRefusal('TOPIC_NOT_FOUND', 404, 'Tópico não declarado.', cors);
2283
+ }
2284
+
2285
+ if (declaration.readRoles?.length && !hasAnyRole(session, declaration.readRoles)) {
2286
+ return this.realtimeRefusal('TOPIC_FORBIDDEN', 403, 'Sessão sem papel para este tópico.', cors);
2287
+ }
2288
+
2289
+ const bounds = this.realtimeBounds();
2290
+
2291
+ if (this.openStreams.size >= bounds.maxSubscriptions) {
2292
+ return this.realtimeRefusal(
2293
+ 'REALTIME_LIMIT_EXCEEDED',
2294
+ 429,
2295
+ 'Limite de subscrições simultâneas atingido.',
2296
+ cors
2297
+ );
1678
2298
  }
1679
2299
 
2300
+ const effectiveTopic = scopedTopic(topic, session.tenantId);
1680
2301
  const eventHub = getEventHub();
1681
- const session = this.sessionFrom(req);
2302
+ const isNewTopic = eventHub.listenerCount(effectiveTopic) === 0;
2303
+
2304
+ if (isNewTopic && eventHub.activeTopics().length >= bounds.maxTopics) {
2305
+ return this.realtimeRefusal(
2306
+ 'REALTIME_LIMIT_EXCEEDED',
2307
+ 429,
2308
+ 'Limite de tópicos registrados atingido.',
2309
+ cors
2310
+ );
2311
+ }
2312
+
2313
+ const clientKey = this.clientIdentity(req);
2314
+ const openForClient = this.realtimeConnections.get(clientKey) ?? 0;
2315
+
2316
+ if (openForClient >= bounds.maxConnectionsPerClient) {
2317
+ return this.realtimeRefusal(
2318
+ 'REALTIME_LIMIT_EXCEEDED',
2319
+ 429,
2320
+ 'Limite de conexões simultâneas por cliente atingido.',
2321
+ cors
2322
+ );
2323
+ }
2324
+
2325
+ this.realtimeConnections.set(clientKey, openForClient + 1);
2326
+
1682
2327
  let unsubscribe: (() => void) | null = null;
1683
2328
  let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
1684
2329
 
@@ -1691,12 +2336,26 @@ ${this.renderStylesheets()}
1691
2336
  unsubscribe();
1692
2337
  unsubscribe = null;
1693
2338
  }
2339
+ const open = this.realtimeConnections.get(clientKey) ?? 0;
2340
+ if (open <= 1) {
2341
+ this.realtimeConnections.delete(clientKey);
2342
+ } else {
2343
+ this.realtimeConnections.set(clientKey, open - 1);
2344
+ }
1694
2345
  };
1695
2346
 
1696
2347
  req.signal.addEventListener('abort', cleanup);
1697
2348
 
1698
- const stream = new ReadableStream({
2349
+ let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
2350
+ const server = this;
2351
+ const stream = new ReadableStream<Uint8Array>({
1699
2352
  start(controller) {
2353
+ streamController = controller;
2354
+ // An open stream is not drainable work: it never completes on its own.
2355
+ // It is tracked separately and closed deliberately when shutdown begins.
2356
+ server.openStreams.add(controller);
2357
+ releaseInFlight();
2358
+
1700
2359
  const encoder = new TextEncoder();
1701
2360
  controller.enqueue(encoder.encode(': connected\n\n'));
1702
2361
 
@@ -1708,16 +2367,19 @@ ${this.renderStylesheets()}
1708
2367
  }
1709
2368
  }, 15000);
1710
2369
 
1711
- unsubscribe = eventHub.subscribe(topic, (data) => {
2370
+ unsubscribe = eventHub.subscribe(effectiveTopic, (data) => {
1712
2371
  try {
1713
2372
  const eventData = JSON.stringify(data);
1714
2373
  controller.enqueue(encoder.encode(`event: message\ndata: ${eventData}\n\n`));
1715
2374
  } catch (err) {
1716
- console.error(`[SSE] Erro ao serializar evento para tópico "${topic}":`, err);
2375
+ console.error(`[SSE] Erro ao serializar evento para tópico "${effectiveTopic}":`, err);
1717
2376
  }
1718
2377
  });
1719
2378
  },
1720
2379
  cancel() {
2380
+ if (streamController) {
2381
+ server.openStreams.delete(streamController);
2382
+ }
1721
2383
  cleanup();
1722
2384
  }
1723
2385
  });
@@ -1756,7 +2418,7 @@ ${this.renderStylesheets()}
1756
2418
  const outcome = await saveUpload(req, {
1757
2419
  baseDir: this.baseDir,
1758
2420
  domain: resolvedUpload.slice.domain,
1759
- session: this.sessionFrom(req)
2421
+ session: await this.requestSession(req)
1760
2422
  });
1761
2423
 
1762
2424
  this.logRequest(req, pathname, outcome.status, startedAt);
@@ -1771,6 +2433,11 @@ ${this.renderStylesheets()}
1771
2433
  return new Response(null, { status: cors ? 204 : 404, headers: cors ?? undefined });
1772
2434
  }
1773
2435
 
2436
+ const session = await this.requestSession(req);
2437
+ if (!session.isAuthenticated) {
2438
+ return Response.json({ ok: false, error: 'UNAUTHORIZED' }, { status: 401, headers: cors ?? undefined });
2439
+ }
2440
+
1774
2441
  const fileUrl = url.searchParams.get('url');
1775
2442
  const widthParam = url.searchParams.get('w');
1776
2443
  const heightParam = url.searchParams.get('h');
@@ -1800,15 +2467,20 @@ ${this.renderStylesheets()}
1800
2467
  );
1801
2468
  }
1802
2469
 
1803
- const res = await fetch(fileUrl);
1804
- if (!res.ok) {
2470
+ const fetched = await fetchPinnedExternal(fileUrl, validation.resolvedIp, {
2471
+ maxBytes: this.config.imageOptimizer?.maxResponseBytes ?? 10 * 1024 * 1024,
2472
+ timeoutMs: this.config.imageOptimizer?.timeoutMs ?? 10_000
2473
+ });
2474
+
2475
+ if (!fetched.ok || !fetched.body) {
1805
2476
  return Response.json(
1806
- { ok: false, error: 'FETCH_IMAGE_FAILED' },
1807
- { status: 502, headers: cors ?? undefined }
2477
+ { ok: false, error: fetched.code || 'FETCH_IMAGE_FAILED' },
2478
+ { status: fetched.status ?? 502, headers: cors ?? undefined }
1808
2479
  );
1809
2480
  }
1810
- buffer = new Uint8Array(await res.arrayBuffer());
1811
- mimeType = res.headers.get('content-type') || mimeType;
2481
+
2482
+ buffer = fetched.body;
2483
+ mimeType = fetched.contentType || mimeType;
1812
2484
  } else {
1813
2485
  const publicDir = path.resolve(this.baseDir, 'public');
1814
2486
  const cleanRel = fileUrl.replace(/^\/+/, '');
@@ -1846,8 +2518,7 @@ ${this.renderStylesheets()}
1846
2518
  }
1847
2519
  });
1848
2520
  } catch (err: unknown) {
1849
- const msg = err instanceof Error ? err.message : String(err);
1850
- return Response.json({ ok: false, error: msg }, { status: 500 });
2521
+ return this.internalFailure(err, pathname, cors);
1851
2522
  }
1852
2523
  }
1853
2524
 
@@ -1902,7 +2573,16 @@ ${this.renderStylesheets()}
1902
2573
 
1903
2574
  if (resolvedSlice) {
1904
2575
  const slice = resolvedSlice;
1905
- const session = this.sessionFrom(req);
2576
+
2577
+ // A rendered page load is an accepting surface too, and it is the most
2578
+ // expensive one: refuse before any loader or action runs.
2579
+ const pageRefusal = this.rateLimitRefusal(req, pathname, startedAt);
2580
+
2581
+ if (pageRefusal) {
2582
+ return pageRefusal;
2583
+ }
2584
+
2585
+ const session = await this.requestSession(req);
1906
2586
  const cacheConfig = slice.cacheConfig;
1907
2587
  const scopePrefix = `${session.tenantId || 'global'}:${session.userId !== 'anon' ? session.userId : 'public'}`;
1908
2588
  const normalizedKey = cacheConfig
@@ -2003,7 +2683,20 @@ ${this.renderStylesheets()}
2003
2683
  return new Response('404 Not Found in SynapseJS Router', { status: 404 });
2004
2684
  };
2005
2685
 
2006
- let response = await handleRequest();
2686
+ let response: Response | undefined;
2687
+
2688
+ try {
2689
+ response = await Promise.race([handleRequest(), this.drainSignal]);
2690
+ } finally {
2691
+ releaseInFlight();
2692
+ }
2693
+
2694
+ // A successful WebSocket upgrade returned nothing: the connection is no
2695
+ // longer an HTTP response, so neither plugins nor headers apply to it.
2696
+ if (!(response instanceof Response)) {
2697
+ return response as unknown as Response;
2698
+ }
2699
+
2007
2700
  if (this.config.plugins) {
2008
2701
  for (const plugin of this.config.plugins) {
2009
2702
  if (plugin.onResponse) {
@@ -2011,7 +2704,10 @@ ${this.renderStylesheets()}
2011
2704
  }
2012
2705
  }
2013
2706
  }
2014
- return response;
2707
+
2708
+ // The baseline security headers are applied here, so every response — routed,
2709
+ // unrouted, static or an error page — carries the same set.
2710
+ return this.withBaselineHeaders(response, req);
2015
2711
  }
2016
2712
  });
2017
2713