@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
@@ -38,10 +38,29 @@ export interface JoinClause {
38
38
  as?: string;
39
39
  }
40
40
 
41
+ export interface AggregateItem {
42
+ fn: 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX';
43
+ column?: string;
44
+ as?: string;
45
+ }
46
+
47
+ export interface HavingAggregateCondition {
48
+ fn: 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX';
49
+ column?: string;
50
+ op: WhereOperator;
51
+ value?: unknown;
52
+ }
53
+
54
+ export type HavingInput = WhereCondition | HavingAggregateCondition | HavingAggregateCondition[];
55
+
41
56
  export interface QueryOptions<T = Record<string, unknown>> {
42
57
  select?: Array<keyof T | string>;
58
+ distinct?: boolean;
59
+ aggregations?: AggregateItem[];
43
60
  where?: WhereCondition;
44
61
  join?: JoinClause[];
62
+ groupBy?: string[] | string;
63
+ having?: HavingInput;
45
64
  orderBy?: Record<string, 'asc' | 'desc' | 'ASC' | 'DESC'> | string;
46
65
  limit?: number;
47
66
  offset?: number;
@@ -73,6 +92,8 @@ const VALID_WHERE_OPERATORS = new Set<string>([
73
92
  'BETWEEN'
74
93
  ]);
75
94
 
95
+ const VALID_AGGREGATE_FUNCTIONS = new Set<string>(['COUNT', 'SUM', 'AVG', 'MIN', 'MAX']);
96
+
76
97
  export function sanitizeOperator(op: string): WhereOperator {
77
98
  const normalized = op.trim().toUpperCase();
78
99
  if (!VALID_WHERE_OPERATORS.has(normalized)) {
@@ -109,41 +130,114 @@ function normalizeWhere(where?: WhereCondition): WhereClauseItem[] {
109
130
  return items;
110
131
  }
111
132
 
133
+ /** Compiles one expression and its operator into a parameterized SQL condition. */
134
+ function compileCondition(expression: string, op: string, value: unknown, params: unknown[]): string {
135
+ const normalized = sanitizeOperator(op);
136
+
137
+ if (normalized === 'IS NULL' || normalized === 'IS NOT NULL') {
138
+ return `${expression} ${normalized}`;
139
+ }
140
+
141
+ if (normalized === 'IN' || normalized === 'NOT IN') {
142
+ const arr = Array.isArray(value) ? value : [value];
143
+ if (arr.length === 0) {
144
+ return normalized === 'IN' ? '1=0' : '1=1';
145
+ }
146
+
147
+ const placeholders = arr.map((entry) => {
148
+ params.push(entry);
149
+ return `$${params.length}`;
150
+ });
151
+
152
+ return `${expression} ${normalized} (${placeholders.join(', ')})`;
153
+ }
154
+
155
+ if (normalized === 'BETWEEN') {
156
+ const arr = Array.isArray(value) ? value : [];
157
+ if (arr.length !== 2) {
158
+ throw new Error(`BETWEEN operator requires an array of 2 values, received: ${JSON.stringify(value)}`);
159
+ }
160
+
161
+ params.push(arr[0]);
162
+ const p1 = `$${params.length}`;
163
+ params.push(arr[1]);
164
+ const p2 = `$${params.length}`;
165
+ return `${expression} BETWEEN ${p1} AND ${p2}`;
166
+ }
167
+
168
+ params.push(value);
169
+ return `${expression} ${normalized} $${params.length}`;
170
+ }
171
+
112
172
  /** Compiles WhereClauseItem array into parameterized SQL condition strings */
113
173
  export function compileWhereClauses(whereItems: WhereClauseItem[], params: unknown[]): string[] {
174
+ return whereItems.map((item) => compileCondition(sanitizeIdentifier(item.column), item.op, item.value, params));
175
+ }
176
+
177
+ /**
178
+ * Compiles HAVING conditions. A condition that names an aggregation alias is
179
+ * compiled as the aggregate expression, so PostgreSQL (which rejects output
180
+ * aliases in HAVING) and SQLite receive the same portable SQL.
181
+ */
182
+ function compileHaving(
183
+ having: HavingInput,
184
+ aggregations: AggregateItem[],
185
+ groupBy: string[] | string | undefined,
186
+ params: unknown[]
187
+ ): string[] {
188
+ const aliases = new Map<string, string>();
189
+ const groupedColumns = new Set<string>();
190
+
191
+ if (groupBy) {
192
+ const columns = Array.isArray(groupBy) ? groupBy : groupBy.split(',').map((column) => column.trim());
193
+ for (const column of columns) {
194
+ groupedColumns.add(sanitizeIdentifier(column.trim()));
195
+ }
196
+ }
197
+
198
+ for (const aggregation of aggregations) {
199
+ const fn = aggregation.fn.toUpperCase();
200
+ if (!VALID_AGGREGATE_FUNCTIONS.has(fn)) {
201
+ throw new Error(`Invalid aggregate function: "${aggregation.fn}"`);
202
+ }
203
+
204
+ const column = aggregation.column?.trim() || '*';
205
+ const expression = `${fn}(${column === '*' ? '*' : sanitizeIdentifier(column)})`;
206
+
207
+ if (aggregation.as) {
208
+ aliases.set(aggregation.as, expression);
209
+ }
210
+ }
211
+
212
+ const items: Array<WhereClauseItem | HavingAggregateCondition> = Array.isArray(having)
213
+ ? having
214
+ : typeof having === 'object' && having !== null && 'fn' in having
215
+ ? [having as HavingAggregateCondition]
216
+ : normalizeWhere(having as WhereCondition);
217
+
114
218
  const conditions: string[] = [];
115
219
 
116
- for (const item of whereItems) {
117
- const col = sanitizeIdentifier(item.column);
118
- const op = sanitizeOperator(item.op);
119
-
120
- if (op === 'IS NULL' || op === 'IS NOT NULL') {
121
- conditions.push(`${col} ${op}`);
122
- } else if (op === 'IN' || op === 'NOT IN') {
123
- const arr = Array.isArray(item.value) ? item.value : [item.value];
124
- if (arr.length === 0) {
125
- conditions.push(op === 'IN' ? '1=0' : '1=1');
126
- } else {
127
- const placeholders = arr.map((val) => {
128
- params.push(val);
129
- return `$${params.length}`;
130
- });
131
- conditions.push(`${col} ${op} (${placeholders.join(', ')})`);
132
- }
133
- } else if (op === 'BETWEEN') {
134
- const arr = Array.isArray(item.value) ? item.value : [];
135
- if (arr.length !== 2) {
136
- throw new Error(`BETWEEN operator requires an array of 2 values, received: ${JSON.stringify(item.value)}`);
220
+ for (const item of items) {
221
+ if ('fn' in item) {
222
+ const fn = item.fn.toUpperCase();
223
+ if (!VALID_AGGREGATE_FUNCTIONS.has(fn)) {
224
+ throw new Error(`Invalid aggregate function: "${item.fn}"`);
137
225
  }
138
- params.push(arr[0]);
139
- const p1 = `$${params.length}`;
140
- params.push(arr[1]);
141
- const p2 = `$${params.length}`;
142
- conditions.push(`${col} BETWEEN ${p1} AND ${p2}`);
143
- } else {
144
- params.push(item.value);
145
- conditions.push(`${col} ${op} $${params.length}`);
226
+
227
+ const column = item.column?.trim() || '*';
228
+ const expression = `${fn}(${column === '*' ? '*' : sanitizeIdentifier(column)})`;
229
+ conditions.push(compileCondition(expression, item.op, item.value, params));
230
+ continue;
146
231
  }
232
+
233
+ const aliasExpression = aliases.get(item.column);
234
+ const column = sanitizeIdentifier(item.column);
235
+
236
+ if (!aliasExpression && !groupedColumns.has(column)) {
237
+ throw new Error(`Invalid HAVING column: "${item.column}" must be a groupBy column or an aggregation alias`);
238
+ }
239
+
240
+ conditions.push(compileCondition(aliasExpression ?? column, item.op, item.value, params));
147
241
  }
148
242
 
149
243
  return conditions;
@@ -200,10 +294,28 @@ export function compileSelect(table: string, options?: QueryOptions): { sql: str
200
294
  return safeCol;
201
295
  })
202
296
  );
203
- } else if (!options?.join || options.join.length === 0) {
204
- selectCols.push('*');
205
- } else {
206
- selectCols.push(`${safeTable}.*`);
297
+ }
298
+
299
+ // Handle explicit aggregations
300
+ if (options?.aggregations && options.aggregations.length > 0) {
301
+ for (const agg of options.aggregations) {
302
+ const fn = agg.fn.toUpperCase();
303
+ if (!VALID_AGGREGATE_FUNCTIONS.has(fn)) {
304
+ throw new Error(`Invalid aggregate function: "${agg.fn}"`);
305
+ }
306
+ const rawCol = agg.column?.trim() || '*';
307
+ const col = rawCol === '*' ? '*' : sanitizeIdentifier(rawCol);
308
+ const alias = agg.as ? ` AS "${sanitizeIdentifier(agg.as)}"` : '';
309
+ selectCols.push(`${fn}(${col})${alias}`);
310
+ }
311
+ }
312
+
313
+ if (selectCols.length === 0) {
314
+ if (!options?.join || options.join.length === 0) {
315
+ selectCols.push('*');
316
+ } else {
317
+ selectCols.push(`${safeTable}.*`);
318
+ }
207
319
  }
208
320
 
209
321
  // Handle joins
@@ -238,7 +350,8 @@ export function compileSelect(table: string, options?: QueryOptions): { sql: str
238
350
  }
239
351
  }
240
352
 
241
- let sql = `SELECT ${selectCols.join(', ')} FROM ${safeTable}`;
353
+ const distinctPrefix = options?.distinct ? 'DISTINCT ' : '';
354
+ let sql = `SELECT ${distinctPrefix}${selectCols.join(', ')} FROM ${safeTable}`;
242
355
  if (joinClauses.length > 0) {
243
356
  sql += ` ${joinClauses.join(' ')}`;
244
357
  }
@@ -252,6 +365,25 @@ export function compileSelect(table: string, options?: QueryOptions): { sql: str
252
365
  }
253
366
  }
254
367
 
368
+ // 3. GROUP BY clause
369
+ if (options?.groupBy) {
370
+ const groupCols = Array.isArray(options.groupBy)
371
+ ? options.groupBy
372
+ : options.groupBy.split(',').map((c) => c.trim());
373
+ const safeGroupCols = groupCols.map((c) => sanitizeIdentifier(c.trim())).filter(Boolean);
374
+ if (safeGroupCols.length > 0) {
375
+ sql += ` GROUP BY ${safeGroupCols.join(', ')}`;
376
+ }
377
+ }
378
+
379
+ // 4. HAVING clause
380
+ if (options?.having) {
381
+ const conditions = compileHaving(options.having, options.aggregations ?? [], options.groupBy, params);
382
+ if (conditions.length > 0) {
383
+ sql += ` HAVING ${conditions.join(' AND ')}`;
384
+ }
385
+ }
386
+
255
387
  // 3. ORDER BY clause
256
388
  if (options?.orderBy) {
257
389
  if (typeof options.orderBy === 'string') {
@@ -13,6 +13,7 @@ export interface SessionContext {
13
13
  readonly tenantId?: string;
14
14
  readonly email?: string;
15
15
  readonly roles: string[];
16
+ readonly permissions?: string[];
16
17
  readonly isAuthenticated: boolean;
17
18
  readonly token?: string;
18
19
  readonly metadata?: Record<string, unknown>;
@@ -21,6 +22,7 @@ export interface SessionContext {
21
22
  export function AnonymousSession(tenantId?: string): SessionContext {
22
23
  return {
23
24
  roles: [],
25
+ permissions: [],
24
26
  isAuthenticated: false,
25
27
  tenantId
26
28
  };
@@ -31,6 +33,7 @@ export function createSession(data: {
31
33
  tenantId?: string;
32
34
  email?: string;
33
35
  roles?: string[];
36
+ permissions?: string[];
34
37
  token?: string;
35
38
  metadata?: Record<string, unknown>;
36
39
  }): SessionContext {
@@ -39,6 +42,7 @@ export function createSession(data: {
39
42
  tenantId: data.tenantId,
40
43
  email: data.email,
41
44
  roles: data.roles || ['user'],
45
+ permissions: data.permissions || [],
42
46
  isAuthenticated: true,
43
47
  token: data.token,
44
48
  metadata: data.metadata || {}
@@ -53,18 +57,55 @@ export function hasAnyRole(session: SessionContext, roles: string[]): boolean {
53
57
  return session.isAuthenticated && roles.some((r) => session.roles.includes(r));
54
58
  }
55
59
 
60
+ export function hasPermission(session: SessionContext, permission: string): boolean {
61
+ if (!session.isAuthenticated) return false;
62
+
63
+ const adminBypass = process.env.SYNAPSE_RBAC_ADMIN_BYPASS !== 'false';
64
+
65
+ if (adminBypass && (session.roles.includes('admin') || session.roles.includes('superadmin'))) {
66
+ return true;
67
+ }
68
+
69
+ return Boolean(session.permissions?.includes(permission));
70
+ }
71
+
72
+ export function hasAllPermissions(session: SessionContext, permissions: string[]): boolean {
73
+ return session.isAuthenticated && permissions.every((p) => hasPermission(session, p));
74
+ }
75
+
56
76
  export type AuthCheckResult = Result<SessionContext, 'UNAUTHORIZED' | 'FORBIDDEN'>;
57
77
 
58
- export function requireAuth(session?: SessionContext, requiredRoles?: string[]): AuthCheckResult {
78
+ export interface AuthRequirements {
79
+ roles?: string[];
80
+ permissions?: string[];
81
+ }
82
+
83
+ export function requireAuth(session?: SessionContext, requirements?: string[] | AuthRequirements): AuthCheckResult {
59
84
  if (!session?.isAuthenticated) {
60
85
  return Err('UNAUTHORIZED');
61
86
  }
62
87
 
63
- if (requiredRoles && requiredRoles.length > 0) {
64
- const authorized = hasAnyRole(session, requiredRoles);
65
- if (!authorized) {
88
+ if (!requirements) {
89
+ return Ok(session);
90
+ }
91
+
92
+ if (Array.isArray(requirements)) {
93
+ if (requirements.length > 0 && !hasAnyRole(session, requirements)) {
66
94
  return Err('FORBIDDEN');
67
95
  }
96
+ return Ok(session);
97
+ }
98
+
99
+ if (requirements.roles && requirements.roles.length > 0 && !hasAnyRole(session, requirements.roles)) {
100
+ return Err('FORBIDDEN');
101
+ }
102
+
103
+ if (
104
+ requirements.permissions &&
105
+ requirements.permissions.length > 0 &&
106
+ !hasAllPermissions(session, requirements.permissions)
107
+ ) {
108
+ return Err('FORBIDDEN');
68
109
  }
69
110
 
70
111
  return Ok(session);
@@ -18,6 +18,8 @@ export interface SessionClaims {
18
18
  userId: string;
19
19
  roles: string[];
20
20
  tenantId?: string;
21
+ /** Fine-grained permissions carried by the signed token. */
22
+ permissions?: string[];
21
23
  /** Expiry, in seconds since the epoch. */
22
24
  exp: number;
23
25
  }
@@ -220,7 +222,7 @@ function constantTimeEquals(left: string, right: string): boolean {
220
222
  }
221
223
 
222
224
  export function signSessionToken(
223
- claims: { userId: string; roles: string[]; tenantId?: string },
225
+ claims: { userId: string; roles: string[]; tenantId?: string; permissions?: string[] },
224
226
  secret: string,
225
227
  maxAgeSeconds = 86_400
226
228
  ): string {
@@ -229,6 +231,7 @@ export function signSessionToken(
229
231
  userId: claims.userId,
230
232
  roles: claims.roles,
231
233
  tenantId: claims.tenantId,
234
+ permissions: claims.permissions,
232
235
  exp: Math.floor(Date.now() / 1000) + maxAgeSeconds
233
236
  })
234
237
  );
@@ -269,6 +272,18 @@ export function verifySessionToken(
269
272
  return Err('INVALID_TOKEN');
270
273
  }
271
274
 
275
+ if (claims.permissions !== undefined) {
276
+ if (
277
+ !Array.isArray(claims.permissions) ||
278
+ claims.permissions.length > 100 ||
279
+ claims.permissions.some(
280
+ (permission) => typeof permission !== 'string' || permission.length === 0 || permission.length > 100
281
+ )
282
+ ) {
283
+ return Err('INVALID_TOKEN');
284
+ }
285
+ }
286
+
272
287
  if (claims.exp * 1000 < Date.now()) {
273
288
  return Err('EXPIRED_TOKEN');
274
289
  }
@@ -166,6 +166,26 @@ export class SqliteDatabaseClient implements DatabaseClient {
166
166
  return rows.length;
167
167
  }
168
168
 
169
+ async count(table: string, where?: WhereCondition): Promise<number> {
170
+ const { sql, params } = compileSelect(table, {
171
+ aggregations: [{ fn: 'COUNT', column: '*', as: 'total' }],
172
+ where
173
+ });
174
+ const row = await this.queryOne<{ total: number | string }>(sql, params);
175
+ return row ? Number(row.total) : 0;
176
+ }
177
+
178
+ async exists(table: string, where: WhereCondition): Promise<boolean> {
179
+ const { sql, params } = compileSelect(table, { select: ['1'], where, limit: 1 });
180
+ const row = await this.queryOne<Record<string, unknown>>(sql, params);
181
+
182
+ return row !== null;
183
+ }
184
+
185
+ async aggregate<T = Record<string, unknown>>(table: string, options: QueryOptions): Promise<T[]> {
186
+ return this.findMany<T>(table, options);
187
+ }
188
+
169
189
  async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
170
190
  if (this.txDepth > 0) {
171
191
  // Reentrant transaction: use SQLite SAVEPOINT