@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.
Files changed (55) hide show
  1. package/README.md +98 -6
  2. package/bin/synapse.ts +291 -241
  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 +9 -1
  13. package/src/compiler/contract.ts +31 -4
  14. package/src/compiler/db-schema-generator.ts +39 -8
  15. package/src/compiler/fields-parser.ts +28 -1
  16. package/src/compiler/impact-analyzer.ts +31 -1
  17. package/src/compiler/migration-runner.ts +10 -2
  18. package/src/compiler/optional-coverage.ts +137 -0
  19. package/src/compiler/oracle-runner.ts +18 -4
  20. package/src/compiler/scaffolder.ts +227 -9
  21. package/src/compiler/slice-discovery.ts +8 -1
  22. package/src/compiler/slice-splitter.ts +68 -6
  23. package/src/compiler/slice-templates.ts +234 -10
  24. package/src/compiler/typescript-api.ts +33 -0
  25. package/src/core/action-context.ts +101 -15
  26. package/src/core/config.ts +88 -10
  27. package/src/core/cron-parser.ts +167 -0
  28. package/src/core/database-client.ts +103 -6
  29. package/src/core/index.ts +5 -0
  30. package/src/core/jobs.ts +37 -0
  31. package/src/core/oauth-client.ts +322 -0
  32. package/src/core/path-guard.ts +105 -0
  33. package/src/core/postgres-client.ts +37 -0
  34. package/src/core/postgres-queue.ts +76 -9
  35. package/src/core/query-builder.ts +166 -34
  36. package/src/core/session-context.ts +45 -4
  37. package/src/core/session-token.ts +124 -8
  38. package/src/core/sqlite-client.ts +20 -0
  39. package/src/core/storage.ts +60 -19
  40. package/src/core/topics.ts +84 -0
  41. package/src/core/webhook-signature.ts +151 -0
  42. package/src/index.ts +2 -1
  43. package/src/mcp/server.ts +317 -218
  44. package/src/mcp/tools.ts +329 -0
  45. package/src/project-status.ts +433 -0
  46. package/src/runtime/client-entry.ts +11 -5
  47. package/src/runtime/network-guard.ts +3 -1
  48. package/src/runtime/queue-engine.ts +167 -5
  49. package/src/runtime/rate-limiter.ts +14 -4
  50. package/src/runtime/security-headers.ts +35 -0
  51. package/src/runtime/server.ts +1174 -236
  52. package/src/runtime/uploads.ts +199 -19
  53. package/src/version.ts +9 -2
  54. package/templates/starter/package.json +2 -2
  55. 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 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,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
- const inMemoryRevokedSignatures = new Set<string>();
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.add(signature);
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('INSERT INTO _synapse_session_blacklist (token_signature) VALUES ($1) ON CONFLICT DO NOTHING;', [signature])
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);', [signature])
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.add(signature);
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.add(signature);
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
@@ -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
- if (this.accessKeyId && this.secretAccessKey) {
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
- const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`;
203
- const hashedCanonical = crypto.createHash('sha256').update(canonicalRequest).digest('hex');
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 res = await fetch(targetUrl, { method: 'DELETE' });
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;