@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.
Files changed (119) hide show
  1. package/CONNECTIONS.md +3 -0
  2. package/README.md +395 -465
  3. package/bin/drawlatch.js +1018 -49
  4. package/dist/auth/auth.d.ts +10 -0
  5. package/dist/auth/auth.js +146 -0
  6. package/dist/auth/env-writer.d.ts +6 -0
  7. package/dist/auth/env-writer.js +52 -0
  8. package/dist/auth/password.d.ts +8 -0
  9. package/dist/auth/password.js +28 -0
  10. package/dist/auth/sessions.d.ts +13 -0
  11. package/dist/auth/sessions.js +85 -0
  12. package/dist/cli/generate-keys.d.ts +4 -4
  13. package/dist/cli/generate-keys.js +30 -25
  14. package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
  15. package/dist/connections/{devin.json → ai/devin.json} +7 -1
  16. package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
  17. package/dist/connections/{openai.json → ai/openai.json} +7 -1
  18. package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
  19. package/dist/connections/developer-tools/github.json +138 -0
  20. package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
  21. package/dist/connections/developer-tools/linear.json +75 -0
  22. package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
  23. package/dist/connections/messaging/agentmail.json +21 -0
  24. package/dist/connections/messaging/discord-bot.json +114 -0
  25. package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
  26. package/dist/connections/messaging/slack.json +75 -0
  27. package/dist/connections/messaging/telegram.json +66 -0
  28. package/dist/connections/{google.json → productivity/google.json} +7 -1
  29. package/dist/connections/productivity/notion.json +75 -0
  30. package/dist/connections/productivity/stripe.json +74 -0
  31. package/dist/connections/productivity/trello.json +113 -0
  32. package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
  33. package/dist/connections/social-media/mastodon.json +65 -0
  34. package/dist/connections/social-media/reddit.json +80 -0
  35. package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
  36. package/dist/connections/social-media/x.json +67 -0
  37. package/dist/mcp/server.js +544 -31
  38. package/dist/remote/admin-mutations.d.ts +37 -0
  39. package/dist/remote/admin-mutations.js +251 -0
  40. package/dist/remote/admin-types.d.ts +149 -0
  41. package/dist/remote/admin-types.js +11 -0
  42. package/dist/remote/admin.d.ts +37 -0
  43. package/dist/remote/admin.js +316 -0
  44. package/dist/remote/caller-bootstrap.d.ts +71 -0
  45. package/dist/remote/caller-bootstrap.js +141 -0
  46. package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
  47. package/dist/remote/ingestors/base-ingestor.js +27 -6
  48. package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
  49. package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
  50. package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
  51. package/dist/remote/ingestors/e2e/setup.js +147 -0
  52. package/dist/remote/ingestors/manager.d.ts +110 -10
  53. package/dist/remote/ingestors/manager.js +449 -42
  54. package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
  55. package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
  56. package/dist/remote/ingestors/registry.d.ts +2 -2
  57. package/dist/remote/ingestors/registry.js +2 -2
  58. package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
  59. package/dist/remote/ingestors/slack/socket-mode.js +28 -6
  60. package/dist/remote/ingestors/types.d.ts +25 -0
  61. package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
  62. package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
  63. package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
  64. package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
  65. package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
  66. package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
  67. package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
  68. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
  69. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
  70. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
  71. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
  72. package/dist/remote/server.d.ts +25 -33
  73. package/dist/remote/server.js +681 -195
  74. package/dist/remote/tool-dispatch.d.ts +84 -0
  75. package/dist/remote/tool-dispatch.js +910 -0
  76. package/dist/remote/triggers/rule-engine.d.ts +40 -0
  77. package/dist/remote/triggers/rule-engine.js +228 -0
  78. package/dist/remote/triggers/types.d.ts +69 -0
  79. package/dist/remote/triggers/types.js +10 -0
  80. package/dist/remote/tunnel-state.d.ts +14 -0
  81. package/dist/remote/tunnel-state.js +20 -0
  82. package/dist/remote/tunnel.d.ts +53 -0
  83. package/dist/remote/tunnel.js +149 -0
  84. package/dist/shared/config.d.ts +97 -25
  85. package/dist/shared/config.js +48 -35
  86. package/dist/shared/connections.d.ts +21 -5
  87. package/dist/shared/connections.js +56 -14
  88. package/dist/shared/crypto/index.d.ts +1 -0
  89. package/dist/shared/crypto/index.js +1 -0
  90. package/dist/shared/crypto/key-manager.d.ts +67 -0
  91. package/dist/shared/crypto/key-manager.js +152 -0
  92. package/dist/shared/env-utils.d.ts +42 -0
  93. package/dist/shared/env-utils.js +150 -0
  94. package/dist/shared/listener-config.d.ts +157 -0
  95. package/dist/shared/listener-config.js +10 -0
  96. package/dist/shared/migrations.d.ts +40 -0
  97. package/dist/shared/migrations.js +122 -0
  98. package/dist/shared/protocol/handshake.js +14 -1
  99. package/dist/shared/protocol/index.d.ts +2 -0
  100. package/dist/shared/protocol/index.js +2 -0
  101. package/dist/shared/protocol/sync-client.d.ts +52 -0
  102. package/dist/shared/protocol/sync-client.js +99 -0
  103. package/dist/shared/protocol/sync.d.ts +71 -0
  104. package/dist/shared/protocol/sync.js +176 -0
  105. package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
  106. package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
  107. package/frontend/dist/index.html +15 -0
  108. package/package.json +66 -13
  109. package/dist/connections/discord-bot.json +0 -24
  110. package/dist/connections/github.json +0 -25
  111. package/dist/connections/linear.json +0 -29
  112. package/dist/connections/mastodon.json +0 -25
  113. package/dist/connections/notion.json +0 -33
  114. package/dist/connections/reddit.json +0 -28
  115. package/dist/connections/slack.json +0 -23
  116. package/dist/connections/stripe.json +0 -25
  117. package/dist/connections/telegram.json +0 -26
  118. package/dist/connections/trello.json +0 -25
  119. 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.close(1000, 'Shutting down');
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
- constructor(connectionAlias: string, secrets: Record<string, string>, webhookConfig: WebhookIngestorConfig, bufferSize?: number);
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
- * Unlike WebSocket ingestors, there's nothing to "connect" to the ingestor
34
- * is passive and waits for `handleWebhook()` calls from the Express route.
35
- * We set the state to 'connected' immediately.
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. Nothing to clean up — just set state.
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
- stop(): Promise<void>;
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
- constructor(connectionAlias, secrets, webhookConfig, bufferSize) {
32
- super(connectionAlias, 'webhook', secrets, bufferSize);
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
- * Unlike WebSocket ingestors, there's nothing to "connect" to the ingestor
42
- * is passive and waits for `handleWebhook()` calls from the Express route.
43
- * We set the state to 'connected' immediately.
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. Nothing to clean up — just set state.
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
- return Promise.resolve();
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