@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
|
@@ -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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
64
|
-
|
|
65
|
-
|
|
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,27 +18,89 @@ 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
|
}
|
|
24
26
|
|
|
25
27
|
export type SessionTokenError = 'INVALID_TOKEN' | 'EXPIRED_TOKEN' | 'TOKEN_REVOKED';
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
/** Default bounds for the in-process revocation set. Both are configurable by env. */
|
|
30
|
+
export const DEFAULT_REVOCATION_MAX_ENTRIES = 10_000;
|
|
31
|
+
export const DEFAULT_REVOCATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/** Insertion-ordered signature -> revocation instant. Bounded by cap and TTL. */
|
|
34
|
+
const inMemoryRevokedSignatures = new Map<string, number>();
|
|
35
|
+
|
|
36
|
+
function revocationMaxEntries(): number {
|
|
37
|
+
const raw = Number(process.env.SYNAPSE_REVOCATION_MAX_ENTRIES);
|
|
38
|
+
|
|
39
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_REVOCATION_MAX_ENTRIES;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function revocationTtlMs(): number {
|
|
43
|
+
const raw = Number(process.env.SYNAPSE_REVOCATION_TTL_MS);
|
|
44
|
+
|
|
45
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_REVOCATION_TTL_MS;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Drops expired entries and enforces the entry cap. Returns how many were pruned.
|
|
50
|
+
* Called on every write and read, so neither store grows without bound.
|
|
51
|
+
*/
|
|
52
|
+
export function pruneSessionTokenRevocations(now = Date.now()): number {
|
|
53
|
+
const ttl = revocationTtlMs();
|
|
54
|
+
let pruned = 0;
|
|
55
|
+
|
|
56
|
+
if (ttl > 0) {
|
|
57
|
+
for (const [signature, revokedAt] of inMemoryRevokedSignatures) {
|
|
58
|
+
if (now - revokedAt > ttl) {
|
|
59
|
+
inMemoryRevokedSignatures.delete(signature);
|
|
60
|
+
pruned++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cap = revocationMaxEntries();
|
|
66
|
+
while (inMemoryRevokedSignatures.size > cap) {
|
|
67
|
+
const oldest = inMemoryRevokedSignatures.keys().next().value;
|
|
68
|
+
|
|
69
|
+
if (oldest === undefined) {
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
inMemoryRevokedSignatures.delete(oldest);
|
|
74
|
+
pruned++;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return pruned;
|
|
78
|
+
}
|
|
28
79
|
|
|
29
80
|
export function revokeSessionToken(token: string): boolean {
|
|
30
81
|
const [, signature] = token.split('.');
|
|
31
82
|
if (!signature) return false;
|
|
32
|
-
inMemoryRevokedSignatures.
|
|
83
|
+
inMemoryRevokedSignatures.set(signature, Date.now());
|
|
84
|
+
pruneSessionTokenRevocations();
|
|
33
85
|
return true;
|
|
34
86
|
}
|
|
35
87
|
|
|
36
88
|
export function isSessionTokenRevoked(token: string): boolean {
|
|
37
89
|
const [, signature] = token.split('.');
|
|
38
90
|
if (!signature) return true;
|
|
91
|
+
|
|
92
|
+
pruneSessionTokenRevocations();
|
|
93
|
+
|
|
39
94
|
return inMemoryRevokedSignatures.has(signature);
|
|
40
95
|
}
|
|
41
96
|
|
|
97
|
+
/** Test/tooling helper: how many signatures the in-process set currently holds. */
|
|
98
|
+
export function sessionTokenRevocationCount(): number {
|
|
99
|
+
pruneSessionTokenRevocations();
|
|
100
|
+
|
|
101
|
+
return inMemoryRevokedSignatures.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
42
104
|
export function clearSessionTokenRevocations(): void {
|
|
43
105
|
inMemoryRevokedSignatures.clear();
|
|
44
106
|
}
|
|
@@ -53,24 +115,64 @@ export async function revokeSessionTokenInDb(db: DatabaseClient, token: string):
|
|
|
53
115
|
await db.query(`
|
|
54
116
|
CREATE TABLE IF NOT EXISTS _synapse_session_blacklist (
|
|
55
117
|
token_signature TEXT PRIMARY KEY,
|
|
56
|
-
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
118
|
+
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
119
|
+
revoked_at_ms INTEGER
|
|
57
120
|
);
|
|
58
121
|
`);
|
|
59
122
|
|
|
123
|
+
try {
|
|
124
|
+
await db.query('ALTER TABLE _synapse_session_blacklist ADD COLUMN revoked_at_ms INTEGER;');
|
|
125
|
+
} catch {
|
|
126
|
+
// Column already present, or the engine refuses the ALTER: either way the
|
|
127
|
+
// table carries the revocation instant it needs for pruning.
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const revokedAtMs = Date.now();
|
|
131
|
+
|
|
60
132
|
await db
|
|
61
|
-
.query(
|
|
133
|
+
.query(
|
|
134
|
+
'INSERT INTO _synapse_session_blacklist (token_signature, revoked_at_ms) VALUES ($1, $2) ON CONFLICT DO NOTHING;',
|
|
135
|
+
[signature, revokedAtMs]
|
|
136
|
+
)
|
|
62
137
|
.catch(async () => {
|
|
63
138
|
// SQLite fallback without ON CONFLICT DO NOTHING if needed
|
|
64
139
|
await db
|
|
65
|
-
.query('INSERT OR IGNORE INTO _synapse_session_blacklist (token_signature) VALUES ($1);', [
|
|
140
|
+
.query('INSERT OR IGNORE INTO _synapse_session_blacklist (token_signature, revoked_at_ms) VALUES ($1, $2);', [
|
|
141
|
+
signature,
|
|
142
|
+
revokedAtMs
|
|
143
|
+
])
|
|
66
144
|
.catch(() => {});
|
|
67
145
|
});
|
|
68
146
|
|
|
69
147
|
// Also sync in-memory for instant local lookup
|
|
70
|
-
inMemoryRevokedSignatures.
|
|
148
|
+
inMemoryRevokedSignatures.set(signature, revokedAtMs);
|
|
149
|
+
pruneSessionTokenRevocations();
|
|
71
150
|
return true;
|
|
72
151
|
}
|
|
73
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Deletes persisted revocations older than the retention window. Returns how many
|
|
155
|
+
* rows were removed; SQLite and PostgreSQL both compare the epoch-millisecond
|
|
156
|
+
* column, so pruning does not depend on either dialect's date functions.
|
|
157
|
+
*/
|
|
158
|
+
export async function pruneSessionTokenRevocationsInDb(db: DatabaseClient, ttlMs = revocationTtlMs()): Promise<number> {
|
|
159
|
+
if (ttlMs <= 0) {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const cutoff = Date.now() - ttlMs;
|
|
165
|
+
const rows = await db.query<{ token_signature: string }>(
|
|
166
|
+
'DELETE FROM _synapse_session_blacklist WHERE revoked_at_ms IS NOT NULL AND revoked_at_ms < $1 RETURNING token_signature;',
|
|
167
|
+
[cutoff]
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return Array.isArray(rows) ? rows.length : 0;
|
|
171
|
+
} catch {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
74
176
|
/**
|
|
75
177
|
* Checks whether a token signature exists in the database blacklist table.
|
|
76
178
|
*/
|
|
@@ -85,7 +187,8 @@ export async function isSessionTokenRevokedInDb(db: DatabaseClient, token: strin
|
|
|
85
187
|
signature
|
|
86
188
|
]);
|
|
87
189
|
if (rows && rows.length > 0) {
|
|
88
|
-
inMemoryRevokedSignatures.
|
|
190
|
+
inMemoryRevokedSignatures.set(signature, Date.now());
|
|
191
|
+
pruneSessionTokenRevocations();
|
|
89
192
|
return true;
|
|
90
193
|
}
|
|
91
194
|
} catch {
|
|
@@ -119,7 +222,7 @@ function constantTimeEquals(left: string, right: string): boolean {
|
|
|
119
222
|
}
|
|
120
223
|
|
|
121
224
|
export function signSessionToken(
|
|
122
|
-
claims: { userId: string; roles: string[]; tenantId?: string },
|
|
225
|
+
claims: { userId: string; roles: string[]; tenantId?: string; permissions?: string[] },
|
|
123
226
|
secret: string,
|
|
124
227
|
maxAgeSeconds = 86_400
|
|
125
228
|
): string {
|
|
@@ -128,6 +231,7 @@ export function signSessionToken(
|
|
|
128
231
|
userId: claims.userId,
|
|
129
232
|
roles: claims.roles,
|
|
130
233
|
tenantId: claims.tenantId,
|
|
234
|
+
permissions: claims.permissions,
|
|
131
235
|
exp: Math.floor(Date.now() / 1000) + maxAgeSeconds
|
|
132
236
|
})
|
|
133
237
|
);
|
|
@@ -168,6 +272,18 @@ export function verifySessionToken(
|
|
|
168
272
|
return Err('INVALID_TOKEN');
|
|
169
273
|
}
|
|
170
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
|
+
|
|
171
287
|
if (claims.exp * 1000 < Date.now()) {
|
|
172
288
|
return Err('EXPIRED_TOKEN');
|
|
173
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
|
package/src/core/storage.ts
CHANGED
|
@@ -169,6 +169,43 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
169
169
|
return `${this.endpoint}/${cleanKey}`;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Builds the SigV4 `Authorization` header for one request. Shared by every
|
|
174
|
+
* mutating operation — upload, delete — so no method reaches the service
|
|
175
|
+
* unauthenticated because its own signing was forgotten.
|
|
176
|
+
*/
|
|
177
|
+
private buildAuthorization(
|
|
178
|
+
method: string,
|
|
179
|
+
url: URL,
|
|
180
|
+
headers: Record<string, string>,
|
|
181
|
+
payloadHash: string,
|
|
182
|
+
service = 's3'
|
|
183
|
+
): string | null {
|
|
184
|
+
if (!this.accessKeyId || !this.secretAccessKey) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const amzDate = headers['x-amz-date'];
|
|
189
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
190
|
+
const sortedKeys = Object.keys(headers).sort();
|
|
191
|
+
const canonicalHeaders = sortedKeys.map((k) => `${k.toLowerCase()}:${headers[k].trim()}\n`).join('');
|
|
192
|
+
const signedHeaders = sortedKeys.map((k) => k.toLowerCase()).join(';');
|
|
193
|
+
|
|
194
|
+
const canonicalRequest = [method, url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n');
|
|
195
|
+
|
|
196
|
+
const credentialScope = `${dateStamp}/${this.region}/${service}/aws4_request`;
|
|
197
|
+
const hashedCanonical = crypto.createHash('sha256').update(canonicalRequest).digest('hex');
|
|
198
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, hashedCanonical].join('\n');
|
|
199
|
+
|
|
200
|
+
const kDate = crypto.createHmac('sha256', `AWS4${this.secretAccessKey}`).update(dateStamp).digest();
|
|
201
|
+
const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest();
|
|
202
|
+
const kService = crypto.createHmac('sha256', kRegion).update(service).digest();
|
|
203
|
+
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
|
204
|
+
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
205
|
+
|
|
206
|
+
return `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
172
209
|
async upload(
|
|
173
210
|
key: string,
|
|
174
211
|
data: Uint8Array | ArrayBuffer | string,
|
|
@@ -181,7 +218,6 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
181
218
|
|
|
182
219
|
const now = new Date();
|
|
183
220
|
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
184
|
-
const dateStamp = amzDate.slice(0, 8);
|
|
185
221
|
const contentSha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
186
222
|
|
|
187
223
|
const headers: Record<string, string> = {
|
|
@@ -192,24 +228,10 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
192
228
|
host: url.host
|
|
193
229
|
};
|
|
194
230
|
|
|
195
|
-
|
|
196
|
-
const sortedKeys = Object.keys(headers).sort();
|
|
197
|
-
const canonicalHeaders = sortedKeys.map((k) => `${k.toLowerCase()}:${headers[k].trim()}\n`).join('');
|
|
198
|
-
const signedHeaders = sortedKeys.map((k) => k.toLowerCase()).join(';');
|
|
199
|
-
|
|
200
|
-
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, contentSha256].join('\n');
|
|
231
|
+
const authorization = this.buildAuthorization('PUT', url, headers, contentSha256);
|
|
201
232
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, hashedCanonical].join('\n');
|
|
205
|
-
|
|
206
|
-
const kDate = crypto.createHmac('sha256', `AWS4${this.secretAccessKey}`).update(dateStamp).digest();
|
|
207
|
-
const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest();
|
|
208
|
-
const kService = crypto.createHmac('sha256', kRegion).update('s3').digest();
|
|
209
|
-
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
|
210
|
-
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
211
|
-
|
|
212
|
-
headers.Authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
233
|
+
if (authorization) {
|
|
234
|
+
headers.Authorization = authorization;
|
|
213
235
|
}
|
|
214
236
|
|
|
215
237
|
const response = await fetch(targetUrl, {
|
|
@@ -281,7 +303,26 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
281
303
|
try {
|
|
282
304
|
const cleanKey = sanitizeStorageKey(key);
|
|
283
305
|
const targetUrl = this.getUrl(cleanKey);
|
|
284
|
-
const
|
|
306
|
+
const url = new URL(targetUrl);
|
|
307
|
+
|
|
308
|
+
// Signed with the same routine as upload(): an unauthenticated DELETE is
|
|
309
|
+
// either silently rejected by the service or, against a public-write bucket,
|
|
310
|
+
// an anonymous destructive request.
|
|
311
|
+
const headers: Record<string, string> = { host: url.host };
|
|
312
|
+
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
313
|
+
const payloadHash = crypto.createHash('sha256').update('').digest('hex');
|
|
314
|
+
|
|
315
|
+
headers['x-amz-content-sha256'] = payloadHash;
|
|
316
|
+
headers['x-amz-date'] = amzDate;
|
|
317
|
+
|
|
318
|
+
const authorization = this.buildAuthorization('DELETE', url, headers, payloadHash);
|
|
319
|
+
|
|
320
|
+
if (authorization) {
|
|
321
|
+
headers.Authorization = authorization;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const res = await fetch(targetUrl, { method: 'DELETE', headers });
|
|
325
|
+
|
|
285
326
|
return res.status === 200 || res.status === 204;
|
|
286
327
|
} catch {
|
|
287
328
|
return false;
|