@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.
- package/README.md +108 -10
- package/bin/synapse.ts +263 -218
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/context-bench.ts +7 -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 +144 -11
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +174 -3
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +67 -6
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +101 -8
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +124 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +5 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +45 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +115 -8
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +61 -24
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +10 -2
- package/src/mcp/server.ts +78 -173
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +182 -0
- package/src/runtime/queue-engine.ts +102 -21
- package/src/runtime/rate-limiter.ts +24 -0
- package/src/runtime/server.ts +1380 -251
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +11 -0
- package/templates/starter/.jev.json +1 -1
- 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
|
-
|
|
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.
|
|
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(
|
|
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);', [
|
|
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.
|
|
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.
|
|
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.
|
|
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();
|
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');
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 };
|