@ismaelsoilet/synapsejs 1.4.0 → 1.9.0

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 (53) hide show
  1. package/README.md +108 -10
  2. package/bin/synapse.ts +263 -218
  3. package/package.json +1 -1
  4. package/src/client/components.tsx +26 -9
  5. package/src/client/outlet.tsx +40 -0
  6. package/src/client/sub-slice.tsx +45 -0
  7. package/src/client/use-websocket.ts +146 -0
  8. package/src/client.ts +12 -0
  9. package/src/compiler/client-bundler.ts +17 -7
  10. package/src/compiler/context-bench.ts +7 -1
  11. package/src/compiler/contract.ts +31 -4
  12. package/src/compiler/db-schema-generator.ts +39 -8
  13. package/src/compiler/fields-parser.ts +28 -1
  14. package/src/compiler/impact-analyzer.ts +31 -1
  15. package/src/compiler/migration-runner.ts +144 -11
  16. package/src/compiler/optional-coverage.ts +137 -0
  17. package/src/compiler/oracle-runner.ts +8 -3
  18. package/src/compiler/scaffolder.ts +174 -3
  19. package/src/compiler/schema-dag.ts +6 -3
  20. package/src/compiler/slice-discovery.ts +8 -1
  21. package/src/compiler/slice-splitter.ts +67 -6
  22. package/src/compiler/slice-templates.ts +10 -0
  23. package/src/compiler/standalone-builder.ts +16 -2
  24. package/src/core/action-context.ts +101 -8
  25. package/src/core/cache.ts +139 -0
  26. package/src/core/config.ts +124 -10
  27. package/src/core/database-client.ts +68 -6
  28. package/src/core/index.ts +5 -0
  29. package/src/core/path-guard.ts +105 -0
  30. package/src/core/postgres-client.ts +6 -0
  31. package/src/core/postgres-queue.ts +45 -7
  32. package/src/core/query-builder.ts +75 -60
  33. package/src/core/session-context.ts +1 -1
  34. package/src/core/session-token.ts +115 -8
  35. package/src/core/sqlite-client.ts +26 -1
  36. package/src/core/storage.ts +61 -24
  37. package/src/core/topics.ts +84 -0
  38. package/src/core/webhook-signature.ts +151 -0
  39. package/src/core/websocket.ts +30 -0
  40. package/src/index.ts +10 -2
  41. package/src/mcp/server.ts +78 -173
  42. package/src/mcp/tools.ts +238 -0
  43. package/src/project-status.ts +330 -0
  44. package/src/runtime/discovery-rules.ts +20 -1
  45. package/src/runtime/event-hub.ts +6 -1
  46. package/src/runtime/network-guard.ts +182 -0
  47. package/src/runtime/queue-engine.ts +102 -21
  48. package/src/runtime/rate-limiter.ts +24 -0
  49. package/src/runtime/server.ts +1380 -251
  50. package/src/runtime/uploads.ts +199 -19
  51. package/src/version.ts +11 -0
  52. package/templates/starter/.jev.json +1 -1
  53. package/templates/starter/tsconfig.json +1 -0
@@ -17,27 +17,88 @@ import { Err, Ok, type Result } from './machine-types';
17
17
  export interface SessionClaims {
18
18
  userId: string;
19
19
  roles: string[];
20
+ tenantId?: string;
20
21
  /** Expiry, in seconds since the epoch. */
21
22
  exp: number;
22
23
  }
23
24
 
24
25
  export type SessionTokenError = 'INVALID_TOKEN' | 'EXPIRED_TOKEN' | 'TOKEN_REVOKED';
25
26
 
26
- const inMemoryRevokedSignatures = new Set<string>();
27
+ /** Default bounds for the in-process revocation set. Both are configurable by env. */
28
+ export const DEFAULT_REVOCATION_MAX_ENTRIES = 10_000;
29
+ export const DEFAULT_REVOCATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
30
+
31
+ /** Insertion-ordered signature -> revocation instant. Bounded by cap and TTL. */
32
+ const inMemoryRevokedSignatures = new Map<string, number>();
33
+
34
+ function revocationMaxEntries(): number {
35
+ const raw = Number(process.env.SYNAPSE_REVOCATION_MAX_ENTRIES);
36
+
37
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_REVOCATION_MAX_ENTRIES;
38
+ }
39
+
40
+ function revocationTtlMs(): number {
41
+ const raw = Number(process.env.SYNAPSE_REVOCATION_TTL_MS);
42
+
43
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_REVOCATION_TTL_MS;
44
+ }
45
+
46
+ /**
47
+ * Drops expired entries and enforces the entry cap. Returns how many were pruned.
48
+ * Called on every write and read, so neither store grows without bound.
49
+ */
50
+ export function pruneSessionTokenRevocations(now = Date.now()): number {
51
+ const ttl = revocationTtlMs();
52
+ let pruned = 0;
53
+
54
+ if (ttl > 0) {
55
+ for (const [signature, revokedAt] of inMemoryRevokedSignatures) {
56
+ if (now - revokedAt > ttl) {
57
+ inMemoryRevokedSignatures.delete(signature);
58
+ pruned++;
59
+ }
60
+ }
61
+ }
62
+
63
+ const cap = revocationMaxEntries();
64
+ while (inMemoryRevokedSignatures.size > cap) {
65
+ const oldest = inMemoryRevokedSignatures.keys().next().value;
66
+
67
+ if (oldest === undefined) {
68
+ break;
69
+ }
70
+
71
+ inMemoryRevokedSignatures.delete(oldest);
72
+ pruned++;
73
+ }
74
+
75
+ return pruned;
76
+ }
27
77
 
28
78
  export function revokeSessionToken(token: string): boolean {
29
79
  const [, signature] = token.split('.');
30
80
  if (!signature) return false;
31
- inMemoryRevokedSignatures.add(signature);
81
+ inMemoryRevokedSignatures.set(signature, Date.now());
82
+ pruneSessionTokenRevocations();
32
83
  return true;
33
84
  }
34
85
 
35
86
  export function isSessionTokenRevoked(token: string): boolean {
36
87
  const [, signature] = token.split('.');
37
88
  if (!signature) return true;
89
+
90
+ pruneSessionTokenRevocations();
91
+
38
92
  return inMemoryRevokedSignatures.has(signature);
39
93
  }
40
94
 
95
+ /** Test/tooling helper: how many signatures the in-process set currently holds. */
96
+ export function sessionTokenRevocationCount(): number {
97
+ pruneSessionTokenRevocations();
98
+
99
+ return inMemoryRevokedSignatures.size;
100
+ }
101
+
41
102
  export function clearSessionTokenRevocations(): void {
42
103
  inMemoryRevokedSignatures.clear();
43
104
  }
@@ -52,24 +113,64 @@ export async function revokeSessionTokenInDb(db: DatabaseClient, token: string):
52
113
  await db.query(`
53
114
  CREATE TABLE IF NOT EXISTS _synapse_session_blacklist (
54
115
  token_signature TEXT PRIMARY KEY,
55
- revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
116
+ revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
117
+ revoked_at_ms INTEGER
56
118
  );
57
119
  `);
58
120
 
121
+ try {
122
+ await db.query('ALTER TABLE _synapse_session_blacklist ADD COLUMN revoked_at_ms INTEGER;');
123
+ } catch {
124
+ // Column already present, or the engine refuses the ALTER: either way the
125
+ // table carries the revocation instant it needs for pruning.
126
+ }
127
+
128
+ const revokedAtMs = Date.now();
129
+
59
130
  await db
60
- .query('INSERT INTO _synapse_session_blacklist (token_signature) VALUES ($1) ON CONFLICT DO NOTHING;', [signature])
131
+ .query(
132
+ 'INSERT INTO _synapse_session_blacklist (token_signature, revoked_at_ms) VALUES ($1, $2) ON CONFLICT DO NOTHING;',
133
+ [signature, revokedAtMs]
134
+ )
61
135
  .catch(async () => {
62
136
  // SQLite fallback without ON CONFLICT DO NOTHING if needed
63
137
  await db
64
- .query('INSERT OR IGNORE INTO _synapse_session_blacklist (token_signature) VALUES ($1);', [signature])
138
+ .query('INSERT OR IGNORE INTO _synapse_session_blacklist (token_signature, revoked_at_ms) VALUES ($1, $2);', [
139
+ signature,
140
+ revokedAtMs
141
+ ])
65
142
  .catch(() => {});
66
143
  });
67
144
 
68
145
  // Also sync in-memory for instant local lookup
69
- inMemoryRevokedSignatures.add(signature);
146
+ inMemoryRevokedSignatures.set(signature, revokedAtMs);
147
+ pruneSessionTokenRevocations();
70
148
  return true;
71
149
  }
72
150
 
151
+ /**
152
+ * Deletes persisted revocations older than the retention window. Returns how many
153
+ * rows were removed; SQLite and PostgreSQL both compare the epoch-millisecond
154
+ * column, so pruning does not depend on either dialect's date functions.
155
+ */
156
+ export async function pruneSessionTokenRevocationsInDb(db: DatabaseClient, ttlMs = revocationTtlMs()): Promise<number> {
157
+ if (ttlMs <= 0) {
158
+ return 0;
159
+ }
160
+
161
+ try {
162
+ const cutoff = Date.now() - ttlMs;
163
+ const rows = await db.query<{ token_signature: string }>(
164
+ 'DELETE FROM _synapse_session_blacklist WHERE revoked_at_ms IS NOT NULL AND revoked_at_ms < $1 RETURNING token_signature;',
165
+ [cutoff]
166
+ );
167
+
168
+ return Array.isArray(rows) ? rows.length : 0;
169
+ } catch {
170
+ return 0;
171
+ }
172
+ }
173
+
73
174
  /**
74
175
  * Checks whether a token signature exists in the database blacklist table.
75
176
  */
@@ -84,7 +185,8 @@ export async function isSessionTokenRevokedInDb(db: DatabaseClient, token: strin
84
185
  signature
85
186
  ]);
86
187
  if (rows && rows.length > 0) {
87
- inMemoryRevokedSignatures.add(signature);
188
+ inMemoryRevokedSignatures.set(signature, Date.now());
189
+ pruneSessionTokenRevocations();
88
190
  return true;
89
191
  }
90
192
  } catch {
@@ -118,7 +220,7 @@ function constantTimeEquals(left: string, right: string): boolean {
118
220
  }
119
221
 
120
222
  export function signSessionToken(
121
- claims: { userId: string; roles: string[] },
223
+ claims: { userId: string; roles: string[]; tenantId?: string },
122
224
  secret: string,
123
225
  maxAgeSeconds = 86_400
124
226
  ): string {
@@ -126,6 +228,7 @@ export function signSessionToken(
126
228
  JSON.stringify({
127
229
  userId: claims.userId,
128
230
  roles: claims.roles,
231
+ tenantId: claims.tenantId,
129
232
  exp: Math.floor(Date.now() / 1000) + maxAgeSeconds
130
233
  })
131
234
  );
@@ -162,6 +265,10 @@ export function verifySessionToken(
162
265
  return Err('INVALID_TOKEN');
163
266
  }
164
267
 
268
+ if (claims.tenantId !== undefined && typeof claims.tenantId !== 'string') {
269
+ return Err('INVALID_TOKEN');
270
+ }
271
+
165
272
  if (claims.exp * 1000 < Date.now()) {
166
273
  return Err('EXPIRED_TOKEN');
167
274
  }
@@ -38,6 +38,7 @@ export class SqliteDatabaseClient implements DatabaseClient {
38
38
  private statements: Map<string, Statement<unknown, SQLQueryBindings[]>> = new Map();
39
39
  private maxStatements: number;
40
40
  private txLock: Promise<void> = Promise.resolve();
41
+ private txDepth = 0;
41
42
 
42
43
  constructor(filePath?: string, maxStatements = 500) {
43
44
  this.maxStatements = maxStatements;
@@ -53,6 +54,11 @@ export class SqliteDatabaseClient implements DatabaseClient {
53
54
  }
54
55
 
55
56
  this.db.run('PRAGMA journal_mode = WAL;');
57
+ this.db.run('PRAGMA synchronous = NORMAL;');
58
+ this.db.run('PRAGMA busy_timeout = 5000;');
59
+ this.db.run('PRAGMA temp_store = MEMORY;');
60
+ this.db.run('PRAGMA cache_size = -64000;');
61
+ this.db.run('PRAGMA mmap_size = 268435456;');
56
62
  this.db.run('PRAGMA foreign_keys = ON;');
57
63
  }
58
64
 
@@ -161,6 +167,22 @@ export class SqliteDatabaseClient implements DatabaseClient {
161
167
  }
162
168
 
163
169
  async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
170
+ if (this.txDepth > 0) {
171
+ // Reentrant transaction: use SQLite SAVEPOINT
172
+ const spId = `sp_${++this.txDepth}`;
173
+ this.db.run(`SAVEPOINT ${spId};`);
174
+ try {
175
+ const result = await operation(this);
176
+ this.db.run(`RELEASE SAVEPOINT ${spId};`);
177
+ return result;
178
+ } catch (err) {
179
+ this.db.run(`ROLLBACK TO SAVEPOINT ${spId};`);
180
+ throw err;
181
+ } finally {
182
+ this.txDepth--;
183
+ }
184
+ }
185
+
164
186
  let releaseLock: () => void = () => {};
165
187
  const lockAcquired = new Promise<void>((resolve) => {
166
188
  releaseLock = resolve;
@@ -172,7 +194,8 @@ export class SqliteDatabaseClient implements DatabaseClient {
172
194
  await previousLock;
173
195
 
174
196
  try {
175
- this.db.run('BEGIN TRANSACTION;');
197
+ this.txDepth = 1;
198
+ this.db.run('BEGIN IMMEDIATE TRANSACTION;');
176
199
  try {
177
200
  const result = await operation(this);
178
201
  this.db.run('COMMIT;');
@@ -180,6 +203,8 @@ export class SqliteDatabaseClient implements DatabaseClient {
180
203
  } catch (err) {
181
204
  this.db.run('ROLLBACK;');
182
205
  throw err;
206
+ } finally {
207
+ this.txDepth = 0;
183
208
  }
184
209
  } finally {
185
210
  releaseLock();
@@ -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');
201
-
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');
231
+ const authorization = this.buildAuthorization('PUT', url, headers, contentSha256);
205
232
 
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, {
@@ -219,11 +241,7 @@ export class S3StorageAdapter implements StorageClient {
219
241
  });
220
242
 
221
243
  if (!response.ok && response.status !== 200 && response.status !== 204) {
222
- if (response.status === 403 || response.status === 404) {
223
- // Fallback for mock/test environments
224
- } else {
225
- throw new Error(`Storage upload failed with HTTP status ${response.status}`);
226
- }
244
+ throw new Error(`Storage upload failed with HTTP status ${response.status}`);
227
245
  }
228
246
 
229
247
  return {
@@ -285,7 +303,26 @@ export class S3StorageAdapter implements StorageClient {
285
303
  try {
286
304
  const cleanKey = sanitizeStorageKey(key);
287
305
  const targetUrl = this.getUrl(cleanKey);
288
- 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
+
289
326
  return res.status === 200 || res.status === 204;
290
327
  } catch {
291
328
  return false;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * SynapseJS - Declared Realtime Topics
3
+ *
4
+ * A topic is a name a slice deliberately declares together with who may read it and
5
+ * which feature may publish into it. Inferring authorization from a naming convention
6
+ * is not a security control, so the event-stream gateway refuses any topic that was
7
+ * never declared — a caller cannot subscribe to a name merely because it can spell it.
8
+ *
9
+ * Topics are namespaced by tenant: two tenants that use the same name are two
10
+ * independent topics that never observe each other's broadcasts.
11
+ */
12
+
13
+ export interface TopicDeclaration {
14
+ /** The topic name as it appears in the URL. */
15
+ name: string;
16
+ /** The slice key (`<domain>/<name>`) permitted to publish into this topic. */
17
+ owner: string;
18
+ /** When set, the declaration exists only inside that tenant. */
19
+ tenantId?: string;
20
+ /** Roles permitted to subscribe. Empty means "any authenticated session". */
21
+ readRoles?: string[];
22
+ /** A deliberately public topic: reachable without a session. */
23
+ public?: boolean;
24
+ }
25
+
26
+ /**
27
+ * The complete set of realtime rejection codes and the status each is returned with.
28
+ * Both transports draw from this table and the machine contract enumerates it, so a
29
+ * code cannot be invented at a refusal site.
30
+ */
31
+ export const REALTIME_REJECTIONS = {
32
+ UNAUTHENTICATED: 401,
33
+ TOPIC_NOT_FOUND: 404,
34
+ TOPIC_FORBIDDEN: 403,
35
+ RATE_LIMIT_EXCEEDED: 429,
36
+ REALTIME_LIMIT_EXCEEDED: 429,
37
+ ORIGIN_NOT_ALLOWED: 403
38
+ } as const;
39
+
40
+ export type RealtimeRejectionCode = keyof typeof REALTIME_REJECTIONS;
41
+
42
+ const registry = new Map<string, TopicDeclaration>();
43
+
44
+ function registryKey(name: string, tenantId?: string): string {
45
+ return `${tenantId ?? '*'}:${name}`;
46
+ }
47
+
48
+ /**
49
+ * Declares a topic. Call it at module scope in the slice that owns the topic.
50
+ */
51
+ export function defineTopic(declaration: TopicDeclaration): TopicDeclaration {
52
+ registry.set(registryKey(declaration.name, declaration.tenantId), declaration);
53
+
54
+ return declaration;
55
+ }
56
+
57
+ /**
58
+ * The declaration that governs `name` for a session in `tenantId`: an exact
59
+ * tenant-scoped declaration wins over a global one. A name declared only for another
60
+ * tenant resolves to nothing here, so the refusal cannot disclose that it exists.
61
+ */
62
+ export function declaredTopic(name: string, tenantId?: string): TopicDeclaration | undefined {
63
+ const scoped = registry.get(registryKey(name, tenantId));
64
+
65
+ if (scoped) {
66
+ return scoped;
67
+ }
68
+
69
+ return registry.get(registryKey(name));
70
+ }
71
+
72
+ export function declaredTopics(): TopicDeclaration[] {
73
+ return Array.from(registry.values());
74
+ }
75
+
76
+ /** The effective, tenant-scoped hub key for a topic. */
77
+ export function scopedTopic(name: string, tenantId?: string): string {
78
+ return `${tenantId || 'global'}:${name}`;
79
+ }
80
+
81
+ /** Test helper: drops every declaration. */
82
+ export function resetDeclaredTopics(): void {
83
+ registry.clear();
84
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * SynapseJS - Webhook Signature Verification
3
+ *
4
+ * A webhook endpoint is a public write surface, so the framework ships the two
5
+ * primitives a receiver needs to treat it as one: a constant-time comparison of the
6
+ * provider's signature over the raw body, and a bounded replay window so a captured
7
+ * delivery cannot be replayed. Verifying a signature is not enough on its own — a
8
+ * valid signature stays valid forever unless the receiver also tracks what it has
9
+ * already accepted.
10
+ */
11
+
12
+ import * as crypto from 'crypto';
13
+ import { Err, Ok, type Result } from './machine-types';
14
+
15
+ export type WebhookSignatureError = 'INVALID_SIGNATURE' | 'TIMESTAMP_OUT_OF_WINDOW' | 'REPLAYED_DELIVERY';
16
+
17
+ /** Compares two strings without leaking their difference through timing. */
18
+ export function constantTimeEquals(left: string, right: string): boolean {
19
+ const a = Buffer.from(left, 'utf-8');
20
+ const b = Buffer.from(right, 'utf-8');
21
+
22
+ if (a.length !== b.length) {
23
+ // Still perform a comparison of equal length so the failure is not timing-visible.
24
+ const padded = Buffer.alloc(a.length);
25
+ crypto.timingSafeEqual(a, padded);
26
+
27
+ return false;
28
+ }
29
+
30
+ return crypto.timingSafeEqual(a, b);
31
+ }
32
+
33
+ /** The canonical signed value: `<timestamp>.<raw body>`. */
34
+ export function signedPayload(timestampSeconds: number, payload: Uint8Array): Buffer {
35
+ return Buffer.concat([Buffer.from(`${timestampSeconds}.`, 'utf-8'), Buffer.from(payload)]);
36
+ }
37
+
38
+ /** HMAC-SHA256 of `<timestamp>.<body>`, hex encoded — the shape providers send. */
39
+ export function signWebhookPayload(secret: string, timestampSeconds: number, payload: Uint8Array): string {
40
+ return crypto.createHmac('sha256', secret).update(signedPayload(timestampSeconds, payload)).digest('hex');
41
+ }
42
+
43
+ export interface VerifyWebhookOptions {
44
+ /** The raw request body, byte for byte — a re-serialized JSON body will not verify. */
45
+ payload: Uint8Array;
46
+ /** The signature the provider sent (hex, `sha256=...` accepted). */
47
+ signature: string;
48
+ secret: string;
49
+ /** The provider's delivery timestamp, in seconds. */
50
+ timestamp: number;
51
+ /** How far from now a delivery may be, in seconds (default: 300). */
52
+ toleranceSeconds?: number;
53
+ /** Injected for tests. */
54
+ nowSeconds?: number;
55
+ }
56
+
57
+ /**
58
+ * Verifies the signature and the delivery window. A signature that matches but whose
59
+ * timestamp is outside the tolerance is refused: otherwise a captured delivery stays
60
+ * valid forever.
61
+ */
62
+ export function verifyWebhookSignature(options: VerifyWebhookOptions): Result<true, WebhookSignatureError> {
63
+ const tolerance = options.toleranceSeconds ?? 300;
64
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
65
+ const provided = options.signature.replace(/^sha256=/i, '').trim();
66
+
67
+ if (!Number.isFinite(options.timestamp) || Math.abs(now - options.timestamp) > tolerance) {
68
+ return Err('TIMESTAMP_OUT_OF_WINDOW');
69
+ }
70
+
71
+ const expected = signWebhookPayload(options.secret, options.timestamp, options.payload);
72
+
73
+ return constantTimeEquals(expected, provided) ? Ok(true) : Err('INVALID_SIGNATURE');
74
+ }
75
+
76
+ export interface ReplayGuardOptions {
77
+ /** How long a signature stays in the window, in seconds (default: 300). */
78
+ ttlSeconds?: number;
79
+ /** Maximum tracked signatures (default: 10,000). */
80
+ maxEntries?: number;
81
+ }
82
+
83
+ /**
84
+ * Remembers which signatures have already been accepted, bounded by a time-to-live
85
+ * and an entry cap so a flood of deliveries cannot grow the store without bound.
86
+ */
87
+ export class WebhookReplayGuard {
88
+ private readonly ttlSeconds: number;
89
+ private readonly maxEntries: number;
90
+ private accepted = new Map<string, number>();
91
+
92
+ constructor(options: ReplayGuardOptions = {}) {
93
+ this.ttlSeconds = options.ttlSeconds ?? 300;
94
+ this.maxEntries = options.maxEntries ?? 10_000;
95
+ }
96
+
97
+ /** True when this signature has not been accepted inside the window. */
98
+ isFresh(signature: string, nowSeconds?: number): boolean {
99
+ const now = nowSeconds ?? Math.floor(Date.now() / 1000);
100
+ this.prune(now);
101
+
102
+ return !this.accepted.has(signature);
103
+ }
104
+
105
+ /** Records a signature as accepted. Returns false when it was already inside the window. */
106
+ accept(signature: string, nowSeconds?: number): boolean {
107
+ const now = nowSeconds ?? Math.floor(Date.now() / 1000);
108
+ this.prune(now);
109
+
110
+ if (this.accepted.has(signature)) {
111
+ return false;
112
+ }
113
+
114
+ while (this.accepted.size >= this.maxEntries) {
115
+ const oldest = this.accepted.keys().next().value;
116
+
117
+ if (oldest === undefined) {
118
+ break;
119
+ }
120
+
121
+ this.accepted.delete(oldest);
122
+ }
123
+
124
+ this.accepted.set(signature, now);
125
+
126
+ return true;
127
+ }
128
+
129
+ /** Drops entries older than the window. Returns how many were removed. */
130
+ prune(nowSeconds?: number): number {
131
+ const now = nowSeconds ?? Math.floor(Date.now() / 1000);
132
+ let pruned = 0;
133
+
134
+ for (const [signature, acceptedAt] of this.accepted) {
135
+ if (now - acceptedAt > this.ttlSeconds) {
136
+ this.accepted.delete(signature);
137
+ pruned++;
138
+ }
139
+ }
140
+
141
+ return pruned;
142
+ }
143
+
144
+ get size(): number {
145
+ return this.accepted.size;
146
+ }
147
+
148
+ clear(): void {
149
+ this.accepted.clear();
150
+ }
151
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SynapseJS - Native Bidirectional WebSocket Abstraction
3
+ *
4
+ * Provides a type-safe contract for real-time duplex communication
5
+ * seamlessly integrated with native Bun.serve WebSockets.
6
+ */
7
+
8
+ export interface SynapseWebSocket<TServerMsg = unknown> {
9
+ readonly id: string;
10
+ readonly sliceKey: string;
11
+ send(message: TServerMsg | string): void;
12
+ close(code?: number, reason?: string): void;
13
+ data: Record<string, unknown>;
14
+ }
15
+
16
+ export interface SliceSocketDefinition<TClientMsg = unknown, TServerMsg = unknown> {
17
+ onOpen?: (ws: SynapseWebSocket<TServerMsg>) => void | Promise<void>;
18
+ onMessage: (ws: SynapseWebSocket<TServerMsg>, message: TClientMsg) => void | Promise<void>;
19
+ onClose?: (ws: SynapseWebSocket<TServerMsg>, code: number, reason: string) => void | Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * Declares a bidirectional WebSocket endpoint for a slice.
24
+ * Automatically discovered and mounted at `GET /_synapse/ws/:domain/:name`.
25
+ */
26
+ export function defineSocket<TClientMsg = unknown, TServerMsg = unknown>(
27
+ def: SliceSocketDefinition<TClientMsg, TServerMsg>
28
+ ): SliceSocketDefinition<TClientMsg, TServerMsg> {
29
+ return def;
30
+ }
package/src/index.ts CHANGED
@@ -65,7 +65,13 @@ export {
65
65
  parseOracleJunit,
66
66
  runSliceOracles
67
67
  } from './compiler/oracle-runner';
68
- export { type ScaffoldError, type ScaffoldErrorCode, scaffoldCrud, scaffoldSlice } from './compiler/scaffolder';
68
+ export {
69
+ type ScaffoldError,
70
+ type ScaffoldErrorCode,
71
+ scaffoldCrud,
72
+ scaffoldShared,
73
+ scaffoldSlice
74
+ } from './compiler/scaffolder';
69
75
  export { orderSlicesByDag, parseTableDependencies } from './compiler/schema-dag';
70
76
  export {
71
77
  checkSchemaDrift,
@@ -128,6 +134,8 @@ export {
128
134
  type SliceLoadError,
129
135
  type SliceLoaderContext,
130
136
  type SliceMetadata,
131
- SynapseServer
137
+ SynapseServer,
138
+ type WebhookEvent
132
139
  } from './runtime/server';
140
+ export { SYNAPSE_VERSION } from './version';
133
141
  export { fc };