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