@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
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED