@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.
- package/README.md +95 -4
- package/bin/synapse.ts +217 -239
- package/package.json +1 -1
- package/src/compiler/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +114 -5
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +47 -3
- package/src/core/action-context.ts +51 -14
- package/src/core/config.ts +88 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +3 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-queue.ts +25 -7
- package/src/core/session-token.ts +108 -7
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +60 -217
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +47 -0
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/server.ts +876 -180
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/tsconfig.json +1 -0
package/src/runtime/server.ts
CHANGED
|
@@ -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({
|
|
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<
|
|
319
|
-
if (this.
|
|
320
|
-
this.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
989
|
+
tenantId: verified.value.tenantId,
|
|
657
990
|
roles: verified.value.roles,
|
|
658
991
|
token
|
|
659
992
|
})
|
|
660
|
-
: AnonymousSession(
|
|
993
|
+
: AnonymousSession();
|
|
661
994
|
}
|
|
662
995
|
|
|
663
|
-
//
|
|
664
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
787
|
-
const actionCtx = this.createActionContext(slice.
|
|
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 =
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
.
|
|
1218
|
-
.
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
|
1227
|
-
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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.
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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.
|
|
1538
|
-
const actionCtx = this.createActionContext(slice.
|
|
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:
|
|
2151
|
+
} catch (err: unknown) {
|
|
1556
2152
|
this.logRequest(req, pathname, 500, startedAt, undefined, 'rpc_error');
|
|
1557
|
-
return
|
|
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.
|
|
1632
|
-
const actionCtx = this.createActionContext(slice.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 "${
|
|
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.
|
|
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
|
|
1804
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|