@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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Isomorphic OAuth2 & PKCE (Proof Key for Code Exchange) Client
|
|
3
|
+
*
|
|
4
|
+
* Provides RFC 7636 compliant PKCE generators and standard authorization / token exchange
|
|
5
|
+
* flows for Google, GitHub, Microsoft, and generic OAuth2/OIDC providers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Err, Ok, type Result } from './machine-types';
|
|
9
|
+
|
|
10
|
+
export interface PkceChallenge {
|
|
11
|
+
codeVerifier: string;
|
|
12
|
+
codeChallenge: string;
|
|
13
|
+
codeChallengeMethod: 'S256';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates a high-entropy cryptographic code verifier and SHA-256 code challenge (RFC 7636).
|
|
18
|
+
*/
|
|
19
|
+
export function generatePkceChallenge(): PkceChallenge {
|
|
20
|
+
const randomBytes = new Uint8Array(32);
|
|
21
|
+
crypto.getRandomValues(randomBytes);
|
|
22
|
+
|
|
23
|
+
// Base64URL encoding without padding
|
|
24
|
+
const codeVerifier = Buffer.from(randomBytes)
|
|
25
|
+
.toString('base64')
|
|
26
|
+
.replace(/\+/g, '-')
|
|
27
|
+
.replace(/\//g, '_')
|
|
28
|
+
.replace(/=+$/, '');
|
|
29
|
+
|
|
30
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
31
|
+
hasher.update(codeVerifier);
|
|
32
|
+
const codeChallenge = hasher.digest('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
codeVerifier,
|
|
36
|
+
codeChallenge,
|
|
37
|
+
codeChallengeMethod: 'S256'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface OAuthProviderConfig {
|
|
42
|
+
clientId: string;
|
|
43
|
+
clientSecret?: string;
|
|
44
|
+
authorizationUrl: string;
|
|
45
|
+
tokenUrl: string;
|
|
46
|
+
redirectUri: string;
|
|
47
|
+
scope: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BuildAuthUrlOptions {
|
|
51
|
+
config: OAuthProviderConfig;
|
|
52
|
+
state: string;
|
|
53
|
+
codeChallenge?: string;
|
|
54
|
+
extraParams?: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Constructs standard OAuth2 authorization URL with PKCE parameters.
|
|
59
|
+
*/
|
|
60
|
+
export function buildOAuthAuthorizationUrl(options: BuildAuthUrlOptions): string {
|
|
61
|
+
const url = new URL(options.config.authorizationUrl);
|
|
62
|
+
url.searchParams.set('client_id', options.config.clientId);
|
|
63
|
+
url.searchParams.set('redirect_uri', options.config.redirectUri);
|
|
64
|
+
url.searchParams.set('response_type', 'code');
|
|
65
|
+
url.searchParams.set('scope', options.config.scope.join(' '));
|
|
66
|
+
url.searchParams.set('state', options.state);
|
|
67
|
+
|
|
68
|
+
if (options.codeChallenge) {
|
|
69
|
+
url.searchParams.set('code_challenge', options.codeChallenge);
|
|
70
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (options.extraParams) {
|
|
74
|
+
for (const [key, value] of Object.entries(options.extraParams)) {
|
|
75
|
+
url.searchParams.set(key, value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return url.toString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TokenExchangeOptions {
|
|
83
|
+
config: OAuthProviderConfig;
|
|
84
|
+
code: string;
|
|
85
|
+
codeVerifier?: string;
|
|
86
|
+
/** Maximum time to wait for the provider, in milliseconds (default: 10s). */
|
|
87
|
+
timeoutMs?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Generates an opaque, high-entropy OAuth `state` value. */
|
|
91
|
+
export function generateOAuthState(): string {
|
|
92
|
+
const randomBytes = new Uint8Array(32);
|
|
93
|
+
crypto.getRandomValues(randomBytes);
|
|
94
|
+
|
|
95
|
+
return Buffer.from(randomBytes).toString('base64url');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Constant-time comparison of the state emitted by the server and the value returned. */
|
|
99
|
+
export function verifyOAuthState(expected: string, received: string | null | undefined): boolean {
|
|
100
|
+
if (!expected || !received) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const expectedBuf = Buffer.from(expected);
|
|
105
|
+
const receivedBuf = Buffer.from(received);
|
|
106
|
+
|
|
107
|
+
if (expectedBuf.length !== receivedBuf.length) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return require('node:crypto').timingSafeEqual(expectedBuf, receivedBuf);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface OAuthTokenResponse {
|
|
115
|
+
accessToken: string;
|
|
116
|
+
tokenType: string;
|
|
117
|
+
expiresIn?: number;
|
|
118
|
+
refreshToken?: string;
|
|
119
|
+
scope?: string;
|
|
120
|
+
idToken?: string;
|
|
121
|
+
raw: Record<string, unknown>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Exchanges authorization code for access tokens via standard HTTP POST.
|
|
126
|
+
*/
|
|
127
|
+
export async function exchangeOAuthCode(
|
|
128
|
+
options: TokenExchangeOptions
|
|
129
|
+
): Promise<Result<OAuthTokenResponse, 'TOKEN_EXCHANGE_FAILED' | 'NETWORK_ERROR'>> {
|
|
130
|
+
const bodyParams = new URLSearchParams();
|
|
131
|
+
bodyParams.set('client_id', options.config.clientId);
|
|
132
|
+
bodyParams.set('code', options.code);
|
|
133
|
+
bodyParams.set('redirect_uri', options.config.redirectUri);
|
|
134
|
+
bodyParams.set('grant_type', 'authorization_code');
|
|
135
|
+
|
|
136
|
+
if (options.config.clientSecret) {
|
|
137
|
+
bodyParams.set('client_secret', options.config.clientSecret);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (options.codeVerifier) {
|
|
141
|
+
bodyParams.set('code_verifier', options.codeVerifier);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch(options.config.tokenUrl, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {
|
|
151
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
152
|
+
Accept: 'application/json'
|
|
153
|
+
},
|
|
154
|
+
body: bodyParams.toString(),
|
|
155
|
+
signal: controller.signal
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
return Err('TOKEN_EXCHANGE_FAILED');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
163
|
+
const accessToken = String(data.access_token || data.accessToken || '');
|
|
164
|
+
|
|
165
|
+
if (!accessToken) {
|
|
166
|
+
return Err('TOKEN_EXCHANGE_FAILED');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Ok({
|
|
170
|
+
accessToken,
|
|
171
|
+
tokenType: String(data.token_type || data.tokenType || 'Bearer'),
|
|
172
|
+
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
|
|
173
|
+
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
|
174
|
+
scope: typeof data.scope === 'string' ? data.scope : undefined,
|
|
175
|
+
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
|
|
176
|
+
raw: data
|
|
177
|
+
});
|
|
178
|
+
} catch {
|
|
179
|
+
return Err('NETWORK_ERROR');
|
|
180
|
+
} finally {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ============================================================================
|
|
186
|
+
// OAUTH PROVIDER PRESETS & UNIFIED USER PROFILE
|
|
187
|
+
// ============================================================================
|
|
188
|
+
export interface OAuthUserProfile {
|
|
189
|
+
id: string;
|
|
190
|
+
email?: string;
|
|
191
|
+
name?: string;
|
|
192
|
+
avatarUrl?: string;
|
|
193
|
+
provider: 'google' | 'github' | 'discord' | 'oidc';
|
|
194
|
+
raw: Record<string, unknown>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function googleOAuthPreset(options: {
|
|
198
|
+
clientId: string;
|
|
199
|
+
clientSecret: string;
|
|
200
|
+
redirectUri: string;
|
|
201
|
+
}): OAuthProviderConfig {
|
|
202
|
+
return {
|
|
203
|
+
clientId: options.clientId,
|
|
204
|
+
clientSecret: options.clientSecret,
|
|
205
|
+
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
206
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
207
|
+
redirectUri: options.redirectUri,
|
|
208
|
+
scope: ['openid', 'email', 'profile']
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function githubOAuthPreset(options: {
|
|
213
|
+
clientId: string;
|
|
214
|
+
clientSecret: string;
|
|
215
|
+
redirectUri: string;
|
|
216
|
+
}): OAuthProviderConfig {
|
|
217
|
+
return {
|
|
218
|
+
clientId: options.clientId,
|
|
219
|
+
clientSecret: options.clientSecret,
|
|
220
|
+
authorizationUrl: 'https://github.com/login/oauth/authorize',
|
|
221
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
222
|
+
redirectUri: options.redirectUri,
|
|
223
|
+
scope: ['read:user', 'user:email']
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function discordOAuthPreset(options: {
|
|
228
|
+
clientId: string;
|
|
229
|
+
clientSecret: string;
|
|
230
|
+
redirectUri: string;
|
|
231
|
+
}): OAuthProviderConfig {
|
|
232
|
+
return {
|
|
233
|
+
clientId: options.clientId,
|
|
234
|
+
clientSecret: options.clientSecret,
|
|
235
|
+
authorizationUrl: 'https://discord.com/api/oauth2/authorize',
|
|
236
|
+
tokenUrl: 'https://discord.com/api/oauth2/token',
|
|
237
|
+
redirectUri: options.redirectUri,
|
|
238
|
+
scope: ['identify', 'email']
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function fetchOAuthUserProfile(
|
|
243
|
+
provider: 'google' | 'github' | 'discord' | 'oidc',
|
|
244
|
+
accessToken: string,
|
|
245
|
+
userInfoUrl?: string
|
|
246
|
+
): Promise<Result<OAuthUserProfile, 'FETCH_PROFILE_FAILED'>> {
|
|
247
|
+
const url =
|
|
248
|
+
userInfoUrl ||
|
|
249
|
+
(provider === 'google'
|
|
250
|
+
? 'https://openidconnect.googleapis.com/v1/userinfo'
|
|
251
|
+
: provider === 'github'
|
|
252
|
+
? 'https://api.github.com/user'
|
|
253
|
+
: provider === 'discord'
|
|
254
|
+
? 'https://discord.com/api/users/@me'
|
|
255
|
+
: null);
|
|
256
|
+
|
|
257
|
+
if (!url) {
|
|
258
|
+
return Err('FETCH_PROFILE_FAILED');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const res = await fetch(url, {
|
|
263
|
+
headers: {
|
|
264
|
+
Authorization: `Bearer ${accessToken}`,
|
|
265
|
+
Accept: 'application/json',
|
|
266
|
+
'User-Agent': 'SynapseJS'
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
return Err('FETCH_PROFILE_FAILED');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
275
|
+
let id = '';
|
|
276
|
+
let email: string | undefined;
|
|
277
|
+
let name: string | undefined;
|
|
278
|
+
let avatarUrl: string | undefined;
|
|
279
|
+
|
|
280
|
+
if (provider === 'google') {
|
|
281
|
+
id = String(data.sub || data.id || '');
|
|
282
|
+
email = typeof data.email === 'string' ? data.email : undefined;
|
|
283
|
+
name = typeof data.name === 'string' ? data.name : undefined;
|
|
284
|
+
avatarUrl = typeof data.picture === 'string' ? data.picture : undefined;
|
|
285
|
+
} else if (provider === 'github') {
|
|
286
|
+
id = String(data.id || '');
|
|
287
|
+
email = typeof data.email === 'string' ? data.email : undefined;
|
|
288
|
+
name = typeof data.name === 'string' ? data.name : typeof data.login === 'string' ? data.login : undefined;
|
|
289
|
+
avatarUrl = typeof data.avatar_url === 'string' ? data.avatar_url : undefined;
|
|
290
|
+
} else if (provider === 'discord') {
|
|
291
|
+
id = String(data.id || '');
|
|
292
|
+
email = typeof data.email === 'string' ? data.email : undefined;
|
|
293
|
+
name =
|
|
294
|
+
typeof data.global_name === 'string'
|
|
295
|
+
? data.global_name
|
|
296
|
+
: typeof data.username === 'string'
|
|
297
|
+
? data.username
|
|
298
|
+
: undefined;
|
|
299
|
+
avatarUrl = data.avatar ? `https://cdn.discordapp.com/avatars/${id}/${data.avatar}.png` : undefined;
|
|
300
|
+
} else {
|
|
301
|
+
id = String(data.sub || data.id || '');
|
|
302
|
+
email = typeof data.email === 'string' ? data.email : undefined;
|
|
303
|
+
name = typeof data.name === 'string' ? data.name : undefined;
|
|
304
|
+
avatarUrl = typeof data.picture === 'string' ? data.picture : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!id) {
|
|
308
|
+
return Err('FETCH_PROFILE_FAILED');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return Ok({
|
|
312
|
+
id,
|
|
313
|
+
email,
|
|
314
|
+
name,
|
|
315
|
+
avatarUrl,
|
|
316
|
+
provider,
|
|
317
|
+
raw: data
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
return Err('FETCH_PROFILE_FAILED');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -95,6 +95,26 @@ export class PostgresDatabaseClient implements DatabaseClient {
|
|
|
95
95
|
return rows.length;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
async count(table: string, where?: WhereCondition): Promise<number> {
|
|
99
|
+
const { sql, params } = compileSelect(table, {
|
|
100
|
+
aggregations: [{ fn: 'COUNT', column: '*', as: 'total' }],
|
|
101
|
+
where
|
|
102
|
+
});
|
|
103
|
+
const row = await this.queryOne<{ total: number | string }>(sql, params);
|
|
104
|
+
return row ? Number(row.total) : 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async exists(table: string, where: WhereCondition): Promise<boolean> {
|
|
108
|
+
const { sql, params } = compileSelect(table, { select: ['1'], where, limit: 1 });
|
|
109
|
+
const row = await this.queryOne<Record<string, unknown>>(sql, params);
|
|
110
|
+
|
|
111
|
+
return row !== null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async aggregate<T = Record<string, unknown>>(table: string, options: QueryOptions): Promise<T[]> {
|
|
115
|
+
return this.findMany<T>(table, options);
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
99
119
|
const res = await this.client.begin(async (sqlTx) => {
|
|
100
120
|
const txClient: DatabaseClient = {
|
|
@@ -155,6 +175,23 @@ export class PostgresDatabaseClient implements DatabaseClient {
|
|
|
155
175
|
const rows = await txClient.query(sql, params);
|
|
156
176
|
return rows.length;
|
|
157
177
|
},
|
|
178
|
+
count: async (table: string, where?: WhereCondition) => {
|
|
179
|
+
const { sql, params } = compileSelect(table, {
|
|
180
|
+
aggregations: [{ fn: 'COUNT', column: '*', as: 'total' }],
|
|
181
|
+
where
|
|
182
|
+
});
|
|
183
|
+
const row = await txClient.queryOne<{ total: number | string }>(sql, params);
|
|
184
|
+
return row ? Number(row.total) : 0;
|
|
185
|
+
},
|
|
186
|
+
exists: async (table: string, where: WhereCondition) => {
|
|
187
|
+
const { sql, params } = compileSelect(table, { select: ['1'], where, limit: 1 });
|
|
188
|
+
const row = await txClient.queryOne<Record<string, unknown>>(sql, params);
|
|
189
|
+
|
|
190
|
+
return row !== null;
|
|
191
|
+
},
|
|
192
|
+
aggregate: async <U = Record<string, unknown>>(table: string, options: QueryOptions) => {
|
|
193
|
+
return txClient.findMany<U>(table, options);
|
|
194
|
+
},
|
|
158
195
|
transaction: async <U>(subOp: (nestedTx: DatabaseClient) => Promise<U>) => {
|
|
159
196
|
return await subOp(txClient);
|
|
160
197
|
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
createDefaultLogger,
|
|
15
15
|
type JobDefinition,
|
|
16
16
|
type JobEnqueueOptions,
|
|
17
|
+
JobScheduleError,
|
|
18
|
+
jobPayloadMatchesSchema,
|
|
17
19
|
type SessionContext,
|
|
18
20
|
type StructuredLogger
|
|
19
21
|
} from './index';
|
|
@@ -97,6 +99,28 @@ export class PostgresQueueEngine {
|
|
|
97
99
|
|
|
98
100
|
registerJob<T>(job: JobDefinition<T>): void {
|
|
99
101
|
this.registry.set(job.name, job as JobDefinition<unknown>);
|
|
102
|
+
|
|
103
|
+
if (job.cron) {
|
|
104
|
+
throw new JobScheduleError(
|
|
105
|
+
'CRON_UNSUPPORTED_ENGINE',
|
|
106
|
+
`Job "${job.name}" uses cron, which the PostgreSQL queue engine does not implement yet. Use the SQLite QueueEngine or schedule externally.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (job.runAt !== undefined) {
|
|
111
|
+
const payload = job.cronPayload ? job.cronPayload() : ({} as T);
|
|
112
|
+
|
|
113
|
+
if (!jobPayloadMatchesSchema(job.schema, payload)) {
|
|
114
|
+
throw new JobScheduleError(
|
|
115
|
+
'INVALID_CRON_PAYLOAD',
|
|
116
|
+
`Job "${job.name}" has a schema that rejects its initial runAt payload.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
void this.enqueue(job, payload, { runAt: job.runAt }).catch((err) => {
|
|
121
|
+
this.logger.error(`Falha ao registrar agendamento runAt para ${job.name}: ${err}`);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
async enqueue<TPayload>(
|
|
@@ -106,6 +130,13 @@ export class PostgresQueueEngine {
|
|
|
106
130
|
): Promise<string> {
|
|
107
131
|
await this.initSchema();
|
|
108
132
|
|
|
133
|
+
if (options?.runAt !== undefined && options?.delaySeconds !== undefined) {
|
|
134
|
+
throw new JobScheduleError(
|
|
135
|
+
'CONFLICTING_SCHEDULE',
|
|
136
|
+
`Job "${typeof jobOrName === 'string' ? jobOrName : jobOrName.name}" cannot declare both runAt and delaySeconds.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
109
140
|
const id = `job_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
110
141
|
const name = typeof jobOrName === 'string' ? jobOrName : jobOrName.name;
|
|
111
142
|
const maxAttempts = typeof jobOrName === 'object' && jobOrName.retryLimit ? jobOrName.retryLimit : 3;
|
|
@@ -113,7 +144,7 @@ export class PostgresQueueEngine {
|
|
|
113
144
|
const priority = options?.priority ?? 0;
|
|
114
145
|
const delaySeconds = options?.delaySeconds ?? 0;
|
|
115
146
|
const now = Date.now();
|
|
116
|
-
const runAt = now + delaySeconds * 1000;
|
|
147
|
+
const runAt = options?.runAt ?? now + delaySeconds * 1000;
|
|
117
148
|
const payloadStr = JSON.stringify(payload);
|
|
118
149
|
|
|
119
150
|
await this.db.query(
|
|
@@ -146,14 +177,32 @@ export class PostgresQueueEngine {
|
|
|
146
177
|
);
|
|
147
178
|
|
|
148
179
|
for (const deadJob of deadJobs) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
180
|
+
// `job_id` is NOT NULL in the table, and the id is derived from the job so a
|
|
181
|
+
// repeated recovery of the same zombie stays idempotent. A failure here is
|
|
182
|
+
// logged: swallowing it is how a dead-letter queue silently holds nothing.
|
|
183
|
+
const dlqId = `dlq_${deadJob.id}`;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await this.db.query(
|
|
187
|
+
`INSERT INTO _synapse_jobs_dlq (id, job_id, name, payload, attempts, max_attempts, last_error, failed_at)
|
|
188
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
153
189
|
ON CONFLICT (id) DO UPDATE SET attempts = EXCLUDED.attempts, last_error = EXCLUDED.last_error, failed_at = EXCLUDED.failed_at;`,
|
|
154
|
-
[
|
|
155
|
-
|
|
156
|
-
|
|
190
|
+
[
|
|
191
|
+
dlqId,
|
|
192
|
+
deadJob.id,
|
|
193
|
+
deadJob.name,
|
|
194
|
+
deadJob.payload,
|
|
195
|
+
deadJob.attempts,
|
|
196
|
+
deadJob.max_attempts,
|
|
197
|
+
deadJob.last_error,
|
|
198
|
+
now
|
|
199
|
+
]
|
|
200
|
+
);
|
|
201
|
+
} catch (error: unknown) {
|
|
202
|
+
this.logger.warn(
|
|
203
|
+
`[DLQ] Falha ao registrar o job ${deadJob.id} na dead letter queue: ${error instanceof Error ? error.message : String(error)}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
157
206
|
}
|
|
158
207
|
|
|
159
208
|
const rows = await this.db.query<JobRecord>(
|
|
@@ -189,7 +238,8 @@ export class PostgresQueueEngine {
|
|
|
189
238
|
}
|
|
190
239
|
|
|
191
240
|
const jobDef = this.registry.get(job.name);
|
|
192
|
-
|
|
241
|
+
// Note: attempts is already incremented in SQL by claimNextJob (`attempts = attempts + 1 RETURNING *`)
|
|
242
|
+
const attempts = Number(job.attempts);
|
|
193
243
|
const now = Date.now();
|
|
194
244
|
|
|
195
245
|
if (!jobDef) {
|
|
@@ -302,6 +352,23 @@ export class PostgresQueueEngine {
|
|
|
302
352
|
return true;
|
|
303
353
|
}
|
|
304
354
|
|
|
355
|
+
/** SQLite QueueEngine-compatible alias for inspecting the DLQ. */
|
|
356
|
+
async getDeadLetterJobs(limit = 50): Promise<DlqRecord[]> {
|
|
357
|
+
return this.getDlqJobs(limit);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** SQLite QueueEngine-compatible alias: retries the DLQ entry for a job id. */
|
|
361
|
+
async retryJob(jobId: string): Promise<boolean> {
|
|
362
|
+
await this.initSchema();
|
|
363
|
+
const rows = await this.db.query<{ id: string }>(`SELECT id FROM _synapse_jobs_dlq WHERE job_id = $1`, [jobId]);
|
|
364
|
+
|
|
365
|
+
if (rows.length === 0) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return this.retryDlqJob(rows[0].id);
|
|
370
|
+
}
|
|
371
|
+
|
|
305
372
|
async close(): Promise<void> {
|
|
306
373
|
// No-op for db instance managed externally
|
|
307
374
|
}
|