@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.
@@ -42,7 +42,12 @@ export interface SplitResult {
42
42
  artifacts: SplitArtifact[];
43
43
  }
44
44
 
45
- export type SplitErrorCode = 'SLICE_NOT_FOUND' | 'SLICE_IMPORTS_SLICE' | 'NO_ROOTS' | 'UNPARSED';
45
+ export type SplitErrorCode =
46
+ | 'SLICE_NOT_FOUND'
47
+ | 'SLICE_IMPORTS_SLICE'
48
+ | 'NO_ROOTS'
49
+ | 'UNPARSED'
50
+ | 'SERVER_VALUE_IN_CLIENT';
46
51
 
47
52
  export interface SplitError {
48
53
  code: SplitErrorCode;
@@ -67,10 +72,14 @@ export interface SplitVerification {
67
72
  const CLIENT_ROOTS = [...COMPONENT_SUFFIXES];
68
73
  const TEST_ONLY_NAMES: readonly string[] = [...TEST_ONLY_EXPORTS];
69
74
 
70
- const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
75
+ /** The expressions that must never survive into a client bundle. Exported so the
76
+ * linear-time guarantee can be asserted directly against each of them. */
77
+ export const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
71
78
  // `SELECT ... FROM` e não `SELECT` seguido de qualquer palavra: sem o FROM, a
72
79
  // tag HTML `<select id="x">` de um formulário casava como vazamento de SQL.
73
- { label: 'SELECT statement', pattern: /\bSELECT\s+[\w*"`][\w*"`.,\s]*\bFROM\b/i },
80
+ // O quantificador é limitado (uma janela fixa, não `*`): um bundle grande com
81
+ // muitos `SELECT` sem `FROM` fazia o motor retroceder sobre o texto inteiro.
82
+ { label: 'SELECT statement', pattern: /\bSELECT\s[\w*"`.,\s]{0,4096}?\bFROM\b/i },
74
83
  { label: 'INSERT statement', pattern: /\bINSERT\s+INTO\b/i },
75
84
  { label: 'UPDATE statement', pattern: /\bUPDATE\s+\w+\s+SET\b/i },
76
85
  { label: 'DELETE statement', pattern: /\bDELETE\s+FROM\b/i },
@@ -507,6 +516,20 @@ function unwrapPromiseType(typeText: string): string {
507
516
  return match ? match[1].trim() : `Awaited<${typeText}>`;
508
517
  }
509
518
 
519
+ /**
520
+ * A wire stub describes a server *function*. A server-owned value that a component
521
+ * references has no wire form, so the caller must fail with a diagnostic instead of
522
+ * emitting a stub that would call the endpoint as if it were an action.
523
+ */
524
+ function isStubCandidate(entry: DeclEntry): boolean {
525
+ return (
526
+ ts.isFunctionDeclaration(entry.node) ||
527
+ ts.isFunctionExpression(entry.node) ||
528
+ ts.isArrowFunction(entry.node) ||
529
+ ts.isMethodDeclaration(entry.node)
530
+ );
531
+ }
532
+
510
533
  function renderRpcStub(entry: DeclEntry, endpoint: string, sourceFile: ts.SourceFile): string {
511
534
  const fn = entry.node as ts.FunctionDeclaration;
512
535
  const payload = fn.parameters[0];
@@ -712,6 +735,27 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
712
735
  const sliceName = path.basename(sliceFilePath, '.slice.tsx');
713
736
  const relativePath = path.relative(baseDir, sliceFilePath);
714
737
  const endpoint = `/_synapse/rpc/${sliceName}`;
738
+
739
+ const serverValueInClient = stubbedKeys.find((key) => {
740
+ const entry = entries.get(key);
741
+
742
+ return entry ? !isStubCandidate(entry) : false;
743
+ });
744
+
745
+ if (serverValueInClient) {
746
+ const entry = entries.get(serverValueInClient) as DeclEntry;
747
+
748
+ return Err({
749
+ code: 'SERVER_VALUE_IN_CLIENT',
750
+ message:
751
+ `O componente de ${path.basename(sliceFilePath)} referencia '${serverValueInClient}', ` +
752
+ `que é um valor do servidor (${ts.SyntaxKind[entry.node.kind]}) e não uma action: não existe ` +
753
+ `forma de transportá-lo pelo endpoint RPC. Mova o valor para um módulo em src/shared/, ` +
754
+ `importe-o de 'synapsejs/client', ou limite-se a declará-lo no servidor.`,
755
+ candidates: [serverValueInClient]
756
+ });
757
+ }
758
+
715
759
  const artifacts: SplitArtifact[] = [];
716
760
  const outDir = artifactDirectory(baseDir, sliceName);
717
761
  const sourceDir = path.dirname(sliceFilePath);
@@ -10,6 +10,7 @@ import { getEventHub } from '../runtime/event-hub';
10
10
  import type { DatabaseClient, QueryOptions, WhereCondition } from './database-client';
11
11
  import type { SessionContext } from './session-context';
12
12
  import { getStorage, type StorageClient } from './storage';
13
+ import { declaredTopic, scopedTopic } from './topics';
13
14
 
14
15
  export interface StructuredLogger {
15
16
  info(message: string, context?: Record<string, unknown>): void;
@@ -32,8 +33,20 @@ export type EnqueueFn = <TPayload>(
32
33
  export type BroadcastFn = (topic: string, data: unknown) => number;
33
34
 
34
35
  export interface CookieOptions {
36
+ /**
37
+ * Defaults to true. A cookie written without stating otherwise is not readable by
38
+ * script; pass `false` explicitly to opt out.
39
+ */
35
40
  httpOnly?: boolean;
41
+ /**
42
+ * Defaults to true. A cookie written without stating otherwise is not sent over
43
+ * plaintext; pass `false` explicitly to opt out.
44
+ */
36
45
  secure?: boolean;
46
+ /**
47
+ * Defaults to `lax`. An explicit `strict` or `none` applies instead — the framework
48
+ * never infers a weaker policy from anything but a deliberate statement.
49
+ */
37
50
  sameSite?: 'lax' | 'strict' | 'none';
38
51
  maxAge?: number;
39
52
  path?: string;
@@ -46,6 +59,12 @@ export interface PendingCookie {
46
59
  options?: CookieOptions;
47
60
  }
48
61
 
62
+ /**
63
+ * Serializes one response cookie. The safe attributes are the **defaults**: a cookie
64
+ * written with no options at all is HttpOnly, Secure and SameSite=Lax, and only an
65
+ * explicit `false` (or a different `sameSite`) changes that. This is a breaking
66
+ * change from the previous opt-in behaviour.
67
+ */
49
68
  export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
50
69
  const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
51
70
  if (options.maxAge !== undefined) {
@@ -56,15 +75,14 @@ export function serializeCookie(name: string, value: string, options: CookieOpti
56
75
  }
57
76
  const cleanPath = options.path && !/[\r\n;\s]/.test(options.path) ? options.path.trim() : '/';
58
77
  parts.push(`Path=${cleanPath}`);
59
- if (options.secure) {
78
+ if (options.secure ?? true) {
60
79
  parts.push('Secure');
61
80
  }
62
- if (options.httpOnly) {
81
+ if (options.httpOnly ?? true) {
63
82
  parts.push('HttpOnly');
64
83
  }
65
- if (options.sameSite) {
66
- parts.push(`SameSite=${options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1).toLowerCase()}`);
67
- }
84
+ const sameSite = options.sameSite ?? 'lax';
85
+ parts.push(`SameSite=${sameSite.charAt(0).toUpperCase() + sameSite.slice(1).toLowerCase()}`);
68
86
  return parts.join('; ');
69
87
  }
70
88
 
@@ -101,9 +119,6 @@ export interface ActionContext<TServices = Record<string, unknown>> extends Data
101
119
 
102
120
  /** Internal list of pending cookies to be set on the response */
103
121
  readonly _pendingCookies?: PendingCookie[];
104
-
105
- /** Lazy adapter for Kysely (if configured or requested) */
106
- readonly kysely?: unknown;
107
122
  }
108
123
 
109
124
  export function createDefaultLogger(sliceName?: string): StructuredLogger {
@@ -130,7 +145,11 @@ export interface ActionContextOptions<TServices = Record<string, unknown>> {
130
145
  storage?: StorageClient;
131
146
  broadcast?: BroadcastFn;
132
147
  invalidateCache?: (tags?: string[]) => void;
133
- kysely?: unknown;
148
+ /**
149
+ * The slice key (`<domain>/<name>`) this context belongs to. Publishing into a
150
+ * declared topic is only allowed for the declaration's owner.
151
+ */
152
+ sliceOwner?: string;
134
153
  }
135
154
 
136
155
  /**
@@ -148,11 +167,30 @@ export function createActionContext<TServices = Record<string, unknown>>(
148
167
  logger = createDefaultLogger(),
149
168
  enqueue = async () => 'job_noop',
150
169
  storage = getStorage(),
151
- broadcast = (topic: string, data: unknown) => getEventHub().publish(topic, data),
152
170
  invalidateCache = () => {},
153
- kysely
171
+ sliceOwner
154
172
  } = options;
155
173
 
174
+ /**
175
+ * Publishing is declared, not inferred: the topic must exist in the session's
176
+ * tenant scope and this feature must be its declared owner. Anything else is
177
+ * rejected and notifies nobody.
178
+ */
179
+ const broadcast: BroadcastFn =
180
+ options.broadcast ??
181
+ ((topic: string, data: unknown) => {
182
+ const declaration = declaredTopic(topic, tenantId);
183
+
184
+ if (!declaration || (sliceOwner && declaration.owner !== sliceOwner)) {
185
+ logger.warn(
186
+ `Broadcast recusado no tópico "${topic}": não declarado${sliceOwner ? ` por ${sliceOwner}` : ''} (defineTopic).`
187
+ );
188
+ return 0;
189
+ }
190
+
191
+ return getEventHub().publish(scopedTopic(topic, tenantId), data);
192
+ });
193
+
156
194
  const pendingCookies: PendingCookie[] = [];
157
195
  const setCookie = (name: string, value: string, options?: CookieOptions) => {
158
196
  pendingCookies.push({ name, value, options });
@@ -171,7 +209,6 @@ export function createActionContext<TServices = Record<string, unknown>>(
171
209
  invalidateCache,
172
210
  setCookie,
173
211
  _pendingCookies: pendingCookies,
174
- kysely,
175
212
 
176
213
  // DatabaseClient proxy methods
177
214
  query: <T = unknown>(sql: string, params?: unknown[]) => db.query<T>(sql, params),
@@ -195,8 +232,8 @@ export function createActionContext<TServices = Record<string, unknown>>(
195
232
  logger,
196
233
  enqueue,
197
234
  storage,
198
- broadcast,
199
- kysely: txDb
235
+ broadcast: options.broadcast,
236
+ sliceOwner
200
237
  });
201
238
  return operation(txContext);
202
239
  });
@@ -11,7 +11,7 @@ export interface SynapsePlugin {
11
11
  name: string;
12
12
  // biome-ignore lint/suspicious/noExplicitAny: generic boundary for framework server
13
13
  onBootstrap?: (server: any) => Promise<void> | void;
14
- onRequest?: (req: Request) => Promise<Response | null | undefined> | Response | null | void;
14
+ onRequest?: (req: Request) => Promise<Response | null | undefined> | Response | null | undefined;
15
15
  onResponse?: (res: Response, req: Request) => Promise<Response> | Response;
16
16
  // biome-ignore lint/suspicious/noExplicitAny: generic boundary for database client
17
17
  onMigrate?: (db: any) => Promise<void> | void;
@@ -55,9 +55,31 @@ export interface SynapseConfig<TServices = Record<string, unknown>> {
55
55
 
56
56
  /**
57
57
  * Trust proxy headers (cf-connecting-ip, x-forwarded-for) for rate limiting and IP resolution.
58
+ * Opt-in: when this is not explicitly `true` (or SYNAPSE_TRUST_PROXY is not `true`), the
59
+ * immediate peer address is used and a rotating forwarding header cannot mint a new allowance.
58
60
  */
59
61
  trustProxy?: boolean;
60
62
 
63
+ /**
64
+ * Process-level maximum request body size, enforced by the HTTP server itself before any
65
+ * body reader runs (default: 12 MiB, above every per-route ceiling). It is the backstop
66
+ * behind the RPC (5 MiB), webhook (10 MiB) and upload (5 MiB) limits.
67
+ */
68
+ maxRequestBodyBytes?: number;
69
+
70
+ /**
71
+ * Rate limiting. Scope is the serving process only: each instance keeps its own counters,
72
+ * so N instances behind a load balancer permit N times the per-instance allowance.
73
+ */
74
+ rateLimit?: {
75
+ /** Maximum burst capacity (default: 150). */
76
+ capacity?: number;
77
+ /** Refill rate in tokens per second (default: 100). */
78
+ refillRate?: number;
79
+ /** Maximum tracked client identities before the registry is saturated (default: 10,000). */
80
+ maxBuckets?: number;
81
+ };
82
+
61
83
  /**
62
84
  * Max allowed payload bytes for RPC POST requests (default: 5MB).
63
85
  */
@@ -75,11 +97,54 @@ export interface SynapseConfig<TServices = Record<string, unknown>> {
75
97
  maxEntries?: number;
76
98
  };
77
99
 
100
+ /**
101
+ * Third-party CDN and web-font origins (Tailwind's runtime compiler, Google Fonts).
102
+ * **Opt-in**: nothing third-party is referenced by default. When enabled, the
103
+ * content-security policy is rebuilt to permit exactly the configured origins, and
104
+ * the emitted tags carry a subresource-integrity hash — without one, the tag is not
105
+ * emitted at all, because an unpinned third-party script in an origin that holds a
106
+ * script-readable session cookie is the whole risk.
107
+ */
108
+ cdn?: {
109
+ enabled?: boolean;
110
+ /** SRI hash for the Tailwind runtime script (`sha384-...`). */
111
+ scriptIntegrity?: string;
112
+ /** SRI hash for the Google Fonts stylesheet (`sha384-...`). */
113
+ fontIntegrity?: string;
114
+ };
115
+
116
+ /**
117
+ * Realtime (event stream and WebSocket) limits. Long-lived connections consume a
118
+ * token from the rate limiter at most once, so these bounds — not the token bucket —
119
+ * are what caps concurrent streams.
120
+ */
121
+ realtime?: {
122
+ /** Maximum distinct topics with an open subscription (default: 1000). */
123
+ maxTopics?: number;
124
+ /** Maximum concurrently open realtime connections (default: 1000). */
125
+ maxSubscriptions?: number;
126
+ /** Maximum concurrently open realtime connections from one client (default: 8). */
127
+ maxConnectionsPerClient?: number;
128
+ };
129
+
130
+ /**
131
+ * Operator-supplied Content-Security-Policy. When present it is emitted as given —
132
+ * the framework never weakens it — and replaces the built-in policy.
133
+ */
134
+ contentSecurityPolicy?: string;
135
+
78
136
  /**
79
137
  * Image optimizer security settings.
138
+ * When `allowedDomains` is absent, any publicly routable host is permitted (loopback,
139
+ * link-local, private and metadata addresses are always refused) and the documentation
140
+ * says so rather than implying a restriction that is not enforced.
80
141
  */
81
142
  imageOptimizer?: {
82
143
  allowedDomains?: string[];
144
+ /** Maximum bytes accepted from a remote image host (default: 10 MiB). */
145
+ maxResponseBytes?: number;
146
+ /** Maximum time a remote image fetch may take, in ms (default: 10,000). */
147
+ timeoutMs?: number;
83
148
  };
84
149
  }
85
150
 
@@ -93,20 +158,33 @@ const CONFIG_FILENAMES = ['synapse.config.ts', 'synapse.config.js', 'synapse.con
93
158
 
94
159
  /**
95
160
  * Discovers and loads `synapse.config.(ts|js|mjs)` from the application root.
161
+ *
162
+ * A configuration file that exists but cannot be loaded is **fatal**: the config
163
+ * carries security-relevant defaults (trusted proxy, body ceilings, realtime bounds),
164
+ * so degrading to an empty configuration silently would start the server with
165
+ * settings nobody chose. Absence of the file is not an error — it means "defaults".
96
166
  */
97
167
  export async function loadSynapseConfig(appDir: string): Promise<SynapseConfig> {
98
168
  for (const filename of CONFIG_FILENAMES) {
99
169
  const candidate = path.join(appDir, filename);
100
- if (fs.existsSync(candidate)) {
101
- try {
102
- const mod = await import(candidate);
103
- const config = mod.default || mod;
104
- return typeof config === 'function' ? await config() : config;
105
- } catch (err: unknown) {
106
- const errMessage = err instanceof Error ? err.message : String(err);
107
- console.warn(`[SynapseConfig] Aviso ao carregar ${filename}: ${errMessage}`);
108
- }
170
+
171
+ if (!fs.existsSync(candidate)) {
172
+ continue;
109
173
  }
174
+
175
+ let mod: { default?: unknown } & Record<string, unknown>;
176
+
177
+ try {
178
+ mod = (await import(candidate)) as { default?: unknown } & Record<string, unknown>;
179
+ } catch (err: unknown) {
180
+ const errMessage = err instanceof Error ? err.message : String(err);
181
+
182
+ throw new Error(`[SynapseConfig] falha ao carregar ${filename}: ${errMessage}`);
183
+ }
184
+
185
+ const config = (mod.default || mod) as SynapseConfig | (() => SynapseConfig | Promise<SynapseConfig>);
186
+
187
+ return typeof config === 'function' ? await config() : config;
110
188
  }
111
189
 
112
190
  return {};
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * SynapseJS - Database Context & Query Builder Abstraction
3
3
  *
4
- * Provides a clean, type-safe persistence contract without reflection-based ORM bloat.
5
- * Compatible with raw parameterized SQL, Kysely, and Drizzle query builders.
4
+ * Provides a clean, type-safe persistence contract without reflection-based ORM bloat:
5
+ * raw parameterized SQL plus a relational query builder, with no adapter for any other
6
+ * query builder.
6
7
  */
7
8
 
8
9
  export interface QueryResult<T = unknown> {
@@ -112,6 +113,24 @@ export interface DatabaseClient {
112
113
  /**
113
114
  * In-memory Mock Database Client for deterministic testing and PBT (Fast-Check).
114
115
  */
116
+ /** Equality and `eq`/`in` matching, which is what the in-memory store can honour honestly. */
117
+ function rowMatchesCondition(row: Record<string, unknown>, where: WhereCondition): boolean {
118
+ return Object.entries(where).every(([column, condition]) => {
119
+ const value = row[column];
120
+
121
+ if (condition !== null && typeof condition === 'object' && 'op' in (condition as Record<string, unknown>)) {
122
+ const { op, val } = condition as { op: string; val: unknown };
123
+
124
+ if (op === 'eq') return value === val;
125
+ if (op === 'in') return Array.isArray(val) && val.includes(value);
126
+
127
+ return false;
128
+ }
129
+
130
+ return value === condition;
131
+ });
132
+ }
133
+
115
134
  export class MockDatabaseClient implements DatabaseClient {
116
135
  private tables: Map<string, Array<Record<string, unknown>>> = new Map();
117
136
  private queryHandlers: Array<{
@@ -228,18 +247,61 @@ export class MockDatabaseClient implements DatabaseClient {
228
247
  async update<T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition): Promise<T[]> {
229
248
  const { sql, params } = compileUpdate(table, data, where);
230
249
  const results = await this.query<T>(sql, params);
231
- if (results.length > 0) return results;
232
- return [data as T];
250
+
251
+ if (results.length > 0) {
252
+ return results;
253
+ }
254
+
255
+ // No row matched, so no row is returned: reporting the payload back would be a
256
+ // write that never happened.
257
+ const rows = this.tables.get(table);
258
+
259
+ if (!rows) {
260
+ return [];
261
+ }
262
+
263
+ const updated = rows.filter((row) => rowMatchesCondition(row, where));
264
+ for (const row of updated) {
265
+ Object.assign(row, data);
266
+ }
267
+
268
+ return updated as unknown as T[];
233
269
  }
234
270
 
235
271
  async delete(table: string, where: WhereCondition): Promise<number> {
236
272
  const { sql, params } = compileDelete(table, where);
237
273
  const results = await this.query(sql, params);
238
- return results.length > 0 ? results.length : 1;
274
+
275
+ if (results.length > 0) {
276
+ return results.length;
277
+ }
278
+
279
+ // A delete that matched nothing removed nothing.
280
+ const rows = this.tables.get(table);
281
+
282
+ if (!rows) {
283
+ return 0;
284
+ }
285
+
286
+ const survivors = rows.filter((row) => !rowMatchesCondition(row, where));
287
+ const removed = rows.length - survivors.length;
288
+ this.tables.set(table, survivors);
289
+
290
+ return removed;
239
291
  }
240
292
 
241
293
  async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
242
- return operation(this);
294
+ const snapshot = new Map(
295
+ Array.from(this.tables.entries()).map(([table, rows]) => [table, rows.map((row) => ({ ...row }))])
296
+ );
297
+
298
+ try {
299
+ return await operation(this);
300
+ } catch (error) {
301
+ // A rolled-back transaction discards the writes made inside it.
302
+ this.tables = snapshot;
303
+ throw error;
304
+ }
243
305
  }
244
306
 
245
307
  close(): void {}
package/src/core/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './database-factory';
7
7
  export * from './image-optimizer';
8
8
  export * from './jobs';
9
9
  export * from './machine-types';
10
+ export * from './path-guard';
10
11
  export * from './postgres-client';
11
12
  export * from './postgres-queue';
12
13
  export * from './query-builder';
@@ -14,5 +15,7 @@ export * from './session-context';
14
15
  export * from './session-token';
15
16
  export * from './sqlite-client';
16
17
  export * from './storage';
18
+ export * from './topics';
17
19
  export * from './validation';
20
+ export * from './webhook-signature';
18
21
  export * from './websocket';
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SynapseJS - Path Segment Guard
3
+ *
4
+ * The single rule that decides whether caller-supplied input may become one
5
+ * segment of a filesystem path. Every write surface — slice scaffolding,
6
+ * shared-module scaffolding, application creation and the upload parser —
7
+ * passes its input through this validator before joining, so containment
8
+ * cannot hold on one path and be missing on a sibling.
9
+ *
10
+ * It rejects rather than rewrites: a silently sanitized name turns a traversal
11
+ * into "expected behaviour".
12
+ */
13
+
14
+ import * as path from 'path';
15
+ import { Err, Ok, type Result } from './machine-types';
16
+
17
+ export const MAX_PATH_SEGMENT_LENGTH = 120;
18
+
19
+ /** An ordinary name: alphanumeric start, then alphanumerics, dot, underscore or dash. */
20
+ const SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
21
+
22
+ /** The same rule as a pattern source, for schemas that declare the constraint. */
23
+ export const PATH_SEGMENT_PATTERN = SEGMENT_PATTERN.source;
24
+
25
+ export type PathSegmentErrorCode = 'INVALID_PATH_SEGMENT';
26
+
27
+ /**
28
+ * Accepts a caller-supplied string only when it is one safe path segment.
29
+ *
30
+ * Rejections: separators, parent traversal, absolute prefixes, empty or
31
+ * blank segments, over-length input, and anything outside the plain-name
32
+ * pattern. Every write surface that joins caller input into a filesystem
33
+ * path applies exactly this rule.
34
+ */
35
+ export function validatePathSegment(
36
+ raw: string | null | undefined,
37
+ maxLength: number = MAX_PATH_SEGMENT_LENGTH
38
+ ): Result<string, PathSegmentErrorCode> {
39
+ if (typeof raw !== 'string') {
40
+ return Err('INVALID_PATH_SEGMENT');
41
+ }
42
+
43
+ const segment = raw.trim();
44
+
45
+ if (!segment || segment.length > maxLength) {
46
+ return Err('INVALID_PATH_SEGMENT');
47
+ }
48
+
49
+ if (segment.includes('/') || segment.includes('\\') || segment.includes('\0')) {
50
+ return Err('INVALID_PATH_SEGMENT');
51
+ }
52
+
53
+ if (segment.includes('..')) {
54
+ return Err('INVALID_PATH_SEGMENT');
55
+ }
56
+
57
+ if (path.isAbsolute(segment) || /^[A-Za-z]:/.test(segment)) {
58
+ return Err('INVALID_PATH_SEGMENT');
59
+ }
60
+
61
+ if (!SEGMENT_PATTERN.test(segment)) {
62
+ return Err('INVALID_PATH_SEGMENT');
63
+ }
64
+
65
+ return Ok(segment);
66
+ }
67
+
68
+ /**
69
+ * True only when `target` resolves to a location strictly inside `base`.
70
+ *
71
+ * This is the post-join assertion: character validation alone is defeated by
72
+ * a compound path that looks safe segment by segment, so the resolved target
73
+ * is compared against the resolved base before any write.
74
+ */
75
+ export function isInsideBase(base: string, target: string): boolean {
76
+ const resolvedBase = path.resolve(base);
77
+ const resolvedTarget = path.resolve(target);
78
+ const relative = path.relative(resolvedBase, resolvedTarget);
79
+
80
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
81
+ }
82
+
83
+ /**
84
+ * Validates every segment, joins them onto `base` and asserts the resolved
85
+ * target stays inside it. The only way to turn caller input into a write path.
86
+ */
87
+ export function resolveWithinBase(base: string, ...segments: string[]): Result<string, PathSegmentErrorCode> {
88
+ const resolvedBase = path.resolve(base);
89
+
90
+ for (const segment of segments) {
91
+ const validated = validatePathSegment(segment);
92
+
93
+ if (!validated.ok) {
94
+ return validated;
95
+ }
96
+ }
97
+
98
+ const target = path.join(resolvedBase, ...segments);
99
+
100
+ if (!isInsideBase(resolvedBase, target)) {
101
+ return Err('INVALID_PATH_SEGMENT');
102
+ }
103
+
104
+ return Ok(target);
105
+ }
@@ -146,14 +146,32 @@ export class PostgresQueueEngine {
146
146
  );
147
147
 
148
148
  for (const deadJob of deadJobs) {
149
- await this.db
150
- .query(
151
- `INSERT INTO _synapse_jobs_dlq (id, name, payload, attempts, max_attempts, last_error, failed_at)
152
- VALUES ($1, $2, $3, $4, $5, $6, $7)
149
+ // `job_id` is NOT NULL in the table, and the id is derived from the job so a
150
+ // repeated recovery of the same zombie stays idempotent. A failure here is
151
+ // logged: swallowing it is how a dead-letter queue silently holds nothing.
152
+ const dlqId = `dlq_${deadJob.id}`;
153
+
154
+ try {
155
+ await this.db.query(
156
+ `INSERT INTO _synapse_jobs_dlq (id, job_id, name, payload, attempts, max_attempts, last_error, failed_at)
157
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
153
158
  ON CONFLICT (id) DO UPDATE SET attempts = EXCLUDED.attempts, last_error = EXCLUDED.last_error, failed_at = EXCLUDED.failed_at;`,
154
- [deadJob.id, deadJob.name, deadJob.payload, deadJob.attempts, deadJob.max_attempts, deadJob.last_error, now]
155
- )
156
- .catch(() => {});
159
+ [
160
+ dlqId,
161
+ deadJob.id,
162
+ deadJob.name,
163
+ deadJob.payload,
164
+ deadJob.attempts,
165
+ deadJob.max_attempts,
166
+ deadJob.last_error,
167
+ now
168
+ ]
169
+ );
170
+ } catch (error: unknown) {
171
+ this.logger.warn(
172
+ `[DLQ] Falha ao registrar o job ${deadJob.id} na dead letter queue: ${error instanceof Error ? error.message : String(error)}`
173
+ );
174
+ }
157
175
  }
158
176
 
159
177
  const rows = await this.db.query<JobRecord>(