@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
|
@@ -17,12 +17,24 @@ import { WebhookIngestor } from './base-webhook-ingestor.js';
|
|
|
17
17
|
import type { WebhookIngestorConfig } from '../types.js';
|
|
18
18
|
export declare class TrelloWebhookIngestor extends WebhookIngestor {
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Board ID filter for multi-instance support.
|
|
21
|
+
* When set, only webhooks from this specific board are accepted.
|
|
22
|
+
* Set via `_boardId` on the webhook config (injected by IngestorManager).
|
|
23
23
|
*/
|
|
24
|
-
private readonly
|
|
25
|
-
constructor(connectionAlias: string, secrets: Record<string, string>, webhookConfig: WebhookIngestorConfig, bufferSize?: number);
|
|
24
|
+
private readonly boardId;
|
|
25
|
+
constructor(connectionAlias: string, secrets: Record<string, string>, webhookConfig: WebhookIngestorConfig, bufferSize?: number, instanceId?: string);
|
|
26
|
+
/**
|
|
27
|
+
* Return the board ID for lifecycle webhook registration.
|
|
28
|
+
* Used by the lifecycle manager to match existing webhooks by model ID
|
|
29
|
+
* and clean up stale registrations.
|
|
30
|
+
*/
|
|
31
|
+
protected getModelId(): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Filter webhooks by board ID for multi-instance support.
|
|
34
|
+
* When boardId is set, only events from that specific board are accepted.
|
|
35
|
+
* The board ID is found in `body.model.id` of Trello webhook payloads.
|
|
36
|
+
*/
|
|
37
|
+
protected shouldAcceptPayload(body: unknown): boolean;
|
|
26
38
|
/**
|
|
27
39
|
* Verify the Trello webhook signature (HMAC-SHA1, base64-encoded).
|
|
28
40
|
*
|
|
@@ -18,32 +18,38 @@ import { WebhookIngestor } from './base-webhook-ingestor.js';
|
|
|
18
18
|
import { verifyTrelloSignature, extractTrelloActionType, extractTrelloActionId, TRELLO_SIGNATURE_HEADER, } from './trello-types.js';
|
|
19
19
|
import { createLogger } from '../../../shared/logger.js';
|
|
20
20
|
const log = createLogger('webhook');
|
|
21
|
-
// ── Placeholder resolution ──────────────────────────────────────────────
|
|
22
|
-
/**
|
|
23
|
-
* Resolve ${VAR} placeholders in a string using a secrets map.
|
|
24
|
-
* Returns the original string if no placeholders are found.
|
|
25
|
-
*/
|
|
26
|
-
function resolvePlaceholder(value, secrets) {
|
|
27
|
-
return value.replace(/\$\{(\w+)\}/g, (match, name) => {
|
|
28
|
-
if (name in secrets)
|
|
29
|
-
return secrets[name];
|
|
30
|
-
return match;
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
21
|
// ── Trello Webhook Ingestor ─────────────────────────────────────────────
|
|
34
22
|
export class TrelloWebhookIngestor extends WebhookIngestor {
|
|
35
23
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
24
|
+
* Board ID filter for multi-instance support.
|
|
25
|
+
* When set, only webhooks from this specific board are accepted.
|
|
26
|
+
* Set via `_boardId` on the webhook config (injected by IngestorManager).
|
|
39
27
|
*/
|
|
40
|
-
|
|
41
|
-
constructor(connectionAlias, secrets, webhookConfig, bufferSize) {
|
|
42
|
-
super(connectionAlias, secrets, webhookConfig, bufferSize);
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
28
|
+
boardId;
|
|
29
|
+
constructor(connectionAlias, secrets, webhookConfig, bufferSize, instanceId) {
|
|
30
|
+
super(connectionAlias, secrets, webhookConfig, bufferSize, instanceId);
|
|
31
|
+
// Board ID filter for multi-instance discrimination
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- injected by IngestorManager for multi-instance support
|
|
33
|
+
this.boardId = webhookConfig._boardId;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Return the board ID for lifecycle webhook registration.
|
|
37
|
+
* Used by the lifecycle manager to match existing webhooks by model ID
|
|
38
|
+
* and clean up stale registrations.
|
|
39
|
+
*/
|
|
40
|
+
getModelId() {
|
|
41
|
+
return this.boardId;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Filter webhooks by board ID for multi-instance support.
|
|
45
|
+
* When boardId is set, only events from that specific board are accepted.
|
|
46
|
+
* The board ID is found in `body.model.id` of Trello webhook payloads.
|
|
47
|
+
*/
|
|
48
|
+
shouldAcceptPayload(body) {
|
|
49
|
+
if (!this.boardId)
|
|
50
|
+
return true;
|
|
51
|
+
const payload = body;
|
|
52
|
+
return payload.model?.id === this.boardId;
|
|
47
53
|
}
|
|
48
54
|
/**
|
|
49
55
|
* Verify the Trello webhook signature (HMAC-SHA1, base64-encoded).
|
|
@@ -64,7 +70,7 @@ export class TrelloWebhookIngestor extends WebhookIngestor {
|
|
|
64
70
|
`in resolved secrets for ${this.connectionAlias}`);
|
|
65
71
|
return { valid: false, reason: 'Signature secret not configured' };
|
|
66
72
|
}
|
|
67
|
-
if (!this.
|
|
73
|
+
if (!this.resolvedCallbackUrl) {
|
|
68
74
|
log.error(`Callback URL not configured for ${this.connectionAlias}. ` +
|
|
69
75
|
`Trello signature verification requires the callbackUrl.`);
|
|
70
76
|
return { valid: false, reason: 'Callback URL not configured' };
|
|
@@ -75,7 +81,7 @@ export class TrelloWebhookIngestor extends WebhookIngestor {
|
|
|
75
81
|
if (!signatureValue) {
|
|
76
82
|
return { valid: false, reason: 'Missing signature header' };
|
|
77
83
|
}
|
|
78
|
-
if (!verifyTrelloSignature(rawBody, signatureValue, secret, this.
|
|
84
|
+
if (!verifyTrelloSignature(rawBody, signatureValue, secret, this.resolvedCallbackUrl)) {
|
|
79
85
|
log.warn(`Signature verification failed for ${this.connectionAlias}`);
|
|
80
86
|
return { valid: false, reason: 'Signature verification failed' };
|
|
81
87
|
}
|
|
@@ -116,11 +122,11 @@ export class TrelloWebhookIngestor extends WebhookIngestor {
|
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
// ── Self-registration ───────────────────────────────────────────────────
|
|
119
|
-
registerIngestorFactory('webhook:trello', (connectionAlias, config, secrets, bufferSize) => {
|
|
125
|
+
registerIngestorFactory('webhook:trello', (connectionAlias, config, secrets, bufferSize, instanceId) => {
|
|
120
126
|
if (!config.webhook) {
|
|
121
127
|
log.error(`Missing webhook config for ${connectionAlias}`);
|
|
122
128
|
return null;
|
|
123
129
|
}
|
|
124
|
-
return new TrelloWebhookIngestor(connectionAlias, secrets, config.webhook, bufferSize);
|
|
130
|
+
return new TrelloWebhookIngestor(connectionAlias, secrets, config.webhook, bufferSize, instanceId);
|
|
125
131
|
});
|
|
126
132
|
//# sourceMappingURL=trello-webhook-ingestor.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Lifecycle Manager.
|
|
3
|
+
*
|
|
4
|
+
* Stateless utility that executes declarative lifecycle HTTP requests to
|
|
5
|
+
* list, register, and unregister webhooks with external services.
|
|
6
|
+
*
|
|
7
|
+
* Used by webhook ingestors with a `lifecycle` config block to auto-register
|
|
8
|
+
* webhooks on start, clean up stale registrations (e.g., after tunnel URL changes),
|
|
9
|
+
* and unregister on permanent shutdown or instance deletion.
|
|
10
|
+
*
|
|
11
|
+
* All HTTP requests use native `fetch()` directly (not proxy routes) since
|
|
12
|
+
* secrets are already resolved and lifecycle URLs may not match the
|
|
13
|
+
* connection's `allowedEndpoints` patterns.
|
|
14
|
+
*/
|
|
15
|
+
import type { WebhookLifecycleConfig, WebhookRegistrationState } from './lifecycle-types.js';
|
|
16
|
+
export declare class WebhookLifecycleManager {
|
|
17
|
+
private readonly config;
|
|
18
|
+
private readonly secrets;
|
|
19
|
+
constructor(config: WebhookLifecycleConfig, secrets: Record<string, string>);
|
|
20
|
+
/**
|
|
21
|
+
* Ensure a webhook is registered with the external service (idempotent).
|
|
22
|
+
*
|
|
23
|
+
* Flow:
|
|
24
|
+
* 1. If `list` config exists → fetch existing webhooks, find one matching
|
|
25
|
+
* callbackUrl (+ modelId if provided) → reuse if found
|
|
26
|
+
* 2. Find stale webhooks (matching modelId but wrong callbackUrl) → unregister each
|
|
27
|
+
* 3. If no match → call `register` to create a new webhook
|
|
28
|
+
*
|
|
29
|
+
* Graceful degradation: if `list` fails, attempts direct `register`.
|
|
30
|
+
* All errors are caught and returned in the state object.
|
|
31
|
+
*/
|
|
32
|
+
ensureRegistered(callbackUrl: string, modelId?: string): Promise<WebhookRegistrationState>;
|
|
33
|
+
/**
|
|
34
|
+
* Unregister a webhook by ID.
|
|
35
|
+
* Replaces `${_webhookId}` in the unregister URL with the given ID.
|
|
36
|
+
* Errors are logged but never thrown.
|
|
37
|
+
*/
|
|
38
|
+
unregister(webhookId: string): Promise<void>;
|
|
39
|
+
/** Fetch the list of existing webhooks from the external service. */
|
|
40
|
+
private listWebhooks;
|
|
41
|
+
/** Register a new webhook with the external service. */
|
|
42
|
+
private register;
|
|
43
|
+
/** Resolve headers with placeholder substitution. */
|
|
44
|
+
private resolveHeaders;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=webhook-lifecycle-manager.d.ts.map
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Lifecycle Manager.
|
|
3
|
+
*
|
|
4
|
+
* Stateless utility that executes declarative lifecycle HTTP requests to
|
|
5
|
+
* list, register, and unregister webhooks with external services.
|
|
6
|
+
*
|
|
7
|
+
* Used by webhook ingestors with a `lifecycle` config block to auto-register
|
|
8
|
+
* webhooks on start, clean up stale registrations (e.g., after tunnel URL changes),
|
|
9
|
+
* and unregister on permanent shutdown or instance deletion.
|
|
10
|
+
*
|
|
11
|
+
* All HTTP requests use native `fetch()` directly (not proxy routes) since
|
|
12
|
+
* secrets are already resolved and lifecycle URLs may not match the
|
|
13
|
+
* connection's `allowedEndpoints` patterns.
|
|
14
|
+
*/
|
|
15
|
+
import { resolvePlaceholders } from '../../../shared/config.js';
|
|
16
|
+
import { createLogger } from '../../../shared/logger.js';
|
|
17
|
+
const log = createLogger('webhook-lifecycle');
|
|
18
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Recursively resolve `${VAR}` placeholders in an object tree.
|
|
21
|
+
* Walks objects and arrays, resolving placeholders in all string values.
|
|
22
|
+
*/
|
|
23
|
+
function resolveDeep(value, secrets) {
|
|
24
|
+
if (typeof value === 'string') {
|
|
25
|
+
return resolvePlaceholders(value, secrets);
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.map((item) => resolveDeep(item, secrets));
|
|
29
|
+
}
|
|
30
|
+
if (value !== null && typeof value === 'object') {
|
|
31
|
+
const result = {};
|
|
32
|
+
for (const [k, v] of Object.entries(value)) {
|
|
33
|
+
result[k] = resolveDeep(v, secrets);
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Extract a value from a nested object using a dot-separated path.
|
|
41
|
+
* E.g., `getByPath(obj, 'data.webhooks')` → `obj.data.webhooks`.
|
|
42
|
+
*/
|
|
43
|
+
function getByPath(obj, path) {
|
|
44
|
+
let current = obj;
|
|
45
|
+
for (const segment of path.split('.')) {
|
|
46
|
+
if (current === null || current === undefined || typeof current !== 'object') {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
current = current[segment];
|
|
50
|
+
}
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
// ── Lifecycle Manager ────────────────────────────────────────────────────
|
|
54
|
+
export class WebhookLifecycleManager {
|
|
55
|
+
config;
|
|
56
|
+
secrets;
|
|
57
|
+
constructor(config, secrets) {
|
|
58
|
+
this.config = config;
|
|
59
|
+
this.secrets = secrets;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Ensure a webhook is registered with the external service (idempotent).
|
|
63
|
+
*
|
|
64
|
+
* Flow:
|
|
65
|
+
* 1. If `list` config exists → fetch existing webhooks, find one matching
|
|
66
|
+
* callbackUrl (+ modelId if provided) → reuse if found
|
|
67
|
+
* 2. Find stale webhooks (matching modelId but wrong callbackUrl) → unregister each
|
|
68
|
+
* 3. If no match → call `register` to create a new webhook
|
|
69
|
+
*
|
|
70
|
+
* Graceful degradation: if `list` fails, attempts direct `register`.
|
|
71
|
+
* All errors are caught and returned in the state object.
|
|
72
|
+
*/
|
|
73
|
+
async ensureRegistered(callbackUrl, modelId) {
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
try {
|
|
76
|
+
// Try to discover existing webhooks
|
|
77
|
+
let existingWebhooks = null;
|
|
78
|
+
if (this.config.list) {
|
|
79
|
+
try {
|
|
80
|
+
existingWebhooks = await this.listWebhooks();
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
log.warn('Failed to list existing webhooks, attempting direct registration:', err);
|
|
84
|
+
// Fall through to register
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (existingWebhooks !== null && this.config.list) {
|
|
88
|
+
const listConfig = this.config.list;
|
|
89
|
+
// Find a webhook matching our callback URL (and model ID if applicable)
|
|
90
|
+
const matching = existingWebhooks.find((wh) => {
|
|
91
|
+
const rawUrl = getByPath(wh, listConfig.callbackUrlField);
|
|
92
|
+
const whCallbackUrl = typeof rawUrl === 'string' ? rawUrl : '';
|
|
93
|
+
const urlMatch = whCallbackUrl === callbackUrl;
|
|
94
|
+
if (!urlMatch)
|
|
95
|
+
return false;
|
|
96
|
+
if (modelId && listConfig.modelIdField) {
|
|
97
|
+
const rawModelId = getByPath(wh, listConfig.modelIdField);
|
|
98
|
+
return (typeof rawModelId === 'string' ? rawModelId : '') === modelId;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
if (matching) {
|
|
103
|
+
const webhookId = String(getByPath(matching, listConfig.idField));
|
|
104
|
+
log.info(`Found existing webhook (ID: ${webhookId}), reusing`);
|
|
105
|
+
return { registered: true, webhookId, lastAttempt: now };
|
|
106
|
+
}
|
|
107
|
+
// Clean up stale webhooks (matching model but wrong callback URL)
|
|
108
|
+
if (modelId && listConfig.modelIdField && this.config.unregister) {
|
|
109
|
+
const stale = existingWebhooks.filter((wh) => {
|
|
110
|
+
const rawModelId = getByPath(wh, listConfig.modelIdField);
|
|
111
|
+
const whModelId = typeof rawModelId === 'string' ? rawModelId : '';
|
|
112
|
+
const rawUrl = getByPath(wh, listConfig.callbackUrlField);
|
|
113
|
+
const whCallbackUrl = typeof rawUrl === 'string' ? rawUrl : '';
|
|
114
|
+
return whModelId === modelId && whCallbackUrl !== callbackUrl;
|
|
115
|
+
});
|
|
116
|
+
for (const staleWh of stale) {
|
|
117
|
+
const staleId = String(getByPath(staleWh, listConfig.idField));
|
|
118
|
+
log.info(`Cleaning up stale webhook (ID: ${staleId})`);
|
|
119
|
+
try {
|
|
120
|
+
await this.unregister(staleId);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
log.warn(`Failed to clean up stale webhook ${staleId}:`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// No existing match — register a new webhook
|
|
129
|
+
if (this.config.register) {
|
|
130
|
+
return await this.register(callbackUrl, modelId);
|
|
131
|
+
}
|
|
132
|
+
// No register config — can't auto-register
|
|
133
|
+
return { registered: false, error: 'No register config defined', lastAttempt: now };
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
137
|
+
log.error('Webhook lifecycle error:', error);
|
|
138
|
+
return { registered: false, error, lastAttempt: now };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Unregister a webhook by ID.
|
|
143
|
+
* Replaces `${_webhookId}` in the unregister URL with the given ID.
|
|
144
|
+
* Errors are logged but never thrown.
|
|
145
|
+
*/
|
|
146
|
+
async unregister(webhookId) {
|
|
147
|
+
if (!this.config.unregister) {
|
|
148
|
+
log.warn('No unregister config defined, skipping');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const mergedSecrets = { ...this.secrets, _webhookId: webhookId };
|
|
152
|
+
const url = resolvePlaceholders(this.config.unregister.url, mergedSecrets);
|
|
153
|
+
const headers = this.resolveHeaders(this.config.unregister.headers, mergedSecrets);
|
|
154
|
+
try {
|
|
155
|
+
const resp = await fetch(url, {
|
|
156
|
+
method: this.config.unregister.method,
|
|
157
|
+
headers,
|
|
158
|
+
});
|
|
159
|
+
if (!resp.ok) {
|
|
160
|
+
const body = await resp.text().catch(() => '');
|
|
161
|
+
log.warn(`Webhook unregister failed (${resp.status}): ${body}`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
log.info(`Webhook unregistered (ID: ${webhookId})`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
log.warn(`Webhook unregister request failed for ${webhookId}:`, err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ── Private helpers ────────────────────────────────────────────────────
|
|
172
|
+
/** Fetch the list of existing webhooks from the external service. */
|
|
173
|
+
async listWebhooks() {
|
|
174
|
+
const listConfig = this.config.list;
|
|
175
|
+
const url = resolvePlaceholders(listConfig.url, this.secrets);
|
|
176
|
+
const headers = this.resolveHeaders(listConfig.headers);
|
|
177
|
+
const resp = await fetch(url, {
|
|
178
|
+
method: listConfig.method,
|
|
179
|
+
headers,
|
|
180
|
+
});
|
|
181
|
+
if (!resp.ok) {
|
|
182
|
+
const body = await resp.text().catch(() => '');
|
|
183
|
+
throw new Error(`List webhooks failed (${resp.status}): ${body}`);
|
|
184
|
+
}
|
|
185
|
+
const json = await resp.json();
|
|
186
|
+
// Extract array from response (may be nested under a dot-path)
|
|
187
|
+
let webhooks;
|
|
188
|
+
if (listConfig.responsePath) {
|
|
189
|
+
webhooks = getByPath(json, listConfig.responsePath);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
webhooks = json;
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(webhooks)) {
|
|
195
|
+
throw new Error(`Expected array from list endpoint${listConfig.responsePath ? ` at path "${listConfig.responsePath}"` : ''}, got ${typeof webhooks}`);
|
|
196
|
+
}
|
|
197
|
+
return webhooks;
|
|
198
|
+
}
|
|
199
|
+
/** Register a new webhook with the external service. */
|
|
200
|
+
async register(callbackUrl, modelId) {
|
|
201
|
+
const now = new Date().toISOString();
|
|
202
|
+
const registerConfig = this.config.register;
|
|
203
|
+
// Merge callbackUrl and modelId into resolution context
|
|
204
|
+
const mergedSecrets = { ...this.secrets };
|
|
205
|
+
if (callbackUrl)
|
|
206
|
+
mergedSecrets.CALLBACK_URL = callbackUrl;
|
|
207
|
+
if (modelId)
|
|
208
|
+
mergedSecrets.MODEL_ID = modelId;
|
|
209
|
+
const url = resolvePlaceholders(registerConfig.url, mergedSecrets);
|
|
210
|
+
const headers = this.resolveHeaders(registerConfig.headers, mergedSecrets);
|
|
211
|
+
const fetchOptions = {
|
|
212
|
+
method: registerConfig.method,
|
|
213
|
+
headers,
|
|
214
|
+
};
|
|
215
|
+
if (registerConfig.body) {
|
|
216
|
+
const resolvedBody = resolveDeep(registerConfig.body, mergedSecrets);
|
|
217
|
+
fetchOptions.body = JSON.stringify(resolvedBody);
|
|
218
|
+
// Ensure Content-Type is set if there's a body
|
|
219
|
+
if (!headers['Content-Type'] && !headers['content-type']) {
|
|
220
|
+
headers['Content-Type'] = 'application/json';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const resp = await fetch(url, fetchOptions);
|
|
224
|
+
if (!resp.ok) {
|
|
225
|
+
const body = await resp.text().catch(() => '');
|
|
226
|
+
return {
|
|
227
|
+
registered: false,
|
|
228
|
+
error: `Register failed (${resp.status}): ${body}`,
|
|
229
|
+
lastAttempt: now,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const json = await resp.json();
|
|
233
|
+
const rawWebhookId = json[registerConfig.idField];
|
|
234
|
+
const webhookId = typeof rawWebhookId === 'string'
|
|
235
|
+
? rawWebhookId
|
|
236
|
+
: typeof rawWebhookId === 'number'
|
|
237
|
+
? String(rawWebhookId)
|
|
238
|
+
: '';
|
|
239
|
+
if (!webhookId) {
|
|
240
|
+
return {
|
|
241
|
+
registered: false,
|
|
242
|
+
error: `Register response missing "${registerConfig.idField}" field`,
|
|
243
|
+
lastAttempt: now,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
log.info(`Webhook registered (ID: ${webhookId})`);
|
|
247
|
+
return { registered: true, webhookId, lastAttempt: now };
|
|
248
|
+
}
|
|
249
|
+
/** Resolve headers with placeholder substitution. */
|
|
250
|
+
resolveHeaders(headers, secretsOverride) {
|
|
251
|
+
if (!headers)
|
|
252
|
+
return {};
|
|
253
|
+
const secrets = secretsOverride ?? this.secrets;
|
|
254
|
+
const resolved = {};
|
|
255
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
256
|
+
resolved[key] = resolvePlaceholders(value, secrets);
|
|
257
|
+
}
|
|
258
|
+
return resolved;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
//# sourceMappingURL=webhook-lifecycle-manager.js.map
|
package/dist/remote/server.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - Maintains an audit log of all operations
|
|
13
13
|
* - Rate-limits requests per session
|
|
14
14
|
*/
|
|
15
|
+
import express from 'express';
|
|
15
16
|
import { type RemoteServerConfig, type ResolvedRoute } from '../shared/config.js';
|
|
16
17
|
import { EncryptedChannel, type PublicKeyBundle } from '../shared/crypto/index.js';
|
|
17
18
|
import { HandshakeResponder, type HandshakeInit } from '../shared/protocol/index.js';
|
|
@@ -43,13 +44,25 @@ export interface PendingHandshake {
|
|
|
43
44
|
init: HandshakeInit;
|
|
44
45
|
createdAt: number;
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
/** Sanitized session projection — never includes channel keys or resolved
|
|
48
|
+
* routes (which carry decrypted secrets). Returned by getSessionsSnapshot()
|
|
49
|
+
* for the read-only /admin API. */
|
|
50
|
+
export interface SessionSnapshot {
|
|
51
|
+
/** First 12 chars of the session ID — enough to disambiguate, doesn't
|
|
52
|
+
* expose enough material to forge or replay encrypted requests. */
|
|
53
|
+
sessionIdShort: string;
|
|
54
|
+
callerAlias: string;
|
|
55
|
+
createdAt: number;
|
|
56
|
+
lastActivity: number;
|
|
57
|
+
requestCount: number;
|
|
58
|
+
windowRequests: number;
|
|
59
|
+
windowStart: number;
|
|
60
|
+
}
|
|
61
|
+
/** Loopback guard — used by /sync/listen, /sync/status, /events/stream, /admin.
|
|
62
|
+
* Hoisted to module scope so the admin router and its tests can reuse it. */
|
|
63
|
+
export declare function requireLoopback(req: express.Request, res: express.Response, next: express.NextFunction): void;
|
|
47
64
|
export { resolvePlaceholders } from '../shared/config.js';
|
|
48
|
-
|
|
49
|
-
* Find the first route whose allowedEndpoints match the given URL.
|
|
50
|
-
* Routes with empty allowedEndpoints match nothing.
|
|
51
|
-
*/
|
|
52
|
-
export declare function matchRoute(url: string, routes: ResolvedRoute[]): ResolvedRoute | null;
|
|
65
|
+
export { toolHandlers, executeProxyRequest, isEndpointAllowed, matchRoute, type ToolContext, type ProxyRequestInput, type ProxyRequestResult, type FileAttachment, } from './tool-dispatch.js';
|
|
53
66
|
export declare function checkRateLimit(session: Pick<Session, 'windowRequests' | 'windowStart'>, limit: number): boolean;
|
|
54
67
|
export declare const SESSION_TTL: number;
|
|
55
68
|
export declare const HANDSHAKE_TTL: number;
|
|
@@ -57,36 +70,13 @@ export declare function cleanupSessions(sessionsMap: Map<string, Pick<Session, '
|
|
|
57
70
|
expiredSessions: string[];
|
|
58
71
|
expiredHandshakes: string[];
|
|
59
72
|
};
|
|
60
|
-
export interface ProxyRequestInput {
|
|
61
|
-
method: string;
|
|
62
|
-
url: string;
|
|
63
|
-
headers?: Record<string, string>;
|
|
64
|
-
body?: unknown;
|
|
65
|
-
}
|
|
66
|
-
export interface ProxyRequestResult {
|
|
67
|
-
status: number;
|
|
68
|
-
statusText: string;
|
|
69
|
-
headers: Record<string, string>;
|
|
70
|
-
body: unknown;
|
|
71
|
-
}
|
|
72
73
|
/**
|
|
73
|
-
*
|
|
74
|
+
* Project the active sessions into a sanitized snapshot for read-only use.
|
|
74
75
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* - callboard's `LocalProxy` class (in-process, no encryption)
|
|
78
|
-
*
|
|
79
|
-
* Pure in the sense that it takes routes as input rather than reading global state.
|
|
80
|
-
* The only side effect is the outbound fetch().
|
|
76
|
+
* Drops `channel` (holds AES keys) and `resolvedRoutes` (carry decrypted
|
|
77
|
+
* secrets). Used by the loopback /admin API; never call res.json(session).
|
|
81
78
|
*/
|
|
82
|
-
export declare function
|
|
83
|
-
/** Context passed to every tool handler, providing caller identity and shared services. */
|
|
84
|
-
export interface ToolContext {
|
|
85
|
-
/** The caller alias for the session making this request. */
|
|
86
|
-
callerAlias: string;
|
|
87
|
-
/** The shared ingestor manager (for poll_events / ingestor_status). */
|
|
88
|
-
ingestorManager: IngestorManager;
|
|
89
|
-
}
|
|
79
|
+
export declare function getSessionsSnapshot(): SessionSnapshot[];
|
|
90
80
|
/** Options for creating the app — allows dependency injection for tests */
|
|
91
81
|
export interface CreateAppOptions {
|
|
92
82
|
/** Override config instead of loading from disk */
|
|
@@ -97,6 +87,8 @@ export interface CreateAppOptions {
|
|
|
97
87
|
authorizedPeers?: AuthorizedPeer[];
|
|
98
88
|
/** Override the ingestor manager instead of creating one from config */
|
|
99
89
|
ingestorManager?: IngestorManager;
|
|
90
|
+
/** Disable per-IP rate limiting (for tests that make many requests from localhost) */
|
|
91
|
+
disableRateLimiting?: boolean;
|
|
100
92
|
}
|
|
101
93
|
export declare function createApp(options?: CreateAppOptions): import("express-serve-static-core").Express;
|
|
102
94
|
export declare function main(): void;
|