@wolpertingerlabs/drawlatch 1.0.0-alpha.2 → 1.0.0-alpha.29
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/CONNECTIONS.md +3 -0
- package/README.md +395 -465
- package/bin/drawlatch.js +1018 -49
- package/dist/auth/auth.d.ts +10 -0
- package/dist/auth/auth.js +146 -0
- package/dist/auth/env-writer.d.ts +6 -0
- package/dist/auth/env-writer.js +52 -0
- package/dist/auth/password.d.ts +8 -0
- package/dist/auth/password.js +28 -0
- package/dist/auth/sessions.d.ts +13 -0
- package/dist/auth/sessions.js +85 -0
- package/dist/cli/generate-keys.d.ts +4 -4
- package/dist/cli/generate-keys.js +30 -25
- package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
- package/dist/connections/{devin.json → ai/devin.json} +7 -1
- package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
- package/dist/connections/{openai.json → ai/openai.json} +7 -1
- package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
- package/dist/connections/developer-tools/github.json +138 -0
- package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
- package/dist/connections/developer-tools/linear.json +75 -0
- package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
- package/dist/connections/messaging/agentmail.json +21 -0
- package/dist/connections/messaging/discord-bot.json +114 -0
- package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
- package/dist/connections/messaging/slack.json +75 -0
- package/dist/connections/messaging/telegram.json +66 -0
- package/dist/connections/{google.json → productivity/google.json} +7 -1
- package/dist/connections/productivity/notion.json +75 -0
- package/dist/connections/productivity/stripe.json +74 -0
- package/dist/connections/productivity/trello.json +113 -0
- package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
- package/dist/connections/social-media/mastodon.json +65 -0
- package/dist/connections/social-media/reddit.json +80 -0
- package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
- package/dist/connections/social-media/x.json +67 -0
- package/dist/mcp/server.js +544 -31
- package/dist/remote/admin-mutations.d.ts +37 -0
- package/dist/remote/admin-mutations.js +251 -0
- package/dist/remote/admin-types.d.ts +149 -0
- package/dist/remote/admin-types.js +11 -0
- package/dist/remote/admin.d.ts +37 -0
- package/dist/remote/admin.js +316 -0
- package/dist/remote/caller-bootstrap.d.ts +71 -0
- package/dist/remote/caller-bootstrap.js +141 -0
- package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
- package/dist/remote/ingestors/base-ingestor.js +27 -6
- package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
- package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
- package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
- package/dist/remote/ingestors/e2e/setup.js +147 -0
- package/dist/remote/ingestors/manager.d.ts +110 -10
- package/dist/remote/ingestors/manager.js +449 -42
- package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
- package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
- package/dist/remote/ingestors/registry.d.ts +2 -2
- package/dist/remote/ingestors/registry.js +2 -2
- package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
- package/dist/remote/ingestors/slack/socket-mode.js +28 -6
- package/dist/remote/ingestors/types.d.ts +25 -0
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
- package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
- package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
- package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
- package/dist/remote/server.d.ts +25 -33
- package/dist/remote/server.js +681 -195
- package/dist/remote/tool-dispatch.d.ts +84 -0
- package/dist/remote/tool-dispatch.js +910 -0
- package/dist/remote/triggers/rule-engine.d.ts +40 -0
- package/dist/remote/triggers/rule-engine.js +228 -0
- package/dist/remote/triggers/types.d.ts +69 -0
- package/dist/remote/triggers/types.js +10 -0
- package/dist/remote/tunnel-state.d.ts +14 -0
- package/dist/remote/tunnel-state.js +20 -0
- package/dist/remote/tunnel.d.ts +53 -0
- package/dist/remote/tunnel.js +149 -0
- package/dist/shared/config.d.ts +97 -25
- package/dist/shared/config.js +48 -35
- package/dist/shared/connections.d.ts +21 -5
- package/dist/shared/connections.js +56 -14
- package/dist/shared/crypto/index.d.ts +1 -0
- package/dist/shared/crypto/index.js +1 -0
- package/dist/shared/crypto/key-manager.d.ts +67 -0
- package/dist/shared/crypto/key-manager.js +152 -0
- package/dist/shared/env-utils.d.ts +42 -0
- package/dist/shared/env-utils.js +150 -0
- package/dist/shared/listener-config.d.ts +157 -0
- package/dist/shared/listener-config.js +10 -0
- package/dist/shared/migrations.d.ts +40 -0
- package/dist/shared/migrations.js +122 -0
- package/dist/shared/protocol/handshake.js +14 -1
- package/dist/shared/protocol/index.d.ts +2 -0
- package/dist/shared/protocol/index.js +2 -0
- package/dist/shared/protocol/sync-client.d.ts +52 -0
- package/dist/shared/protocol/sync-client.js +99 -0
- package/dist/shared/protocol/sync.d.ts +71 -0
- package/dist/shared/protocol/sync.js +176 -0
- package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
- package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
- package/frontend/dist/index.html +15 -0
- package/package.json +66 -13
- package/dist/connections/discord-bot.json +0 -24
- package/dist/connections/github.json +0 -25
- package/dist/connections/linear.json +0 -29
- package/dist/connections/mastodon.json +0 -25
- package/dist/connections/notion.json +0 -33
- package/dist/connections/reddit.json +0 -28
- package/dist/connections/slack.json +0 -23
- package/dist/connections/stripe.json +0 -25
- package/dist/connections/telegram.json +0 -26
- package/dist/connections/trello.json +0 -25
- package/dist/connections/x.json +0 -27
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { BaseIngestor } from './base-ingestor.js';
|
|
10
10
|
import type { IngestorConfig } from './types.js';
|
|
11
11
|
/** Signature for a factory that creates an ingestor from its config. */
|
|
12
|
-
export type IngestorFactory = (connectionAlias: string, config: IngestorConfig, secrets: Record<string, string>, bufferSize?: number) => BaseIngestor | null;
|
|
12
|
+
export type IngestorFactory = (connectionAlias: string, config: IngestorConfig, secrets: Record<string, string>, bufferSize?: number, instanceId?: string) => BaseIngestor | null;
|
|
13
13
|
/**
|
|
14
14
|
* Register a factory for a given ingestor key.
|
|
15
15
|
*
|
|
@@ -28,5 +28,5 @@ export declare function registerIngestorFactory(key: string, factory: IngestorFa
|
|
|
28
28
|
*
|
|
29
29
|
* Returns `null` if no factory is registered or if the factory declines to create.
|
|
30
30
|
*/
|
|
31
|
-
export declare function createIngestor(connectionAlias: string, config: IngestorConfig, secrets: Record<string, string>, bufferSize?: number): BaseIngestor | null;
|
|
31
|
+
export declare function createIngestor(connectionAlias: string, config: IngestorConfig, secrets: Record<string, string>, bufferSize?: number, instanceId?: string): BaseIngestor | null;
|
|
32
32
|
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -30,7 +30,7 @@ export function registerIngestorFactory(key, factory) {
|
|
|
30
30
|
*
|
|
31
31
|
* Returns `null` if no factory is registered or if the factory declines to create.
|
|
32
32
|
*/
|
|
33
|
-
export function createIngestor(connectionAlias, config, secrets, bufferSize) {
|
|
33
|
+
export function createIngestor(connectionAlias, config, secrets, bufferSize, instanceId) {
|
|
34
34
|
const key = config.type === 'websocket'
|
|
35
35
|
? `websocket:${config.websocket?.protocol ?? 'generic'}`
|
|
36
36
|
: config.type === 'webhook'
|
|
@@ -41,6 +41,6 @@ export function createIngestor(connectionAlias, config, secrets, bufferSize) {
|
|
|
41
41
|
log.error(`No factory registered for "${key}" (${connectionAlias})`);
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
-
return factory(connectionAlias, config, secrets, bufferSize);
|
|
44
|
+
return factory(connectionAlias, config, secrets, bufferSize, instanceId);
|
|
45
45
|
}
|
|
46
46
|
//# sourceMappingURL=registry.js.map
|
|
@@ -27,9 +27,9 @@ export declare class SlackSocketModeIngestor extends BaseIngestor {
|
|
|
27
27
|
private readonly eventFilter;
|
|
28
28
|
private readonly channelIds;
|
|
29
29
|
private readonly userIds;
|
|
30
|
-
constructor(connectionAlias: string, secrets: Record<string, string>, wsConfig: WebSocketIngestorConfig, bufferSize?: number);
|
|
30
|
+
constructor(connectionAlias: string, secrets: Record<string, string>, wsConfig: WebSocketIngestorConfig, bufferSize?: number, instanceId?: string);
|
|
31
31
|
start(): Promise<void>;
|
|
32
|
-
stop(): Promise<void>;
|
|
32
|
+
stop(_permanent?: boolean): Promise<void>;
|
|
33
33
|
/**
|
|
34
34
|
* Call apps.connections.open to get a dynamic WebSocket URL, then connect.
|
|
35
35
|
*/
|
|
@@ -31,8 +31,8 @@ export class SlackSocketModeIngestor extends BaseIngestor {
|
|
|
31
31
|
eventFilter;
|
|
32
32
|
channelIds;
|
|
33
33
|
userIds;
|
|
34
|
-
constructor(connectionAlias, secrets, wsConfig, bufferSize) {
|
|
35
|
-
super(connectionAlias, 'websocket', secrets, bufferSize);
|
|
34
|
+
constructor(connectionAlias, secrets, wsConfig, bufferSize, instanceId) {
|
|
35
|
+
super(connectionAlias, 'websocket', secrets, bufferSize, instanceId);
|
|
36
36
|
this.wsConfig = wsConfig;
|
|
37
37
|
this.connectUrl = wsConfig.gatewayUrl;
|
|
38
38
|
this.eventFilter = wsConfig.eventFilter ?? [];
|
|
@@ -43,12 +43,34 @@ export class SlackSocketModeIngestor extends BaseIngestor {
|
|
|
43
43
|
this.state = 'starting';
|
|
44
44
|
await this.openConnection();
|
|
45
45
|
}
|
|
46
|
-
stop() {
|
|
46
|
+
stop(_permanent) {
|
|
47
47
|
this.state = 'stopped';
|
|
48
48
|
this.clearReconnectTimer();
|
|
49
49
|
if (this.ws) {
|
|
50
|
-
this.ws
|
|
50
|
+
const ws = this.ws;
|
|
51
51
|
this.ws = null;
|
|
52
|
+
// Wait for the WebSocket to fully close before resolving.
|
|
53
|
+
// This prevents a race where the old connection is still alive when a new
|
|
54
|
+
// ingestor starts, causing Slack to distribute events to the dying
|
|
55
|
+
// connection (where nobody processes them).
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const onClose = () => {
|
|
58
|
+
ws.removeEventListener('close', onClose);
|
|
59
|
+
resolve();
|
|
60
|
+
};
|
|
61
|
+
if (ws.readyState === WebSocket.CLOSED) {
|
|
62
|
+
resolve();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
ws.addEventListener('close', onClose);
|
|
66
|
+
ws.close(1000, 'Shutting down');
|
|
67
|
+
// Safety timeout — don't block forever if close event never fires
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
ws.removeEventListener('close', onClose);
|
|
70
|
+
resolve();
|
|
71
|
+
}, 5000);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
52
74
|
}
|
|
53
75
|
return Promise.resolve();
|
|
54
76
|
}
|
|
@@ -257,11 +279,11 @@ export class SlackSocketModeIngestor extends BaseIngestor {
|
|
|
257
279
|
}
|
|
258
280
|
}
|
|
259
281
|
// ── Self-registration ────────────────────────────────────────────────────
|
|
260
|
-
registerIngestorFactory('websocket:slack', (connectionAlias, config, secrets, bufferSize) => {
|
|
282
|
+
registerIngestorFactory('websocket:slack', (connectionAlias, config, secrets, bufferSize, instanceId) => {
|
|
261
283
|
if (!config.websocket) {
|
|
262
284
|
log.error(`Missing websocket config for ${connectionAlias}`);
|
|
263
285
|
return null;
|
|
264
286
|
}
|
|
265
|
-
return new SlackSocketModeIngestor(connectionAlias, secrets, config.websocket, bufferSize);
|
|
287
|
+
return new SlackSocketModeIngestor(connectionAlias, secrets, config.websocket, bufferSize, instanceId);
|
|
266
288
|
});
|
|
267
289
|
//# sourceMappingURL=socket-mode.js.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* pulling real-time events from external services (Discord Gateway, webhooks,
|
|
6
6
|
* polling) and buffering them for the MCP proxy to retrieve via `poll_events`.
|
|
7
7
|
*/
|
|
8
|
+
import type { WebhookLifecycleConfig } from './webhook/lifecycle-types.js';
|
|
8
9
|
/** Top-level ingestor configuration attached to a connection template. */
|
|
9
10
|
export interface IngestorConfig {
|
|
10
11
|
/** Type of ingestor — determines which runtime class is used. */
|
|
@@ -56,6 +57,10 @@ export interface WebhookIngestorConfig {
|
|
|
56
57
|
* Required for services like Trello that include the callback URL in their
|
|
57
58
|
* signature computation. May contain ${VAR} placeholders resolved from secrets. */
|
|
58
59
|
callbackUrl?: string;
|
|
60
|
+
/** Declarative webhook lifecycle configuration for auto-registration and cleanup.
|
|
61
|
+
* When present, the ingestor will automatically list, register, and unregister
|
|
62
|
+
* webhooks with the external service at the appropriate lifecycle moments. */
|
|
63
|
+
lifecycle?: WebhookLifecycleConfig;
|
|
59
64
|
}
|
|
60
65
|
/** Configuration for polling-based ingestors (e.g., Notion search). */
|
|
61
66
|
export interface PollIngestorConfig {
|
|
@@ -81,6 +86,12 @@ export interface PollIngestorConfig {
|
|
|
81
86
|
* Values may contain ${VAR} placeholders.
|
|
82
87
|
* These are merged UNDER the connection's route headers (route headers take precedence). */
|
|
83
88
|
headers?: Record<string, string>;
|
|
89
|
+
/** Enable ETag-based conditional requests.
|
|
90
|
+
* When true, the poller stores the ETag from each successful response and sends
|
|
91
|
+
* `If-None-Match` on subsequent requests. HTTP 304 responses are treated as a
|
|
92
|
+
* successful no-op (no items, no error). Useful for APIs like GitHub Events
|
|
93
|
+
* where 304s do not count against rate limits. */
|
|
94
|
+
etag?: boolean;
|
|
84
95
|
}
|
|
85
96
|
/** A single event received by an ingestor, stored in the ring buffer. */
|
|
86
97
|
export interface IngestedEvent {
|
|
@@ -105,8 +116,13 @@ export interface IngestedEvent {
|
|
|
105
116
|
receivedAt: string;
|
|
106
117
|
/** Unix timestamp (milliseconds) when the event was received by the ingestor. */
|
|
107
118
|
receivedAtMs: number;
|
|
119
|
+
/** Caller alias that owns this event (e.g., 'default', 'alice'). */
|
|
120
|
+
callerAlias: string;
|
|
108
121
|
/** Source connection alias (e.g., 'discord-bot', 'github'). */
|
|
109
122
|
source: string;
|
|
123
|
+
/** Instance identifier for multi-instance listeners (e.g., "project-board").
|
|
124
|
+
* Omitted for single-instance connections (the default). */
|
|
125
|
+
instanceId?: string;
|
|
110
126
|
/** Event type/name (e.g., 'MESSAGE_CREATE', 'push'). */
|
|
111
127
|
eventType: string;
|
|
112
128
|
/** The raw event payload from the external service. */
|
|
@@ -118,6 +134,9 @@ export type IngestorState = 'starting' | 'connected' | 'reconnecting' | 'stopped
|
|
|
118
134
|
export interface IngestorStatus {
|
|
119
135
|
/** Connection alias this ingestor belongs to. */
|
|
120
136
|
connection: string;
|
|
137
|
+
/** Instance identifier for multi-instance listeners.
|
|
138
|
+
* Omitted for single-instance connections (the default). */
|
|
139
|
+
instanceId?: string;
|
|
121
140
|
/** Ingestor type. */
|
|
122
141
|
type: 'websocket' | 'webhook' | 'poll';
|
|
123
142
|
/** Current lifecycle state. */
|
|
@@ -130,6 +149,12 @@ export interface IngestorStatus {
|
|
|
130
149
|
lastEventAt: string | null;
|
|
131
150
|
/** Error message when state is 'error'. */
|
|
132
151
|
error?: string;
|
|
152
|
+
/** Webhook registration status (only present for webhook ingestors with lifecycle config). */
|
|
153
|
+
webhookRegistration?: {
|
|
154
|
+
registered: boolean;
|
|
155
|
+
webhookId?: string;
|
|
156
|
+
error?: string;
|
|
157
|
+
};
|
|
133
158
|
}
|
|
134
159
|
/** Default ring buffer capacity per ingestor. */
|
|
135
160
|
export declare const DEFAULT_BUFFER_SIZE = 200;
|
|
@@ -12,11 +12,17 @@
|
|
|
12
12
|
* extraction by overriding `verifySignature()`, `extractEventType()`, and
|
|
13
13
|
* `extractEventData()`.
|
|
14
14
|
*
|
|
15
|
+
* When a `lifecycle` config is present on the webhook config, this class
|
|
16
|
+
* automatically manages webhook registration with the external service:
|
|
17
|
+
* - On `start()`: lists existing webhooks, cleans up stale ones, registers new
|
|
18
|
+
* - On `stop(permanent=true)`: unregisters the webhook (deletion/shutdown only)
|
|
19
|
+
*
|
|
15
20
|
* @see GitHubWebhookIngestor
|
|
16
21
|
* @see StripeWebhookIngestor
|
|
17
22
|
*/
|
|
18
23
|
import { BaseIngestor } from '../base-ingestor.js';
|
|
19
|
-
import type { WebhookIngestorConfig } from '../types.js';
|
|
24
|
+
import type { WebhookIngestorConfig, IngestorStatus } from '../types.js';
|
|
25
|
+
import type { WebhookRegistrationState } from './lifecycle-types.js';
|
|
20
26
|
export declare abstract class WebhookIngestor extends BaseIngestor {
|
|
21
27
|
/** The path segment this ingestor listens on (e.g., 'github' → /webhooks/github). */
|
|
22
28
|
readonly webhookPath: string;
|
|
@@ -26,19 +32,42 @@ export declare abstract class WebhookIngestor extends BaseIngestor {
|
|
|
26
32
|
protected readonly signatureHeader: string | undefined;
|
|
27
33
|
/** Event type filter (empty = capture all). */
|
|
28
34
|
protected readonly eventFilter: string[];
|
|
29
|
-
|
|
35
|
+
/** Resolved callback URL (with ${VAR} placeholders replaced). */
|
|
36
|
+
protected readonly resolvedCallbackUrl: string | undefined;
|
|
37
|
+
/** Lifecycle manager for auto-registration (null if no lifecycle config). */
|
|
38
|
+
private readonly lifecycleManager;
|
|
39
|
+
/** Runtime state of the webhook registration. */
|
|
40
|
+
protected registrationState: WebhookRegistrationState | null;
|
|
41
|
+
constructor(connectionAlias: string, secrets: Record<string, string>, webhookConfig: WebhookIngestorConfig, bufferSize?: number, instanceId?: string);
|
|
30
42
|
/**
|
|
31
43
|
* Start the webhook ingestor.
|
|
32
44
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
45
|
+
* If a lifecycle config is present, attempts to auto-register the webhook
|
|
46
|
+
* with the external service before setting state to 'connected'.
|
|
47
|
+
* Registration failures are logged but never prevent the ingestor from starting
|
|
48
|
+
* (graceful degradation).
|
|
36
49
|
*/
|
|
37
50
|
start(): Promise<void>;
|
|
38
51
|
/**
|
|
39
|
-
* Stop the webhook ingestor.
|
|
52
|
+
* Stop the webhook ingestor.
|
|
53
|
+
*
|
|
54
|
+
* When `permanent` is true (server shutdown or instance deletion), unregisters
|
|
55
|
+
* the webhook from the external service if one was registered.
|
|
56
|
+
* Regular stops (pause/restart) leave the webhook intact.
|
|
57
|
+
*/
|
|
58
|
+
stop(permanent?: boolean): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Return the model/resource ID for multi-instance webhook registration.
|
|
61
|
+
*
|
|
62
|
+
* Override in subclasses that support multi-instance webhooks (e.g., Trello
|
|
63
|
+
* board ID, GitHub repo name). Used by the lifecycle manager to match
|
|
64
|
+
* existing webhooks and clean up stale registrations.
|
|
65
|
+
*
|
|
66
|
+
* Default: undefined (single-instance).
|
|
40
67
|
*/
|
|
41
|
-
|
|
68
|
+
protected getModelId(): string | undefined;
|
|
69
|
+
/** Return status including webhook registration state. */
|
|
70
|
+
getStatus(): IngestorStatus;
|
|
42
71
|
/**
|
|
43
72
|
* Verify the webhook signature.
|
|
44
73
|
*
|
|
@@ -78,6 +107,16 @@ export declare abstract class WebhookIngestor extends BaseIngestor {
|
|
|
78
107
|
* @returns The data object to store in the ring buffer.
|
|
79
108
|
*/
|
|
80
109
|
protected abstract extractEventData(headers: Record<string, string | string[] | undefined>, body: unknown): unknown;
|
|
110
|
+
/**
|
|
111
|
+
* Instance-level content filter for multi-instance webhook discrimination.
|
|
112
|
+
*
|
|
113
|
+
* Called after signature verification and body parsing. Override in subclasses
|
|
114
|
+
* to filter webhooks by resource (e.g., Trello board ID, GitHub repo name).
|
|
115
|
+
* Return false to silently skip the webhook for this instance.
|
|
116
|
+
*
|
|
117
|
+
* Default: accept all webhooks.
|
|
118
|
+
*/
|
|
119
|
+
protected shouldAcceptPayload(_body: unknown): boolean;
|
|
81
120
|
/**
|
|
82
121
|
* Extract a service-specific idempotency key from the webhook request.
|
|
83
122
|
*
|
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
* extraction by overriding `verifySignature()`, `extractEventType()`, and
|
|
13
13
|
* `extractEventData()`.
|
|
14
14
|
*
|
|
15
|
+
* When a `lifecycle` config is present on the webhook config, this class
|
|
16
|
+
* automatically manages webhook registration with the external service:
|
|
17
|
+
* - On `start()`: lists existing webhooks, cleans up stale ones, registers new
|
|
18
|
+
* - On `stop(permanent=true)`: unregisters the webhook (deletion/shutdown only)
|
|
19
|
+
*
|
|
15
20
|
* @see GitHubWebhookIngestor
|
|
16
21
|
* @see StripeWebhookIngestor
|
|
17
22
|
*/
|
|
18
23
|
import { BaseIngestor } from '../base-ingestor.js';
|
|
24
|
+
import { WebhookLifecycleManager } from './webhook-lifecycle-manager.js';
|
|
25
|
+
import { resolvePlaceholders } from '../../../shared/config.js';
|
|
19
26
|
import { createLogger } from '../../../shared/logger.js';
|
|
20
27
|
const log = createLogger('webhook');
|
|
21
28
|
// ── Abstract Webhook Ingestor ──────────────────────────────────────────
|
|
@@ -28,32 +35,126 @@ export class WebhookIngestor extends BaseIngestor {
|
|
|
28
35
|
signatureHeader;
|
|
29
36
|
/** Event type filter (empty = capture all). */
|
|
30
37
|
eventFilter;
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
/** Resolved callback URL (with ${VAR} placeholders replaced). */
|
|
39
|
+
resolvedCallbackUrl;
|
|
40
|
+
/** Lifecycle manager for auto-registration (null if no lifecycle config). */
|
|
41
|
+
lifecycleManager;
|
|
42
|
+
/** Runtime state of the webhook registration. */
|
|
43
|
+
registrationState = null;
|
|
44
|
+
constructor(connectionAlias, secrets, webhookConfig, bufferSize, instanceId) {
|
|
45
|
+
super(connectionAlias, 'webhook', secrets, bufferSize, instanceId);
|
|
33
46
|
this.webhookPath = webhookConfig.path;
|
|
34
47
|
this.signatureHeader = webhookConfig.signatureHeader;
|
|
35
48
|
this.signatureSecretName = webhookConfig.signatureSecret;
|
|
36
49
|
this.eventFilter = [];
|
|
50
|
+
// Resolve callbackUrl from secrets if it contains ${VAR} placeholders
|
|
51
|
+
if (webhookConfig.callbackUrl) {
|
|
52
|
+
this.resolvedCallbackUrl = resolvePlaceholders(webhookConfig.callbackUrl, secrets);
|
|
53
|
+
}
|
|
54
|
+
// Initialize lifecycle manager if lifecycle config is present
|
|
55
|
+
if (webhookConfig.lifecycle) {
|
|
56
|
+
this.lifecycleManager = new WebhookLifecycleManager(webhookConfig.lifecycle, secrets);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
this.lifecycleManager = null;
|
|
60
|
+
}
|
|
37
61
|
}
|
|
38
62
|
/**
|
|
39
63
|
* Start the webhook ingestor.
|
|
40
64
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
65
|
+
* If a lifecycle config is present, attempts to auto-register the webhook
|
|
66
|
+
* with the external service before setting state to 'connected'.
|
|
67
|
+
* Registration failures are logged but never prevent the ingestor from starting
|
|
68
|
+
* (graceful degradation).
|
|
44
69
|
*/
|
|
45
|
-
start() {
|
|
70
|
+
async start() {
|
|
71
|
+
// Attempt lifecycle registration if configured
|
|
72
|
+
if (this.lifecycleManager && this.resolvedCallbackUrl) {
|
|
73
|
+
try {
|
|
74
|
+
this.registrationState = await this.lifecycleManager.ensureRegistered(this.resolvedCallbackUrl, this.getModelId());
|
|
75
|
+
if (this.registrationState.registered) {
|
|
76
|
+
log.info(`Webhook auto-registered for ${this.connectionAlias} ` +
|
|
77
|
+
`(ID: ${this.registrationState.webhookId})`);
|
|
78
|
+
}
|
|
79
|
+
else if (this.registrationState.error) {
|
|
80
|
+
log.warn(`Webhook auto-registration failed for ${this.connectionAlias}: ` +
|
|
81
|
+
this.registrationState.error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
log.warn(`Webhook lifecycle error for ${this.connectionAlias}:`, err);
|
|
86
|
+
this.registrationState = {
|
|
87
|
+
registered: false,
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
lastAttempt: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Always proceed to connected state (graceful degradation)
|
|
46
94
|
this.state = 'connected';
|
|
47
95
|
log.info(`Webhook ingestor ready for ${this.connectionAlias} ` +
|
|
48
96
|
`(path: /webhooks/${this.webhookPath})`);
|
|
49
|
-
return Promise.resolve();
|
|
50
97
|
}
|
|
51
98
|
/**
|
|
52
|
-
* Stop the webhook ingestor.
|
|
99
|
+
* Stop the webhook ingestor.
|
|
100
|
+
*
|
|
101
|
+
* When `permanent` is true (server shutdown or instance deletion), unregisters
|
|
102
|
+
* the webhook from the external service if one was registered.
|
|
103
|
+
* Regular stops (pause/restart) leave the webhook intact.
|
|
53
104
|
*/
|
|
54
|
-
stop() {
|
|
105
|
+
async stop(permanent) {
|
|
106
|
+
// Unregister webhook on permanent stop if we have a registered webhook
|
|
107
|
+
if (permanent &&
|
|
108
|
+
this.lifecycleManager &&
|
|
109
|
+
this.registrationState?.registered &&
|
|
110
|
+
this.registrationState.webhookId) {
|
|
111
|
+
try {
|
|
112
|
+
await this.lifecycleManager.unregister(this.registrationState.webhookId);
|
|
113
|
+
log.info(`Webhook unregistered for ${this.connectionAlias}`);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
log.warn(`Webhook unregistration failed for ${this.connectionAlias}:`, err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
55
119
|
this.state = 'stopped';
|
|
56
|
-
|
|
120
|
+
}
|
|
121
|
+
// ── Lifecycle hooks for subclasses ───────────────────────────────────
|
|
122
|
+
/**
|
|
123
|
+
* Return the model/resource ID for multi-instance webhook registration.
|
|
124
|
+
*
|
|
125
|
+
* Override in subclasses that support multi-instance webhooks (e.g., Trello
|
|
126
|
+
* board ID, GitHub repo name). Used by the lifecycle manager to match
|
|
127
|
+
* existing webhooks and clean up stale registrations.
|
|
128
|
+
*
|
|
129
|
+
* Default: undefined (single-instance).
|
|
130
|
+
*/
|
|
131
|
+
getModelId() {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
// ── Status ─────────────────────────────────────────────────────────────
|
|
135
|
+
/** Return status including webhook registration state. */
|
|
136
|
+
getStatus() {
|
|
137
|
+
const status = super.getStatus();
|
|
138
|
+
if (this.registrationState) {
|
|
139
|
+
status.webhookRegistration = {
|
|
140
|
+
registered: this.registrationState.registered,
|
|
141
|
+
...(this.registrationState.webhookId && { webhookId: this.registrationState.webhookId }),
|
|
142
|
+
...(this.registrationState.error && { error: this.registrationState.error }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return status;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Instance-level content filter for multi-instance webhook discrimination.
|
|
149
|
+
*
|
|
150
|
+
* Called after signature verification and body parsing. Override in subclasses
|
|
151
|
+
* to filter webhooks by resource (e.g., Trello board ID, GitHub repo name).
|
|
152
|
+
* Return false to silently skip the webhook for this instance.
|
|
153
|
+
*
|
|
154
|
+
* Default: accept all webhooks.
|
|
155
|
+
*/
|
|
156
|
+
shouldAcceptPayload(_body) {
|
|
157
|
+
return true;
|
|
57
158
|
}
|
|
58
159
|
/**
|
|
59
160
|
* Extract a service-specific idempotency key from the webhook request.
|
|
@@ -100,6 +201,10 @@ export class WebhookIngestor extends BaseIngestor {
|
|
|
100
201
|
catch {
|
|
101
202
|
return { accepted: false, reason: 'Invalid JSON body' };
|
|
102
203
|
}
|
|
204
|
+
// 2.5. Instance-level content filter (for multi-instance discrimination)
|
|
205
|
+
if (!this.shouldAcceptPayload(body)) {
|
|
206
|
+
return { accepted: true, reason: 'Not for this instance' };
|
|
207
|
+
}
|
|
103
208
|
// 3. Determine event type (delegated to subclass)
|
|
104
209
|
const eventType = this.extractEventType(headers, body);
|
|
105
210
|
// 4. Apply event filter (if any — reserved for future caller overrides)
|
|
@@ -10,8 +10,38 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @see https://docs.github.com/en/webhooks
|
|
12
12
|
*/
|
|
13
|
+
import type { WebhookIngestorConfig } from '../types.js';
|
|
13
14
|
import { WebhookIngestor } from './base-webhook-ingestor.js';
|
|
14
15
|
export declare class GitHubWebhookIngestor extends WebhookIngestor {
|
|
16
|
+
/**
|
|
17
|
+
* Repository filter for multi-instance support.
|
|
18
|
+
* When set, only webhooks from these repositories (owner/repo format) are accepted.
|
|
19
|
+
* Set via `_repoFilter` on the webhook config (injected by IngestorManager).
|
|
20
|
+
*/
|
|
21
|
+
private readonly repoFilter;
|
|
22
|
+
/**
|
|
23
|
+
* Organization filter for org-level webhook registration.
|
|
24
|
+
* When set, lifecycle URLs target the org API instead of the repo API.
|
|
25
|
+
* Set via `_orgFilter` on the webhook config (injected by IngestorManager).
|
|
26
|
+
*/
|
|
27
|
+
private readonly orgFilter;
|
|
28
|
+
constructor(connectionAlias: string, secrets: Record<string, string>, webhookConfig: WebhookIngestorConfig, bufferSize?: number, instanceId?: string);
|
|
29
|
+
/**
|
|
30
|
+
* Return the model ID for multi-instance webhook lifecycle management.
|
|
31
|
+
* For org-level: the org name. For repo-level: the single repo name.
|
|
32
|
+
*/
|
|
33
|
+
protected getModelId(): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Clone webhookConfig with lifecycle URLs rewritten for org-level endpoints.
|
|
36
|
+
* Replaces `repos/${repoFilter}` with `orgs/${orgFilter}` in all lifecycle URLs.
|
|
37
|
+
*/
|
|
38
|
+
private static withOrgLifecycle;
|
|
39
|
+
/**
|
|
40
|
+
* Filter webhooks by repository for multi-instance support.
|
|
41
|
+
* When repoFilter is set, only events from those repos are accepted.
|
|
42
|
+
* The repo is found in `body.repository.full_name` of GitHub webhook payloads.
|
|
43
|
+
*/
|
|
44
|
+
protected shouldAcceptPayload(body: unknown): boolean;
|
|
15
45
|
/**
|
|
16
46
|
* Verify the GitHub webhook signature (HMAC-SHA256).
|
|
17
47
|
*
|
|
@@ -17,6 +17,77 @@ import { createLogger } from '../../../shared/logger.js';
|
|
|
17
17
|
const log = createLogger('webhook');
|
|
18
18
|
// ── GitHub Webhook Ingestor ──────────────────────────────────────────────
|
|
19
19
|
export class GitHubWebhookIngestor extends WebhookIngestor {
|
|
20
|
+
/**
|
|
21
|
+
* Repository filter for multi-instance support.
|
|
22
|
+
* When set, only webhooks from these repositories (owner/repo format) are accepted.
|
|
23
|
+
* Set via `_repoFilter` on the webhook config (injected by IngestorManager).
|
|
24
|
+
*/
|
|
25
|
+
repoFilter;
|
|
26
|
+
/**
|
|
27
|
+
* Organization filter for org-level webhook registration.
|
|
28
|
+
* When set, lifecycle URLs target the org API instead of the repo API.
|
|
29
|
+
* Set via `_orgFilter` on the webhook config (injected by IngestorManager).
|
|
30
|
+
*/
|
|
31
|
+
orgFilter;
|
|
32
|
+
constructor(connectionAlias, secrets, webhookConfig, bufferSize, instanceId) {
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- injected by IngestorManager for multi-instance support
|
|
34
|
+
const orgFilter = webhookConfig._orgFilter;
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
36
|
+
const repoFilterArr = webhookConfig._repoFilter;
|
|
37
|
+
// When orgFilter is set, swap lifecycle URLs to use org endpoints
|
|
38
|
+
if (orgFilter && webhookConfig.lifecycle) {
|
|
39
|
+
webhookConfig = GitHubWebhookIngestor.withOrgLifecycle(webhookConfig);
|
|
40
|
+
}
|
|
41
|
+
else if (!repoFilterArr?.length && !orgFilter && webhookConfig.lifecycle) {
|
|
42
|
+
// Neither filter set — lifecycle registration can't determine the target
|
|
43
|
+
log.warn(`${connectionAlias}: No repoFilter or orgFilter set — webhook auto-registration disabled. ` +
|
|
44
|
+
'Set repoFilter (owner/repo) or orgFilter (org name) to enable.');
|
|
45
|
+
webhookConfig = { ...webhookConfig, lifecycle: undefined };
|
|
46
|
+
}
|
|
47
|
+
super(connectionAlias, secrets, webhookConfig, bufferSize, instanceId);
|
|
48
|
+
this.repoFilter = repoFilterArr ?? [];
|
|
49
|
+
this.orgFilter = orgFilter;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Return the model ID for multi-instance webhook lifecycle management.
|
|
53
|
+
* For org-level: the org name. For repo-level: the single repo name.
|
|
54
|
+
*/
|
|
55
|
+
getModelId() {
|
|
56
|
+
if (this.orgFilter)
|
|
57
|
+
return this.orgFilter;
|
|
58
|
+
return this.repoFilter.length === 1 ? this.repoFilter[0] : undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Clone webhookConfig with lifecycle URLs rewritten for org-level endpoints.
|
|
62
|
+
* Replaces `repos/${repoFilter}` with `orgs/${orgFilter}` in all lifecycle URLs.
|
|
63
|
+
*/
|
|
64
|
+
static withOrgLifecycle(config) {
|
|
65
|
+
if (!config.lifecycle)
|
|
66
|
+
return config;
|
|
67
|
+
const lc = config.lifecycle;
|
|
68
|
+
const swap = (url) => url.replace('repos/${repoFilter}', 'orgs/${orgFilter}');
|
|
69
|
+
return {
|
|
70
|
+
...config,
|
|
71
|
+
lifecycle: {
|
|
72
|
+
list: lc.list ? { ...lc.list, url: swap(lc.list.url) } : undefined,
|
|
73
|
+
register: lc.register ? { ...lc.register, url: swap(lc.register.url) } : undefined,
|
|
74
|
+
unregister: lc.unregister ? { ...lc.unregister, url: swap(lc.unregister.url) } : undefined,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Filter webhooks by repository for multi-instance support.
|
|
80
|
+
* When repoFilter is set, only events from those repos are accepted.
|
|
81
|
+
* The repo is found in `body.repository.full_name` of GitHub webhook payloads.
|
|
82
|
+
*/
|
|
83
|
+
shouldAcceptPayload(body) {
|
|
84
|
+
if (this.repoFilter.length === 0)
|
|
85
|
+
return true;
|
|
86
|
+
const payload = body;
|
|
87
|
+
const repo = payload.repository?.full_name;
|
|
88
|
+
// Events without a repository (e.g., org-level events) pass through when no filter
|
|
89
|
+
return repo ? this.repoFilter.includes(repo) : true;
|
|
90
|
+
}
|
|
20
91
|
/**
|
|
21
92
|
* Verify the GitHub webhook signature (HMAC-SHA256).
|
|
22
93
|
*
|
|
@@ -76,11 +147,11 @@ export class GitHubWebhookIngestor extends WebhookIngestor {
|
|
|
76
147
|
}
|
|
77
148
|
}
|
|
78
149
|
// ── Self-registration ────────────────────────────────────────────────────
|
|
79
|
-
registerIngestorFactory('webhook:generic', (connectionAlias, config, secrets, bufferSize) => {
|
|
150
|
+
registerIngestorFactory('webhook:generic', (connectionAlias, config, secrets, bufferSize, instanceId) => {
|
|
80
151
|
if (!config.webhook) {
|
|
81
152
|
log.error(`Missing webhook config for ${connectionAlias}`);
|
|
82
153
|
return null;
|
|
83
154
|
}
|
|
84
|
-
return new GitHubWebhookIngestor(connectionAlias, secrets, config.webhook, bufferSize);
|
|
155
|
+
return new GitHubWebhookIngestor(connectionAlias, secrets, config.webhook, bufferSize, instanceId);
|
|
85
156
|
});
|
|
86
157
|
//# sourceMappingURL=github-webhook-ingestor.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for webhook lifecycle management.
|
|
3
|
+
*
|
|
4
|
+
* Webhook lifecycle configs are declared in connection JSON templates and
|
|
5
|
+
* describe how to list, register, and unregister webhooks with external
|
|
6
|
+
* services (Trello, GitHub, Stripe, etc.).
|
|
7
|
+
*
|
|
8
|
+
* All URL and body values support `${VAR}` placeholder resolution against
|
|
9
|
+
* resolved secrets and instance params.
|
|
10
|
+
*/
|
|
11
|
+
/** Lifecycle configuration for a webhook ingestor. */
|
|
12
|
+
export interface WebhookLifecycleConfig {
|
|
13
|
+
/**
|
|
14
|
+
* List existing webhooks from the external service.
|
|
15
|
+
* Used to find existing registrations and detect stale ones.
|
|
16
|
+
*/
|
|
17
|
+
list?: {
|
|
18
|
+
method: string;
|
|
19
|
+
url: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
/** Dot-path to the array in the response (omit if top-level array). */
|
|
22
|
+
responsePath?: string;
|
|
23
|
+
/** Field name in each webhook object containing the callback URL. */
|
|
24
|
+
callbackUrlField: string;
|
|
25
|
+
/** Field name in each webhook object containing the webhook ID. */
|
|
26
|
+
idField: string;
|
|
27
|
+
/** Field name in each webhook object containing the model/resource ID. */
|
|
28
|
+
modelIdField?: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Register a new webhook with the external service.
|
|
32
|
+
*/
|
|
33
|
+
register?: {
|
|
34
|
+
method: string;
|
|
35
|
+
url: string;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
/** Request body (supports ${VAR} and ${instanceParam} placeholders). */
|
|
38
|
+
body?: Record<string, unknown>;
|
|
39
|
+
/** Field name in the response containing the new webhook ID. */
|
|
40
|
+
idField: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Unregister (delete) a webhook from the external service.
|
|
44
|
+
* The URL supports ${_webhookId} which is replaced at call time.
|
|
45
|
+
*/
|
|
46
|
+
unregister?: {
|
|
47
|
+
method: string;
|
|
48
|
+
url: string;
|
|
49
|
+
headers?: Record<string, string>;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Runtime state of a webhook registration. */
|
|
53
|
+
export interface WebhookRegistrationState {
|
|
54
|
+
/** Whether the webhook is currently registered with the external service. */
|
|
55
|
+
registered: boolean;
|
|
56
|
+
/** The ID of the registered webhook (from the external service). */
|
|
57
|
+
webhookId?: string;
|
|
58
|
+
/** Error message if registration failed. */
|
|
59
|
+
error?: string;
|
|
60
|
+
/** ISO-8601 timestamp of the last registration attempt. */
|
|
61
|
+
lastAttempt?: string;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=lifecycle-types.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for webhook lifecycle management.
|
|
3
|
+
*
|
|
4
|
+
* Webhook lifecycle configs are declared in connection JSON templates and
|
|
5
|
+
* describe how to list, register, and unregister webhooks with external
|
|
6
|
+
* services (Trello, GitHub, Stripe, etc.).
|
|
7
|
+
*
|
|
8
|
+
* All URL and body values support `${VAR}` placeholder resolution against
|
|
9
|
+
* resolved secrets and instance params.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=lifecycle-types.js.map
|
|
@@ -80,11 +80,11 @@ export class StripeWebhookIngestor extends WebhookIngestor {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
// ── Self-registration ────────────────────────────────────────────────────
|
|
83
|
-
registerIngestorFactory('webhook:stripe', (connectionAlias, config, secrets, bufferSize) => {
|
|
83
|
+
registerIngestorFactory('webhook:stripe', (connectionAlias, config, secrets, bufferSize, instanceId) => {
|
|
84
84
|
if (!config.webhook) {
|
|
85
85
|
log.error(`Missing webhook config for ${connectionAlias}`);
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
|
-
return new StripeWebhookIngestor(connectionAlias, secrets, config.webhook, bufferSize);
|
|
88
|
+
return new StripeWebhookIngestor(connectionAlias, secrets, config.webhook, bufferSize, instanceId);
|
|
89
89
|
});
|
|
90
90
|
//# sourceMappingURL=stripe-webhook-ingestor.js.map
|