@ismaelsoilet/synapsejs 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -4
- package/bin/synapse.ts +217 -239
- package/package.json +1 -1
- package/src/compiler/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +114 -5
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +47 -3
- package/src/core/action-context.ts +51 -14
- package/src/core/config.ts +88 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +3 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-queue.ts +25 -7
- package/src/core/session-token.ts +108 -7
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +60 -217
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +47 -0
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/server.ts +876 -180
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/tsconfig.json +1 -0
|
@@ -42,7 +42,12 @@ export interface SplitResult {
|
|
|
42
42
|
artifacts: SplitArtifact[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export type SplitErrorCode =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
+
broadcast: options.broadcast,
|
|
236
|
+
sliceOwner
|
|
200
237
|
});
|
|
201
238
|
return operation(txContext);
|
|
202
239
|
});
|
package/src/core/config.ts
CHANGED
|
@@ -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 |
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
[
|
|
155
|
-
|
|
156
|
-
|
|
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>(
|