@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
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical MCP tool implementations — the single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* These handlers implement every proxy/management tool drawlatch exposes:
|
|
5
|
+
* http_request, list_routes, poll_events, ingestor_status,
|
|
6
|
+
* test_connection, test_ingestor, control_listener,
|
|
7
|
+
* list_listener_configs, resolve_listener_options,
|
|
8
|
+
* get/set_listener_params, list/delete_listener_instance,
|
|
9
|
+
* list_connection_templates, set_connection_enabled,
|
|
10
|
+
* set_secrets, get_secret_status.
|
|
11
|
+
*
|
|
12
|
+
* Consumed by:
|
|
13
|
+
* - The remote secure server's `/request` dispatch (src/remote/server.ts)
|
|
14
|
+
* - The password-gated admin API (src/remote/admin-mutations.ts)
|
|
15
|
+
* - Any in-process host (e.g. callboard) that imports
|
|
16
|
+
* `@wolpertingerlabs/drawlatch/remote/tool-dispatch` instead of
|
|
17
|
+
* re-implementing a LocalProxy.
|
|
18
|
+
*
|
|
19
|
+
* Pure in the sense that handlers take `routes` + `context` as input rather
|
|
20
|
+
* than reading global session state. The only side effects are the outbound
|
|
21
|
+
* fetch() and config/.env writes (which mirror what the live daemon does).
|
|
22
|
+
*/
|
|
23
|
+
import { loadRemoteConfig, saveRemoteConfig, resolvePlaceholders, } from '../shared/config.js';
|
|
24
|
+
import { listConnectionTemplates } from '../shared/connections.js';
|
|
25
|
+
import { isSecretSetForCaller, setCallerSecrets } from '../shared/env-utils.js';
|
|
26
|
+
// ── Endpoint matching ────────────────────────────────────────────────────────
|
|
27
|
+
export function isEndpointAllowed(url, patterns) {
|
|
28
|
+
if (patterns.length === 0)
|
|
29
|
+
return true; // no restrictions if empty
|
|
30
|
+
return patterns.some((pattern) => {
|
|
31
|
+
// Support simple glob patterns: * matches anything within a segment, ** matches across segments
|
|
32
|
+
const regex = new RegExp('^' +
|
|
33
|
+
pattern
|
|
34
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
35
|
+
.replace(/\*\*/g, '.__DOUBLE_STAR__.')
|
|
36
|
+
.replace(/\*/g, '[^/]*')
|
|
37
|
+
.replace(/\.__DOUBLE_STAR__\./g, '.*') +
|
|
38
|
+
'$');
|
|
39
|
+
return regex.test(url);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Find the first route whose allowedEndpoints match the given URL.
|
|
44
|
+
* Routes with empty allowedEndpoints match nothing.
|
|
45
|
+
*/
|
|
46
|
+
export function matchRoute(url, routes) {
|
|
47
|
+
for (const route of routes) {
|
|
48
|
+
if (route.allowedEndpoints.length > 0 && isEndpointAllowed(url, route.allowedEndpoints)) {
|
|
49
|
+
return route;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Core proxy request execution — route matching, secret injection, and fetch.
|
|
56
|
+
*
|
|
57
|
+
* Pure in the sense that it takes routes as input rather than reading global
|
|
58
|
+
* state. The only side effect is the outbound fetch().
|
|
59
|
+
*/
|
|
60
|
+
export async function executeProxyRequest(input, routes) {
|
|
61
|
+
const { method, url, headers = {}, body, files, bodyFieldName } = input;
|
|
62
|
+
// Step 1: Find matching route — try raw URL first
|
|
63
|
+
let matched = matchRoute(url, routes);
|
|
64
|
+
let resolvedUrl = url;
|
|
65
|
+
if (matched) {
|
|
66
|
+
// Resolve URL placeholders using matched route's secrets
|
|
67
|
+
resolvedUrl = resolvePlaceholders(url, matched.secrets);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Try resolving URL with each route's secrets to find a match
|
|
71
|
+
for (const route of routes) {
|
|
72
|
+
if (route.allowedEndpoints.length === 0)
|
|
73
|
+
continue;
|
|
74
|
+
const candidateUrl = resolvePlaceholders(url, route.secrets);
|
|
75
|
+
if (isEndpointAllowed(candidateUrl, route.allowedEndpoints)) {
|
|
76
|
+
matched = route;
|
|
77
|
+
resolvedUrl = candidateUrl;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!matched) {
|
|
83
|
+
throw new Error(`Endpoint not allowed: ${url}`);
|
|
84
|
+
}
|
|
85
|
+
// Step 2: Resolve client headers using matched route's secrets
|
|
86
|
+
const resolvedHeaders = {};
|
|
87
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
88
|
+
resolvedHeaders[k] = resolvePlaceholders(v, matched.secrets);
|
|
89
|
+
}
|
|
90
|
+
// Step 3: Check for header conflicts — reject if client provides a header
|
|
91
|
+
// that conflicts with a route-level header (case-insensitive)
|
|
92
|
+
const routeHeaderKeys = new Set(Object.keys(matched.headers).map((k) => k.toLowerCase()));
|
|
93
|
+
for (const clientKey of Object.keys(resolvedHeaders)) {
|
|
94
|
+
if (routeHeaderKeys.has(clientKey.toLowerCase())) {
|
|
95
|
+
throw new Error(`Header conflict: client-provided header "${clientKey}" conflicts with a route-level header. Remove it from the request.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Step 4: Merge route-level headers (they take effect after conflict check)
|
|
99
|
+
for (const [k, v] of Object.entries(matched.headers)) {
|
|
100
|
+
resolvedHeaders[k] = v;
|
|
101
|
+
}
|
|
102
|
+
// Step 5: Resolve body placeholders using matched route's secrets.
|
|
103
|
+
// Only when the route explicitly opts in via resolveSecretsInBody — prevents
|
|
104
|
+
// exfiltration of secrets by writing placeholder strings into API resources
|
|
105
|
+
// and reading them back.
|
|
106
|
+
let fetchBody;
|
|
107
|
+
if (files?.length) {
|
|
108
|
+
// ── Multipart mode: build FormData with file attachments ──
|
|
109
|
+
const form = new FormData();
|
|
110
|
+
// Add the JSON body as a named part (default: "payload_json" for Discord-style APIs)
|
|
111
|
+
if (body !== null && body !== undefined) {
|
|
112
|
+
const serialized = typeof body === 'string' ? body : JSON.stringify(body);
|
|
113
|
+
const resolvedPayload = matched.resolveSecretsInBody
|
|
114
|
+
? resolvePlaceholders(serialized, matched.secrets)
|
|
115
|
+
: serialized;
|
|
116
|
+
form.append(bodyFieldName ?? 'payload_json', resolvedPayload);
|
|
117
|
+
}
|
|
118
|
+
// Attach each file from base64 data
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
const buffer = Buffer.from(file.data, 'base64');
|
|
121
|
+
const blob = new Blob([buffer], { type: file.contentType });
|
|
122
|
+
form.append(file.field, blob, file.filename);
|
|
123
|
+
}
|
|
124
|
+
fetchBody = form;
|
|
125
|
+
// Let fetch auto-set Content-Type with the correct multipart boundary —
|
|
126
|
+
// remove any Content-Type that may have been set by route headers
|
|
127
|
+
delete resolvedHeaders['Content-Type'];
|
|
128
|
+
delete resolvedHeaders['content-type'];
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// ── Standard JSON/string body ──
|
|
132
|
+
if (typeof body === 'string') {
|
|
133
|
+
fetchBody = matched.resolveSecretsInBody ? resolvePlaceholders(body, matched.secrets) : body;
|
|
134
|
+
}
|
|
135
|
+
else if (body !== null && body !== undefined) {
|
|
136
|
+
const serialized = JSON.stringify(body);
|
|
137
|
+
fetchBody = matched.resolveSecretsInBody
|
|
138
|
+
? resolvePlaceholders(serialized, matched.secrets)
|
|
139
|
+
: serialized;
|
|
140
|
+
if (!resolvedHeaders['content-type'] && !resolvedHeaders['Content-Type']) {
|
|
141
|
+
resolvedHeaders['Content-Type'] = 'application/json';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Step 6: Final endpoint check on fully resolved URL
|
|
146
|
+
if (!isEndpointAllowed(resolvedUrl, matched.allowedEndpoints)) {
|
|
147
|
+
throw new Error(`Endpoint not allowed after resolution: ${url}`);
|
|
148
|
+
}
|
|
149
|
+
// Step 7: Make the actual HTTP request
|
|
150
|
+
const resp = await fetch(resolvedUrl, {
|
|
151
|
+
method,
|
|
152
|
+
headers: resolvedHeaders,
|
|
153
|
+
body: fetchBody,
|
|
154
|
+
});
|
|
155
|
+
const contentType = resp.headers.get('content-type') ?? '';
|
|
156
|
+
let responseBody;
|
|
157
|
+
if (contentType.includes('application/json')) {
|
|
158
|
+
responseBody = await resp.json();
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
responseBody = await resp.text();
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: resp.status,
|
|
165
|
+
statusText: resp.statusText,
|
|
166
|
+
headers: Object.fromEntries(resp.headers.entries()),
|
|
167
|
+
body: responseBody,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
export const toolHandlers = {
|
|
171
|
+
/**
|
|
172
|
+
* Proxied HTTP request with route-scoped secret injection.
|
|
173
|
+
* Delegates to the extracted executeProxyRequest() function.
|
|
174
|
+
*/
|
|
175
|
+
async http_request(input, routes, _context) {
|
|
176
|
+
return executeProxyRequest(input, routes);
|
|
177
|
+
},
|
|
178
|
+
/**
|
|
179
|
+
* List available routes with metadata, endpoint patterns, and secret names (not values).
|
|
180
|
+
* Provides full disclosure of available routes for the local agent.
|
|
181
|
+
*/
|
|
182
|
+
list_routes(_input, routes, _context) {
|
|
183
|
+
const routeList = routes.map((route, index) => {
|
|
184
|
+
const info = { index };
|
|
185
|
+
if (route.alias)
|
|
186
|
+
info.alias = route.alias;
|
|
187
|
+
if (route.name)
|
|
188
|
+
info.name = route.name;
|
|
189
|
+
if (route.description)
|
|
190
|
+
info.description = route.description;
|
|
191
|
+
if (route.docsUrl)
|
|
192
|
+
info.docsUrl = route.docsUrl;
|
|
193
|
+
if (route.openApiUrl)
|
|
194
|
+
info.openApiUrl = route.openApiUrl;
|
|
195
|
+
if (route.stability)
|
|
196
|
+
info.stability = route.stability;
|
|
197
|
+
if (route.category)
|
|
198
|
+
info.category = route.category;
|
|
199
|
+
info.allowedEndpoints = route.allowedEndpoints;
|
|
200
|
+
info.secretNames = Object.keys(route.secrets);
|
|
201
|
+
info.autoHeaders = Object.keys(route.headers);
|
|
202
|
+
// Ingestor & testing metadata
|
|
203
|
+
info.hasTestConnection = route.testConnection !== undefined;
|
|
204
|
+
info.hasIngestor = route.ingestorConfig !== undefined;
|
|
205
|
+
if (route.ingestorConfig) {
|
|
206
|
+
info.ingestorType = route.ingestorConfig.type;
|
|
207
|
+
info.hasTestIngestor = route.testIngestor !== undefined && route.testIngestor !== null;
|
|
208
|
+
info.hasListenerConfig = route.listenerConfig !== undefined;
|
|
209
|
+
if (route.listenerConfig) {
|
|
210
|
+
info.listenerParamKeys = route.listenerConfig.fields.map((f) => f.key);
|
|
211
|
+
info.supportsMultiInstance = route.listenerConfig.supportsMultiInstance ?? false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return info;
|
|
215
|
+
});
|
|
216
|
+
return Promise.resolve(routeList);
|
|
217
|
+
},
|
|
218
|
+
/**
|
|
219
|
+
* Poll for new events from ingestors (Discord Gateway, webhooks, pollers).
|
|
220
|
+
* Returns events since a cursor, optionally filtered by connection.
|
|
221
|
+
*/
|
|
222
|
+
poll_events(input, _routes, context) {
|
|
223
|
+
const { connection, after_id, instance_id } = input;
|
|
224
|
+
const afterId = after_id ?? -1;
|
|
225
|
+
if (connection) {
|
|
226
|
+
return Promise.resolve(context.ingestorManager.getEvents(context.callerAlias, connection, afterId, instance_id));
|
|
227
|
+
}
|
|
228
|
+
return Promise.resolve(context.ingestorManager.getAllEvents(context.callerAlias, afterId));
|
|
229
|
+
},
|
|
230
|
+
/**
|
|
231
|
+
* Get the status of all active ingestors for this caller.
|
|
232
|
+
*/
|
|
233
|
+
ingestor_status(_input, _routes, context) {
|
|
234
|
+
return Promise.resolve(context.ingestorManager.getStatuses(context.callerAlias));
|
|
235
|
+
},
|
|
236
|
+
/**
|
|
237
|
+
* Test a connection's API credentials by executing a pre-configured,
|
|
238
|
+
* non-destructive read-only request. Returns success/failure with status details.
|
|
239
|
+
*/
|
|
240
|
+
async test_connection(input, routes, _context) {
|
|
241
|
+
const { connection } = input;
|
|
242
|
+
// Find the route matching this connection alias
|
|
243
|
+
const route = routes.find((r) => r.alias === connection);
|
|
244
|
+
if (!route) {
|
|
245
|
+
return { success: false, connection, error: `Unknown connection: ${connection}` };
|
|
246
|
+
}
|
|
247
|
+
if (!route.testConnection) {
|
|
248
|
+
return {
|
|
249
|
+
success: false,
|
|
250
|
+
connection,
|
|
251
|
+
supported: false,
|
|
252
|
+
error: 'This connection does not have a test configuration.',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const testConfig = route.testConnection;
|
|
256
|
+
const method = testConfig.method ?? 'GET';
|
|
257
|
+
const expectedStatus = testConfig.expectedStatus ?? [200];
|
|
258
|
+
try {
|
|
259
|
+
const result = await executeProxyRequest({
|
|
260
|
+
method,
|
|
261
|
+
url: testConfig.url,
|
|
262
|
+
headers: testConfig.headers,
|
|
263
|
+
body: testConfig.body,
|
|
264
|
+
}, routes);
|
|
265
|
+
const isSuccess = expectedStatus.includes(result.status);
|
|
266
|
+
return {
|
|
267
|
+
success: isSuccess,
|
|
268
|
+
connection,
|
|
269
|
+
status: result.status,
|
|
270
|
+
statusText: result.statusText,
|
|
271
|
+
description: testConfig.description,
|
|
272
|
+
...(isSuccess
|
|
273
|
+
? {}
|
|
274
|
+
: {
|
|
275
|
+
error: `Unexpected status ${result.status} (expected ${expectedStatus.join(' or ')})`,
|
|
276
|
+
}),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
connection,
|
|
283
|
+
description: testConfig.description,
|
|
284
|
+
error: err instanceof Error ? err.message : String(err),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
/**
|
|
289
|
+
* Test an event listener / ingestor's configuration by running a lightweight
|
|
290
|
+
* verification appropriate to its type (auth check, secret check, poll check).
|
|
291
|
+
*/
|
|
292
|
+
async test_ingestor(input, routes, _context) {
|
|
293
|
+
const { connection } = input;
|
|
294
|
+
const route = routes.find((r) => r.alias === connection);
|
|
295
|
+
if (!route) {
|
|
296
|
+
return { success: false, connection, error: `Unknown connection: ${connection}` };
|
|
297
|
+
}
|
|
298
|
+
if (!route.ingestorConfig) {
|
|
299
|
+
return {
|
|
300
|
+
success: false,
|
|
301
|
+
connection,
|
|
302
|
+
supported: false,
|
|
303
|
+
error: 'This connection does not have an event listener.',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// testIngestor is explicitly null = not testable
|
|
307
|
+
if (route.testIngestor === null) {
|
|
308
|
+
return {
|
|
309
|
+
success: false,
|
|
310
|
+
connection,
|
|
311
|
+
supported: false,
|
|
312
|
+
error: 'This event listener does not support testing.',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (!route.testIngestor) {
|
|
316
|
+
return {
|
|
317
|
+
success: false,
|
|
318
|
+
connection,
|
|
319
|
+
supported: false,
|
|
320
|
+
error: 'This event listener does not have a test configuration.',
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const testConfig = route.testIngestor;
|
|
324
|
+
try {
|
|
325
|
+
switch (testConfig.strategy) {
|
|
326
|
+
case 'webhook_verify': {
|
|
327
|
+
// Verify that all required secrets are present and non-empty
|
|
328
|
+
const missing = [];
|
|
329
|
+
for (const secretName of testConfig.requireSecrets ?? []) {
|
|
330
|
+
if (!route.secrets[secretName]) {
|
|
331
|
+
missing.push(secretName);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (missing.length > 0) {
|
|
335
|
+
return {
|
|
336
|
+
success: false,
|
|
337
|
+
connection,
|
|
338
|
+
strategy: testConfig.strategy,
|
|
339
|
+
description: testConfig.description,
|
|
340
|
+
error: `Missing required secrets: ${missing.join(', ')}`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
success: true,
|
|
345
|
+
connection,
|
|
346
|
+
strategy: testConfig.strategy,
|
|
347
|
+
description: testConfig.description,
|
|
348
|
+
message: 'All required webhook secrets are configured.',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
case 'websocket_auth':
|
|
352
|
+
case 'http_request':
|
|
353
|
+
case 'poll_once': {
|
|
354
|
+
// Execute the test HTTP request
|
|
355
|
+
if (!testConfig.request) {
|
|
356
|
+
return {
|
|
357
|
+
success: false,
|
|
358
|
+
connection,
|
|
359
|
+
strategy: testConfig.strategy,
|
|
360
|
+
description: testConfig.description,
|
|
361
|
+
error: 'Test configuration missing request details.',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const method = testConfig.request.method ?? 'GET';
|
|
365
|
+
const expectedStatus = testConfig.request.expectedStatus ?? [200];
|
|
366
|
+
const result = await executeProxyRequest({
|
|
367
|
+
method,
|
|
368
|
+
url: testConfig.request.url,
|
|
369
|
+
headers: testConfig.request.headers,
|
|
370
|
+
body: testConfig.request.body,
|
|
371
|
+
}, routes);
|
|
372
|
+
const isSuccess = expectedStatus.includes(result.status);
|
|
373
|
+
return {
|
|
374
|
+
success: isSuccess,
|
|
375
|
+
connection,
|
|
376
|
+
strategy: testConfig.strategy,
|
|
377
|
+
status: result.status,
|
|
378
|
+
statusText: result.statusText,
|
|
379
|
+
description: testConfig.description,
|
|
380
|
+
...(isSuccess
|
|
381
|
+
? { message: 'Listener test passed.' }
|
|
382
|
+
: { error: `Unexpected status ${result.status}` }),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
default:
|
|
386
|
+
return {
|
|
387
|
+
success: false,
|
|
388
|
+
connection,
|
|
389
|
+
error: `Unknown test strategy: ${String(testConfig.strategy)}`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
return {
|
|
395
|
+
success: false,
|
|
396
|
+
connection,
|
|
397
|
+
strategy: testConfig.strategy,
|
|
398
|
+
description: testConfig.description,
|
|
399
|
+
error: err instanceof Error ? err.message : String(err),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
/**
|
|
404
|
+
* List listener configuration schemas for all connections that have configurable
|
|
405
|
+
* event listeners. Returns the schema fields, current values, and metadata.
|
|
406
|
+
*/
|
|
407
|
+
list_listener_configs(_input, routes, _context) {
|
|
408
|
+
const configs = routes
|
|
409
|
+
.filter((r) => r.listenerConfig)
|
|
410
|
+
.map((r) => ({
|
|
411
|
+
connection: r.alias,
|
|
412
|
+
name: r.listenerConfig.name,
|
|
413
|
+
description: r.listenerConfig.description,
|
|
414
|
+
fields: r.listenerConfig.fields,
|
|
415
|
+
ingestorType: r.ingestorConfig?.type,
|
|
416
|
+
supportsMultiInstance: r.listenerConfig.supportsMultiInstance ?? false,
|
|
417
|
+
instanceKeyField: r.listenerConfig.fields.find((f) => f.instanceKey)?.key,
|
|
418
|
+
}));
|
|
419
|
+
return Promise.resolve(configs);
|
|
420
|
+
},
|
|
421
|
+
/**
|
|
422
|
+
* Resolve dynamic options for a listener configuration field.
|
|
423
|
+
* Fetches options from the external API (e.g., list of Trello boards).
|
|
424
|
+
*/
|
|
425
|
+
async resolve_listener_options(input, routes, _context) {
|
|
426
|
+
const { connection, paramKey } = input;
|
|
427
|
+
const route = routes.find((r) => r.alias === connection);
|
|
428
|
+
if (!route?.listenerConfig) {
|
|
429
|
+
return { success: false, error: `No listener config for connection: ${connection}` };
|
|
430
|
+
}
|
|
431
|
+
const field = route.listenerConfig.fields.find((f) => f.key === paramKey);
|
|
432
|
+
if (!field?.dynamicOptions) {
|
|
433
|
+
return { success: false, error: `No dynamic options for field: ${paramKey}` };
|
|
434
|
+
}
|
|
435
|
+
const { url, method = 'GET', body, responsePath, labelField, valueField, } = field.dynamicOptions;
|
|
436
|
+
try {
|
|
437
|
+
const result = await executeProxyRequest({ method, url, headers: {}, body }, routes);
|
|
438
|
+
// Navigate to the response path to find the items array
|
|
439
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- navigating unknown response shape
|
|
440
|
+
let items = result.body;
|
|
441
|
+
if (responsePath) {
|
|
442
|
+
for (const segment of responsePath.split('.')) {
|
|
443
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
|
444
|
+
items = items?.[segment];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!Array.isArray(items)) {
|
|
448
|
+
return { success: false, error: 'Response did not contain an array at the expected path.' };
|
|
449
|
+
}
|
|
450
|
+
const options = items.map((item) => ({
|
|
451
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
452
|
+
value: item[valueField],
|
|
453
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
454
|
+
label: item[labelField],
|
|
455
|
+
}));
|
|
456
|
+
return { success: true, connection, paramKey, options };
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
return {
|
|
460
|
+
success: false,
|
|
461
|
+
connection,
|
|
462
|
+
paramKey,
|
|
463
|
+
error: err instanceof Error ? err.message : String(err),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
/**
|
|
468
|
+
* Start, stop, or restart an event listener for a specific connection.
|
|
469
|
+
*/
|
|
470
|
+
async control_listener(input, _routes, context) {
|
|
471
|
+
const { connection, action, instance_id } = input;
|
|
472
|
+
const mgr = context.ingestorManager;
|
|
473
|
+
try {
|
|
474
|
+
switch (action) {
|
|
475
|
+
case 'start':
|
|
476
|
+
return await mgr.startOne(context.callerAlias, connection, instance_id);
|
|
477
|
+
case 'stop':
|
|
478
|
+
return await mgr.stopOne(context.callerAlias, connection, instance_id);
|
|
479
|
+
case 'restart':
|
|
480
|
+
return await mgr.restartOne(context.callerAlias, connection, instance_id);
|
|
481
|
+
default:
|
|
482
|
+
return { success: false, error: `Unknown action: ${String(action)}` };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
return {
|
|
487
|
+
success: false,
|
|
488
|
+
connection,
|
|
489
|
+
action,
|
|
490
|
+
error: err instanceof Error ? err.message : String(err),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
/**
|
|
495
|
+
* Read current listener parameter overrides for a connection.
|
|
496
|
+
* Returns current param values and schema defaults for form population.
|
|
497
|
+
*/
|
|
498
|
+
get_listener_params(input, routes, context) {
|
|
499
|
+
const { connection, instance_id } = input;
|
|
500
|
+
// Find the route for this connection
|
|
501
|
+
const route = routes.find((r) => r.alias === connection);
|
|
502
|
+
if (!route) {
|
|
503
|
+
return Promise.resolve({
|
|
504
|
+
success: false,
|
|
505
|
+
connection,
|
|
506
|
+
error: `Unknown connection: ${connection}`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
if (!route.listenerConfig) {
|
|
510
|
+
return Promise.resolve({
|
|
511
|
+
success: false,
|
|
512
|
+
connection,
|
|
513
|
+
error: 'This connection does not have a listener configuration.',
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// Build defaults from schema fields
|
|
517
|
+
const defaults = {};
|
|
518
|
+
for (const field of route.listenerConfig.fields) {
|
|
519
|
+
if (field.default !== undefined) {
|
|
520
|
+
defaults[field.key] = field.default;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// Load config to read current overrides
|
|
524
|
+
const config = loadRemoteConfig();
|
|
525
|
+
const callerConfig = config.callers[context.callerAlias];
|
|
526
|
+
if (!callerConfig) {
|
|
527
|
+
return Promise.resolve({
|
|
528
|
+
success: false,
|
|
529
|
+
connection,
|
|
530
|
+
error: `Caller not found: ${context.callerAlias}`,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
let params = {};
|
|
534
|
+
if (instance_id) {
|
|
535
|
+
// Multi-instance: read from listenerInstances
|
|
536
|
+
const instanceOverrides = callerConfig.listenerInstances?.[connection]?.[instance_id];
|
|
537
|
+
if (!instanceOverrides) {
|
|
538
|
+
return Promise.resolve({
|
|
539
|
+
success: false,
|
|
540
|
+
connection,
|
|
541
|
+
instance_id,
|
|
542
|
+
error: `Instance not found: ${instance_id}`,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
params = instanceOverrides.params ?? {};
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
// Single-instance: read from ingestorOverrides
|
|
549
|
+
const overrides = callerConfig.ingestorOverrides?.[connection];
|
|
550
|
+
params = overrides?.params ?? {};
|
|
551
|
+
}
|
|
552
|
+
// When no instance_id is given on a multi-instance connection, include
|
|
553
|
+
// the list of configured instance IDs so callers can discover them
|
|
554
|
+
// without needing a separate list_listener_instances call.
|
|
555
|
+
let instances;
|
|
556
|
+
if (!instance_id && route.listenerConfig.supportsMultiInstance) {
|
|
557
|
+
const instanceMap = callerConfig.listenerInstances?.[connection] ?? {};
|
|
558
|
+
instances = Object.keys(instanceMap);
|
|
559
|
+
}
|
|
560
|
+
return Promise.resolve({
|
|
561
|
+
success: true,
|
|
562
|
+
connection,
|
|
563
|
+
...(instance_id && { instance_id }),
|
|
564
|
+
params,
|
|
565
|
+
defaults,
|
|
566
|
+
...(instances !== undefined && { instances }),
|
|
567
|
+
});
|
|
568
|
+
},
|
|
569
|
+
/**
|
|
570
|
+
* Add or edit listener parameter overrides for a connection.
|
|
571
|
+
* Merges params into existing config. For multi-instance, set create_instance
|
|
572
|
+
* to true to create a new instance if it doesn't exist.
|
|
573
|
+
* After saving, restarts the affected ingestor so new params take effect immediately.
|
|
574
|
+
*/
|
|
575
|
+
async set_listener_params(input, routes, context) {
|
|
576
|
+
const { connection, instance_id, params, create_instance } = input;
|
|
577
|
+
// Find the route for this connection
|
|
578
|
+
const route = routes.find((r) => r.alias === connection);
|
|
579
|
+
if (!route) {
|
|
580
|
+
return { success: false, connection, error: `Unknown connection: ${connection}` };
|
|
581
|
+
}
|
|
582
|
+
if (!route.listenerConfig) {
|
|
583
|
+
return {
|
|
584
|
+
success: false,
|
|
585
|
+
connection,
|
|
586
|
+
error: 'This connection does not have a listener configuration.',
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
// Validate param keys against schema
|
|
590
|
+
const validKeys = new Set(route.listenerConfig.fields.map((f) => f.key));
|
|
591
|
+
const unknownKeys = Object.keys(params).filter((k) => !validKeys.has(k));
|
|
592
|
+
if (unknownKeys.length > 0) {
|
|
593
|
+
return {
|
|
594
|
+
success: false,
|
|
595
|
+
connection,
|
|
596
|
+
error: `Unknown parameter keys: ${unknownKeys.join(', ')}. Valid keys: ${Array.from(validKeys).join(', ')}`,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
// Load config, modify, save
|
|
600
|
+
const config = loadRemoteConfig();
|
|
601
|
+
const callerConfig = config.callers[context.callerAlias];
|
|
602
|
+
if (!callerConfig) {
|
|
603
|
+
return {
|
|
604
|
+
success: false,
|
|
605
|
+
connection,
|
|
606
|
+
error: `Caller not found: ${context.callerAlias}`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
let mergedParams;
|
|
610
|
+
if (instance_id) {
|
|
611
|
+
// Multi-instance: write to listenerInstances
|
|
612
|
+
callerConfig.listenerInstances ??= {};
|
|
613
|
+
callerConfig.listenerInstances[connection] ??= {};
|
|
614
|
+
const existing = callerConfig.listenerInstances[connection][instance_id];
|
|
615
|
+
if (!existing && !create_instance) {
|
|
616
|
+
return {
|
|
617
|
+
success: false,
|
|
618
|
+
connection,
|
|
619
|
+
instance_id,
|
|
620
|
+
error: `Instance "${instance_id}" does not exist. Set create_instance to true to create it.`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
if (existing) {
|
|
624
|
+
existing.params = { ...(existing.params ?? {}), ...params };
|
|
625
|
+
mergedParams = existing.params;
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
callerConfig.listenerInstances[connection][instance_id] = { params };
|
|
629
|
+
mergedParams = params;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
// Single-instance: write to ingestorOverrides
|
|
634
|
+
callerConfig.ingestorOverrides ??= {};
|
|
635
|
+
callerConfig.ingestorOverrides[connection] ??= {};
|
|
636
|
+
const overrides = callerConfig.ingestorOverrides[connection];
|
|
637
|
+
overrides.params = { ...(overrides.params ?? {}), ...params };
|
|
638
|
+
mergedParams = overrides.params;
|
|
639
|
+
}
|
|
640
|
+
saveRemoteConfig(config);
|
|
641
|
+
// Restart the affected ingestor so new params take effect immediately.
|
|
642
|
+
// This matches callboard's local-proxy behavior (which calls reinitialize()).
|
|
643
|
+
const mgr = context.ingestorManager;
|
|
644
|
+
if (mgr.has(context.callerAlias, connection, instance_id)) {
|
|
645
|
+
try {
|
|
646
|
+
await mgr.restartOne(context.callerAlias, connection, instance_id);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
// Config was saved successfully — log the restart failure but don't fail the operation
|
|
650
|
+
console.error(`[remote] Warning: params saved but failed to restart ingestor ${context.callerAlias}:${connection}${instance_id ? `:${instance_id}` : ''}:`, err);
|
|
651
|
+
return {
|
|
652
|
+
success: true,
|
|
653
|
+
connection,
|
|
654
|
+
...(instance_id && { instance_id }),
|
|
655
|
+
params: mergedParams,
|
|
656
|
+
warning: 'Params saved but ingestor restart failed. Use control_listener to restart manually.',
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
success: true,
|
|
662
|
+
connection,
|
|
663
|
+
...(instance_id && { instance_id }),
|
|
664
|
+
params: mergedParams,
|
|
665
|
+
};
|
|
666
|
+
},
|
|
667
|
+
/**
|
|
668
|
+
* List all configured listener instances for a multi-instance connection.
|
|
669
|
+
* Returns every instance from config (including stopped/disabled ones),
|
|
670
|
+
* unlike ingestor_status which only shows running instances.
|
|
671
|
+
*/
|
|
672
|
+
list_listener_instances(input, routes, context) {
|
|
673
|
+
const { connection } = input;
|
|
674
|
+
// Find the route for this connection
|
|
675
|
+
const route = routes.find((r) => r.alias === connection);
|
|
676
|
+
if (!route) {
|
|
677
|
+
return Promise.resolve({
|
|
678
|
+
success: false,
|
|
679
|
+
connection,
|
|
680
|
+
error: `Unknown connection: ${connection}`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
if (!route.listenerConfig?.supportsMultiInstance) {
|
|
684
|
+
return Promise.resolve({
|
|
685
|
+
success: false,
|
|
686
|
+
connection,
|
|
687
|
+
error: 'This connection does not support multi-instance listeners.',
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
// Read from config
|
|
691
|
+
const config = loadRemoteConfig();
|
|
692
|
+
const callerConfig = config.callers[context.callerAlias];
|
|
693
|
+
if (!callerConfig) {
|
|
694
|
+
return Promise.resolve({
|
|
695
|
+
success: false,
|
|
696
|
+
connection,
|
|
697
|
+
error: `Caller not found: ${context.callerAlias}`,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
const instanceMap = callerConfig.listenerInstances?.[connection] ?? {};
|
|
701
|
+
const instances = Object.entries(instanceMap).map(([instanceId, overrides]) => ({
|
|
702
|
+
instanceId,
|
|
703
|
+
disabled: overrides.disabled ?? false,
|
|
704
|
+
params: overrides.params ?? {},
|
|
705
|
+
}));
|
|
706
|
+
return Promise.resolve({
|
|
707
|
+
success: true,
|
|
708
|
+
connection,
|
|
709
|
+
instances,
|
|
710
|
+
});
|
|
711
|
+
},
|
|
712
|
+
/**
|
|
713
|
+
* Delete a multi-instance listener instance.
|
|
714
|
+
* Removes from config and stops the running ingestor if active.
|
|
715
|
+
*/
|
|
716
|
+
async delete_listener_instance(input, _routes, context) {
|
|
717
|
+
const { connection, instance_id } = input;
|
|
718
|
+
// Load config
|
|
719
|
+
const config = loadRemoteConfig();
|
|
720
|
+
const callerConfig = config.callers[context.callerAlias];
|
|
721
|
+
if (!callerConfig) {
|
|
722
|
+
return {
|
|
723
|
+
success: false,
|
|
724
|
+
connection,
|
|
725
|
+
instance_id,
|
|
726
|
+
error: `Caller not found: ${context.callerAlias}`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
const instances = callerConfig.listenerInstances?.[connection];
|
|
730
|
+
if (!instances || !(instance_id in instances)) {
|
|
731
|
+
return {
|
|
732
|
+
success: false,
|
|
733
|
+
connection,
|
|
734
|
+
instance_id,
|
|
735
|
+
error: `Instance "${instance_id}" not found for connection "${connection}".`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
// Stop the running ingestor if active
|
|
739
|
+
const mgr = context.ingestorManager;
|
|
740
|
+
if (mgr.has(context.callerAlias, connection, instance_id)) {
|
|
741
|
+
try {
|
|
742
|
+
await mgr.stopOne(context.callerAlias, connection, instance_id, { permanent: true });
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
// Log but don't fail the delete
|
|
746
|
+
console.error(`[remote] Warning: failed to stop ingestor ${context.callerAlias}:${connection}:${instance_id}:`, err);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Remove from config
|
|
750
|
+
const { [instance_id]: _removed, ...remainingInstances } = instances;
|
|
751
|
+
// Clean up empty maps
|
|
752
|
+
if (Object.keys(remainingInstances).length === 0) {
|
|
753
|
+
if (callerConfig.listenerInstances) {
|
|
754
|
+
const { [connection]: _removedConn, ...remainingConns } = callerConfig.listenerInstances;
|
|
755
|
+
if (Object.keys(remainingConns).length === 0) {
|
|
756
|
+
delete callerConfig.listenerInstances;
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
callerConfig.listenerInstances = remainingConns;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
callerConfig.listenerInstances[connection] = remainingInstances;
|
|
765
|
+
}
|
|
766
|
+
saveRemoteConfig(config);
|
|
767
|
+
return { success: true, connection, instance_id };
|
|
768
|
+
},
|
|
769
|
+
// ── Config management tools ─────────────────────────────────────────────
|
|
770
|
+
/**
|
|
771
|
+
* List all available connection templates with caller-specific status.
|
|
772
|
+
* Returns template metadata, which ones the caller has enabled,
|
|
773
|
+
* and which secrets are configured.
|
|
774
|
+
*/
|
|
775
|
+
list_connection_templates: (_input, _routes, context) => {
|
|
776
|
+
const config = loadRemoteConfig();
|
|
777
|
+
const caller = config.callers[context.callerAlias];
|
|
778
|
+
const enabledSet = new Set(caller.connections);
|
|
779
|
+
const templates = listConnectionTemplates();
|
|
780
|
+
return templates.map((t) => {
|
|
781
|
+
const callerEnv = caller.env;
|
|
782
|
+
const requiredSecretsSet = {};
|
|
783
|
+
for (const s of t.requiredSecrets) {
|
|
784
|
+
requiredSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
|
|
785
|
+
}
|
|
786
|
+
const optionalSecretsSet = {};
|
|
787
|
+
for (const s of t.optionalSecrets) {
|
|
788
|
+
optionalSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
alias: t.alias,
|
|
792
|
+
name: t.name,
|
|
793
|
+
...(t.description !== undefined && { description: t.description }),
|
|
794
|
+
...(t.docsUrl !== undefined && { docsUrl: t.docsUrl }),
|
|
795
|
+
...(t.openApiUrl !== undefined && { openApiUrl: t.openApiUrl }),
|
|
796
|
+
stability: t.stability,
|
|
797
|
+
category: t.category,
|
|
798
|
+
requiredSecrets: t.requiredSecrets,
|
|
799
|
+
optionalSecrets: t.optionalSecrets,
|
|
800
|
+
hasIngestor: t.hasIngestor,
|
|
801
|
+
...(t.ingestorType !== undefined && { ingestorType: t.ingestorType }),
|
|
802
|
+
allowedEndpoints: t.allowedEndpoints,
|
|
803
|
+
enabled: enabledSet.has(t.alias),
|
|
804
|
+
requiredSecretsSet,
|
|
805
|
+
optionalSecretsSet,
|
|
806
|
+
};
|
|
807
|
+
});
|
|
808
|
+
},
|
|
809
|
+
/**
|
|
810
|
+
* Enable or disable a connection for the authenticated caller.
|
|
811
|
+
*/
|
|
812
|
+
set_connection_enabled: async (input, _routes, context) => {
|
|
813
|
+
const connection = input.connection;
|
|
814
|
+
const enabled = input.enabled;
|
|
815
|
+
if (!connection || typeof enabled !== 'boolean') {
|
|
816
|
+
throw new Error('Required: connection (string) and enabled (boolean)');
|
|
817
|
+
}
|
|
818
|
+
const config = loadRemoteConfig();
|
|
819
|
+
const caller = config.callers[context.callerAlias];
|
|
820
|
+
// Verify the connection template exists (built-in or custom connector)
|
|
821
|
+
const connectorAliases = new Set((config.connectors ?? []).map((c) => c.alias).filter(Boolean));
|
|
822
|
+
const templateAliases = new Set(listConnectionTemplates().map((t) => t.alias));
|
|
823
|
+
if (!connectorAliases.has(connection) && !templateAliases.has(connection)) {
|
|
824
|
+
throw new Error(`Unknown connection: ${connection}`);
|
|
825
|
+
}
|
|
826
|
+
const connectionSet = new Set(caller.connections);
|
|
827
|
+
if (enabled) {
|
|
828
|
+
connectionSet.add(connection);
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
connectionSet.delete(connection);
|
|
832
|
+
// Stop any running ingestors for this connection
|
|
833
|
+
const ingestorManager = context.ingestorManager;
|
|
834
|
+
try {
|
|
835
|
+
await ingestorManager.stopOne(context.callerAlias, connection);
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
// Ingestor may not be running — that's fine
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
caller.connections = [...connectionSet];
|
|
842
|
+
saveRemoteConfig(config);
|
|
843
|
+
// Invalidate cached resolved routes so connection changes take effect immediately
|
|
844
|
+
context.refreshRoutes();
|
|
845
|
+
return { success: true, connection, enabled };
|
|
846
|
+
},
|
|
847
|
+
/**
|
|
848
|
+
* Set or delete secrets for the authenticated caller.
|
|
849
|
+
* Uses prefixed env vars to prevent cross-caller collisions.
|
|
850
|
+
*/
|
|
851
|
+
set_secrets: (input, _routes, context) => {
|
|
852
|
+
const secrets = input.secrets;
|
|
853
|
+
if (!secrets || typeof secrets !== 'object') {
|
|
854
|
+
throw new Error('Required: secrets (Record<string, string>)');
|
|
855
|
+
}
|
|
856
|
+
const config = loadRemoteConfig();
|
|
857
|
+
const { config: updatedConfig, status } = setCallerSecrets(secrets, context.callerAlias, config);
|
|
858
|
+
saveRemoteConfig(updatedConfig);
|
|
859
|
+
// Invalidate cached resolved routes so new secrets take effect immediately
|
|
860
|
+
context.refreshRoutes();
|
|
861
|
+
return { success: true, secretsSet: status };
|
|
862
|
+
},
|
|
863
|
+
/**
|
|
864
|
+
* Check which secrets are set for the authenticated caller (never returns values).
|
|
865
|
+
*/
|
|
866
|
+
get_secret_status: (input, _routes, context) => {
|
|
867
|
+
const connection = input.connection;
|
|
868
|
+
if (!connection) {
|
|
869
|
+
throw new Error('Required: connection (string)');
|
|
870
|
+
}
|
|
871
|
+
// Find the connection template
|
|
872
|
+
const templates = listConnectionTemplates();
|
|
873
|
+
const template = templates.find((t) => t.alias === connection);
|
|
874
|
+
if (!template) {
|
|
875
|
+
throw new Error(`Unknown connection: ${connection}`);
|
|
876
|
+
}
|
|
877
|
+
const config = loadRemoteConfig();
|
|
878
|
+
const caller = config.callers[context.callerAlias];
|
|
879
|
+
const callerEnv = caller.env;
|
|
880
|
+
const requiredSecretsSet = {};
|
|
881
|
+
for (const s of template.requiredSecrets) {
|
|
882
|
+
requiredSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
|
|
883
|
+
}
|
|
884
|
+
const optionalSecretsSet = {};
|
|
885
|
+
for (const s of template.optionalSecrets) {
|
|
886
|
+
optionalSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
success: true,
|
|
890
|
+
connection,
|
|
891
|
+
requiredSecretsSet,
|
|
892
|
+
optionalSecretsSet,
|
|
893
|
+
};
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
/**
|
|
897
|
+
* Dispatch a single tool call by name. Throws on unknown tool.
|
|
898
|
+
*
|
|
899
|
+
* The canonical entry point for in-process hosts (admin API, callboard) that
|
|
900
|
+
* want to invoke a tool without going through the encrypted `/request` path.
|
|
901
|
+
*/
|
|
902
|
+
export async function dispatchTool(toolName, input, routes, context) {
|
|
903
|
+
const handler = toolHandlers[toolName];
|
|
904
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime validation for untrusted input
|
|
905
|
+
if (!handler) {
|
|
906
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
907
|
+
}
|
|
908
|
+
return handler(input, routes, context);
|
|
909
|
+
}
|
|
910
|
+
//# sourceMappingURL=tool-dispatch.js.map
|