@ismaelsoilet/synapsejs 1.9.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.
Files changed (38) hide show
  1. package/README.md +4 -3
  2. package/bin/synapse.ts +74 -2
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +2 -0
  13. package/src/compiler/contract.ts +1 -1
  14. package/src/compiler/oracle-runner.ts +10 -1
  15. package/src/compiler/scaffolder.ts +113 -4
  16. package/src/compiler/slice-splitter.ts +21 -3
  17. package/src/compiler/slice-templates.ts +234 -10
  18. package/src/compiler/typescript-api.ts +33 -0
  19. package/src/core/action-context.ts +50 -1
  20. package/src/core/cron-parser.ts +167 -0
  21. package/src/core/database-client.ts +35 -0
  22. package/src/core/index.ts +2 -0
  23. package/src/core/jobs.ts +37 -0
  24. package/src/core/oauth-client.ts +322 -0
  25. package/src/core/postgres-client.ts +37 -0
  26. package/src/core/postgres-queue.ts +51 -2
  27. package/src/core/query-builder.ts +166 -34
  28. package/src/core/session-context.ts +45 -4
  29. package/src/core/session-token.ts +16 -1
  30. package/src/core/sqlite-client.ts +20 -0
  31. package/src/mcp/server.ts +257 -1
  32. package/src/mcp/tools.ts +92 -1
  33. package/src/project-status.ts +103 -0
  34. package/src/runtime/client-entry.ts +11 -5
  35. package/src/runtime/queue-engine.ts +120 -5
  36. package/src/runtime/security-headers.ts +35 -0
  37. package/src/runtime/server.ts +300 -58
  38. package/templates/starter/package.json +2 -2
package/src/core/jobs.ts CHANGED
@@ -8,10 +8,35 @@
8
8
  * });
9
9
  */
10
10
 
11
+ import { Value } from '@sinclair/typebox/value';
11
12
  import type { ActionContext } from './action-context';
12
13
 
13
14
  export type JobHandlerFn<TPayload = unknown> = (payload: TPayload, ctx: ActionContext) => Promise<void>;
14
15
 
16
+ export type JobScheduleErrorCode = 'CRON_UNSUPPORTED_ENGINE' | 'CONFLICTING_SCHEDULE' | 'INVALID_CRON_PAYLOAD';
17
+
18
+ export class JobScheduleError extends Error {
19
+ readonly code: JobScheduleErrorCode;
20
+
21
+ constructor(code: JobScheduleErrorCode, message: string) {
22
+ super(`${code}: ${message}`);
23
+ this.name = 'JobScheduleError';
24
+ this.code = code;
25
+ }
26
+ }
27
+
28
+ export function jobPayloadMatchesSchema(schema: unknown, payload: unknown): boolean {
29
+ if (!schema || typeof schema !== 'object') {
30
+ return true;
31
+ }
32
+
33
+ try {
34
+ return Value.Check(schema as never, payload);
35
+ } catch {
36
+ return true;
37
+ }
38
+ }
39
+
15
40
  export interface JobDefinition<TPayload = unknown> {
16
41
  /** Unique name identifier for the job */
17
42
  readonly name: string;
@@ -21,6 +46,12 @@ export interface JobDefinition<TPayload = unknown> {
21
46
  readonly retryLimit?: number;
22
47
  /** Initial backoff in seconds for exponential backoff retries (default: 5) */
23
48
  readonly backoffSeconds?: number;
49
+ /** Optional recurring cron expression (e.g. '0 0 * * *' or hourly/daily) */
50
+ readonly cron?: string;
51
+ /** Optional payload factory used for the first run and each recurrence. */
52
+ readonly cronPayload?: () => TPayload;
53
+ /** Optional future execution timestamp in milliseconds */
54
+ readonly runAt?: number;
24
55
  /** Optional schema validator (e.g. TypeBox schema) */
25
56
  readonly schema?: unknown;
26
57
  }
@@ -31,6 +62,9 @@ export interface JobOptions<TPayload = unknown> {
31
62
  perform?: JobHandlerFn<TPayload>;
32
63
  retryLimit?: number;
33
64
  backoffSeconds?: number;
65
+ cron?: string;
66
+ cronPayload?: () => TPayload;
67
+ runAt?: number;
34
68
  schema?: unknown;
35
69
  }
36
70
 
@@ -48,6 +82,9 @@ export function defineJob<TPayload = unknown>(options: JobOptions<TPayload>): Jo
48
82
  handler,
49
83
  retryLimit: options.retryLimit ?? 3,
50
84
  backoffSeconds: options.backoffSeconds ?? 5,
85
+ cron: options.cron,
86
+ cronPayload: options.cronPayload,
87
+ runAt: options.runAt,
51
88
  schema: options.schema
52
89
  });
53
90
  }
@@ -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
+ }
@@ -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(
@@ -207,7 +238,8 @@ export class PostgresQueueEngine {
207
238
  }
208
239
 
209
240
  const jobDef = this.registry.get(job.name);
210
- const attempts = Number(job.attempts) + 1;
241
+ // Note: attempts is already incremented in SQL by claimNextJob (`attempts = attempts + 1 RETURNING *`)
242
+ const attempts = Number(job.attempts);
211
243
  const now = Date.now();
212
244
 
213
245
  if (!jobDef) {
@@ -320,6 +352,23 @@ export class PostgresQueueEngine {
320
352
  return true;
321
353
  }
322
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
+
323
372
  async close(): Promise<void> {
324
373
  // No-op for db instance managed externally
325
374
  }