@0xmaxma/claude-gateway 1.8.7 → 1.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/dist/agent/runner.d.ts +47 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +116 -17
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/connectors-router.d.ts +50 -0
- package/dist/api/connectors-router.d.ts.map +1 -0
- package/dist/api/connectors-router.js +650 -0
- package/dist/api/connectors-router.js.map +1 -0
- package/dist/api/gateway-router.d.ts +4 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +26 -0
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/oauth-connectors-router.d.ts +43 -0
- package/dist/api/oauth-connectors-router.d.ts.map +1 -0
- package/dist/api/oauth-connectors-router.js +384 -0
- package/dist/api/oauth-connectors-router.js.map +1 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +101 -13
- package/dist/api/router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts +6 -2
- package/dist/apps/agent-manager.d.ts.map +1 -1
- package/dist/apps/agent-manager.js +10 -18
- package/dist/apps/agent-manager.js.map +1 -1
- package/dist/config/config-write-lock.d.ts +45 -0
- package/dist/config/config-write-lock.d.ts.map +1 -0
- package/dist/config/config-write-lock.js +164 -0
- package/dist/config/config-write-lock.js.map +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +29 -0
- package/dist/config/watcher.js.map +1 -1
- package/dist/connectors/custom-connectors-store.d.ts +54 -0
- package/dist/connectors/custom-connectors-store.d.ts.map +1 -0
- package/dist/connectors/custom-connectors-store.js +204 -0
- package/dist/connectors/custom-connectors-store.js.map +1 -0
- package/dist/connectors/custom.d.ts +69 -0
- package/dist/connectors/custom.d.ts.map +1 -0
- package/dist/connectors/custom.js +158 -0
- package/dist/connectors/custom.js.map +1 -0
- package/dist/connectors/mcp-oauth.d.ts +152 -0
- package/dist/connectors/mcp-oauth.d.ts.map +1 -0
- package/dist/connectors/mcp-oauth.js +522 -0
- package/dist/connectors/mcp-oauth.js.map +1 -0
- package/dist/connectors/oauth-refresh-sweep.d.ts +71 -0
- package/dist/connectors/oauth-refresh-sweep.d.ts.map +1 -0
- package/dist/connectors/oauth-refresh-sweep.js +337 -0
- package/dist/connectors/oauth-refresh-sweep.js.map +1 -0
- package/dist/connectors/pending-oauth-store.d.ts +39 -0
- package/dist/connectors/pending-oauth-store.d.ts.map +1 -0
- package/dist/connectors/pending-oauth-store.js +50 -0
- package/dist/connectors/pending-oauth-store.js.map +1 -0
- package/dist/connectors/resolve.d.ts +52 -0
- package/dist/connectors/resolve.d.ts.map +1 -0
- package/dist/connectors/resolve.js +176 -0
- package/dist/connectors/resolve.js.map +1 -0
- package/dist/connectors/token-env.d.ts +93 -0
- package/dist/connectors/token-env.d.ts.map +1 -0
- package/dist/connectors/token-env.js +323 -0
- package/dist/connectors/token-env.js.map +1 -0
- package/dist/connectors/types.d.ts +126 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +13 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/index.js +32 -1
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts +23 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +78 -2
- package/dist/session/process.js.map +1 -1
- package/dist/types.d.ts +46 -1
- package/dist/types.d.ts.map +1 -1
- package/mcp/instructions.ts +14 -1
- package/mcp/server.ts +11 -5
- package/mcp/tools/image/module.ts +2 -70
- package/mcp/tools/shared/media.ts +76 -0
- package/mcp/tools/video/module.ts +676 -0
- package/package.json +1 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createConnectorsRouter = createConnectorsRouter;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const auth_1 = require("./auth");
|
|
6
|
+
const resolve_1 = require("../connectors/resolve");
|
|
7
|
+
const token_env_1 = require("../connectors/token-env");
|
|
8
|
+
const custom_1 = require("../connectors/custom");
|
|
9
|
+
const custom_connectors_store_1 = require("../connectors/custom-connectors-store");
|
|
10
|
+
const oauth_refresh_sweep_1 = require("../connectors/oauth-refresh-sweep");
|
|
11
|
+
/**
|
|
12
|
+
* Connector management API. The gateway acts as connector registry + secret manager
|
|
13
|
+
* + config injector: connecting a connector stores its secret in mcp-token.env —
|
|
14
|
+
* that store alone is authoritative for "connected" (see resolve.ts's
|
|
15
|
+
* listConnectorStatus). The actual MCP server is injected into each session by
|
|
16
|
+
* SessionProcess (see resolveEnabledConnectors).
|
|
17
|
+
*
|
|
18
|
+
* Routes (mounted under /api):
|
|
19
|
+
* GET /v1/connectors — every connector, with connected state
|
|
20
|
+
* GET /v1/connectors/:id/status — connected boolean (for polling)
|
|
21
|
+
* POST /v1/connectors/:id/connect — store a secret (admin) into a
|
|
22
|
+
* single-secret customConnectors entry
|
|
23
|
+
* (e.g. reconnecting a paste-token
|
|
24
|
+
* connector after DELETE
|
|
25
|
+
* soft-disconnected it) — see the handler
|
|
26
|
+
* POST /v1/connectors/:id/oauth/receive — store a pushed access_token (admin);
|
|
27
|
+
* always writes credentialOwner 'external'
|
|
28
|
+
* DELETE /v1/connectors/:id — clear a secret (admin); a 'static' or
|
|
29
|
+
* 'gateway' connector keeps its
|
|
30
|
+
* entry (config/label intact, just
|
|
31
|
+
* disconnected), a 'none' or 'external'
|
|
32
|
+
* one is removed outright — see the handler below
|
|
33
|
+
* POST /v1/connectors/custom — add a user-pasted connector (admin)
|
|
34
|
+
*
|
|
35
|
+
* (Removal of a custom connector is NOT a separate route — it's the same
|
|
36
|
+
* DELETE /v1/connectors/:id above. See the note at the bottom of this file for
|
|
37
|
+
* why the old dedicated /custom/:id route was retired.)
|
|
38
|
+
*
|
|
39
|
+
* Externally-owned connectors (Gmail/Drive/Calendar) never do the actual OAuth
|
|
40
|
+
* dance here — an external control plane the deployer runs owns the
|
|
41
|
+
* client_secret, the token exchange, and the refresh loop (this gateway runs
|
|
42
|
+
* inside the user's own VM, reachable by that user's own shell/SSH, so a
|
|
43
|
+
* shared client_secret can't live here safely — see ConnectorCredentialOwner's
|
|
44
|
+
* doc comment). That control plane pushes the resulting short-lived
|
|
45
|
+
* access_token here via /oauth/receive, over the internal network,
|
|
46
|
+
* authenticated the same way any other admin API caller is. A connector this
|
|
47
|
+
* gateway signs in for itself is 'gateway' instead, and lives in
|
|
48
|
+
* oauth-connectors-router.ts.
|
|
49
|
+
*
|
|
50
|
+
* `agents` (all live AgentRunners) is what lets a route that changes a connector's
|
|
51
|
+
* secrets restart the sessions already using it — every route works without it
|
|
52
|
+
* (e.g. in tests), it just leaves running sessions on their stale MCP config
|
|
53
|
+
* until they next respawn on their own.
|
|
54
|
+
*/
|
|
55
|
+
function createConnectorsRouter(apiKeys, configPath, agents, customConnectorsStore) {
|
|
56
|
+
const router = (0, express_1.Router)();
|
|
57
|
+
if (apiKeys?.length)
|
|
58
|
+
router.use((0, auth_1.createApiAuthMiddleware)(apiKeys));
|
|
59
|
+
const store = customConnectorsStore ?? (0, custom_connectors_store_1.createCustomConnectorsStore)(configPath);
|
|
60
|
+
function requireAdmin(req, res) {
|
|
61
|
+
if (!apiKeys?.length)
|
|
62
|
+
return true; // no auth configured — allow
|
|
63
|
+
if (!(0, auth_1.isAdmin)(req.apiKey)) {
|
|
64
|
+
res.status(403).json({ error: 'Connector management requires an admin API key' });
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
// Every `:id` in this router is used as a config.json object key and interpolated
|
|
70
|
+
// into mcp-token.env key names, so it is validated once here rather than in each
|
|
71
|
+
// handler. /oauth/receive is why this has to be a shape check and not a lookup: it
|
|
72
|
+
// legitimately names a connector that does not exist yet, so there is nothing to
|
|
73
|
+
// validate the id against except its own grammar.
|
|
74
|
+
router.param('id', (req, res, next, id) => {
|
|
75
|
+
if (!(0, custom_1.isValidConnectorId)(id)) {
|
|
76
|
+
res.status(400).json({ error: `Invalid connector id '${id}'` });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
next();
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* Restart every live session that resolves `id`, after its secrets or its entry
|
|
83
|
+
* changed.
|
|
84
|
+
*
|
|
85
|
+
* A session's MCP subprocess reads its config once, at spawn (see
|
|
86
|
+
* session/process.ts's writeMcpConfig): connecting a connector while a session is
|
|
87
|
+
* running therefore does nothing visible until that session restarts. Without this
|
|
88
|
+
* a status poller flips to "connected" while the agent the user is talking to still
|
|
89
|
+
* has no such tool, for as long as the session lives.
|
|
90
|
+
*
|
|
91
|
+
* `overlay` carries an entry this route has just written but the config watcher may
|
|
92
|
+
* not have propagated to the runners yet. Never throws — a restart failure must not
|
|
93
|
+
* turn a successful connect into a 500.
|
|
94
|
+
*
|
|
95
|
+
* Called on the delete paths too, after the secrets are gone: "this connector now
|
|
96
|
+
* resolves to nothing" is itself the change each session is compared against (see
|
|
97
|
+
* AgentRunner.restartSessionsUsingConnector). The hard-delete branch passes
|
|
98
|
+
* `{ [id]: null }`, the overlay's way of saying the entry was removed — a
|
|
99
|
+
* `'none'`-owner connector has no secrets whose absence would signal the change on
|
|
100
|
+
* its own, so against the runners' not-yet-refreshed config snapshot it would
|
|
101
|
+
* otherwise still resolve identically and nothing would restart.
|
|
102
|
+
*/
|
|
103
|
+
async function restartSessionsUsing(id, opts) {
|
|
104
|
+
await Promise.all((agents ? [...agents.values()] : []).map((runner) => runner
|
|
105
|
+
.restartSessionsUsingConnector(id, { overlay: opts?.overlay })
|
|
106
|
+
.catch((err) => {
|
|
107
|
+
console.error(`connectors-router: restart for connector=${id} failed: ${err.message}`);
|
|
108
|
+
})));
|
|
109
|
+
}
|
|
110
|
+
// Every connector, with its connected state.
|
|
111
|
+
//
|
|
112
|
+
// Wrapped because this is an `async` handler and the app runs Express 4, which
|
|
113
|
+
// does not catch a rejected handler promise: it escapes to the process-wide
|
|
114
|
+
// `unhandledRejection` hook in index.ts, which runs emergencyShutdown and
|
|
115
|
+
// exits. A read failure inside this one route therefore used to take down every
|
|
116
|
+
// agent and every channel on the box — and since callers poll this route, the
|
|
117
|
+
// restarted gateway got killed again on the next poll. Answering 500 keeps the
|
|
118
|
+
// blast radius to the client that asked.
|
|
119
|
+
router.get('/v1/connectors', async (_req, res) => {
|
|
120
|
+
try {
|
|
121
|
+
res.json({ connectors: (0, resolve_1.listConnectorStatus)(await store.read()) });
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error(`connectors-router: listing connectors failed: ${err.message}`);
|
|
125
|
+
res.status(500).json({ error: 'Connector configuration could not be read' });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// Single connector status (used by the web to poll)
|
|
129
|
+
router.get('/v1/connectors/:id/status', async (req, res) => {
|
|
130
|
+
const custom = (await store.read())[req.params.id];
|
|
131
|
+
if (!custom) {
|
|
132
|
+
res.status(404).json({ error: `Unknown connector '${req.params.id}'` });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// One snapshot for both fields. Read separately, `connected` and `refresh`
|
|
136
|
+
// could come from different versions of mcp-token.env — the sweep rewrites
|
|
137
|
+
// it whole — and report a connector as connected with no refresh trouble
|
|
138
|
+
// when in fact the sweep had just cleared its credentials between the reads.
|
|
139
|
+
//
|
|
140
|
+
// Wrapped for the same reason listConnectorStatus wraps each entry:
|
|
141
|
+
// `secretNames` is typed but never validated at read time, and this is an
|
|
142
|
+
// `async` handler on Express 4, which does not catch rejections — an entry
|
|
143
|
+
// missing it would leave the poller's request hanging with no response
|
|
144
|
+
// rather than answering "not connected".
|
|
145
|
+
try {
|
|
146
|
+
const tokenEnv = (0, token_env_1.readTokenEnv)();
|
|
147
|
+
const connected = custom.secretNames.every((name) => !!tokenEnv[(0, custom_1.customSecretKey)(req.params.id, name)]);
|
|
148
|
+
// Same caveat the list endpoint carries: a transiently-failing refresh
|
|
149
|
+
// leaves the (possibly expired) access_token in place, so `connected`
|
|
150
|
+
// alone would keep polling clients green over a dead connector.
|
|
151
|
+
// 'gateway' only, for the reason listConnectorStatus gives: this gateway
|
|
152
|
+
// holds a refresh_token for no other owner, so the sweep never touches
|
|
153
|
+
// them and every counter would read a constant 0.
|
|
154
|
+
const refresh = custom.credentialOwner === 'gateway' ? (0, resolve_1.refreshStatusOf)(req.params.id, tokenEnv) : undefined;
|
|
155
|
+
res.json({ id: req.params.id, connected, ...(refresh ? { refresh } : {}) });
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
console.error(`connectors-router: status for connector=${req.params.id} failed: ${err.message}`);
|
|
159
|
+
res.status(500).json({ error: `Connector '${req.params.id}' has an unreadable configuration` });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
// Connect — store the secret into a customConnectors entry with exactly one
|
|
163
|
+
// secret name. That is what makes reconnecting a paste-token custom connector
|
|
164
|
+
// (e.g. Stripe) actually work after DELETE soft-disconnects it — the entry
|
|
165
|
+
// survives disconnect, so it must be reconnectable here, not only via
|
|
166
|
+
// re-adding it from scratch through POST /v1/connectors/custom.
|
|
167
|
+
router.post('/v1/connectors/:id/connect', async (req, res) => {
|
|
168
|
+
if (!requireAdmin(req, res))
|
|
169
|
+
return;
|
|
170
|
+
const id = req.params.id;
|
|
171
|
+
// Body validation happens inside the locked callback below, in the position it
|
|
172
|
+
// always had — after the entry checks, so an unknown id still answers 404 rather
|
|
173
|
+
// than "token is required". It is pure CPU, so it costs the lock nothing.
|
|
174
|
+
const token = req.body?.token;
|
|
175
|
+
// Everything below, validation included, sits inside the try. `withEntry`
|
|
176
|
+
// does no shape validation — `secretNames` is required by the TypeScript type
|
|
177
|
+
// and by nothing at runtime — so an entry hand-written into config.json
|
|
178
|
+
// without it turns the checks below into a TypeError, and on Express 4 a
|
|
179
|
+
// rejected async handler escapes to index.ts's `unhandledRejection` hook,
|
|
180
|
+
// which calls emergencyShutdown(). The read paths were already hardened for
|
|
181
|
+
// exactly this (see token-env.ts and custom-connectors-store.ts); a
|
|
182
|
+
// malformed entry must cost the caller a 500, not every agent on the box.
|
|
183
|
+
try {
|
|
184
|
+
// `withEntry`, not `read()`: the entry decides both whether a pasted token
|
|
185
|
+
// belongs here and WHICH secret name it is filed under, and /oauth/receive
|
|
186
|
+
// rewrites exactly those two fields on an existing id. Read outside the lock,
|
|
187
|
+
// a push landing in between left this route storing the token under a name
|
|
188
|
+
// the entry no longer declares — nothing ever reads it back, and no route
|
|
189
|
+
// clears it either, since every delete path enumerates the CURRENT
|
|
190
|
+
// secretNames. Deciding inside the lock makes that ordering impossible.
|
|
191
|
+
const outcome = await store.withEntry(id, ({ entry }) => {
|
|
192
|
+
if (!entry)
|
|
193
|
+
return { status: 404, error: `Unknown connector '${id}'` };
|
|
194
|
+
// Only a 'static' connector has a credential a human is supposed to paste.
|
|
195
|
+
// The other two owners each have their own way in, and naming it is the whole
|
|
196
|
+
// value of this branch — a bare "not allowed here" leaves the caller guessing.
|
|
197
|
+
if (entry.credentialOwner === 'gateway' || entry.credentialOwner === 'external') {
|
|
198
|
+
return {
|
|
199
|
+
status: 400,
|
|
200
|
+
error: entry.credentialOwner === 'external'
|
|
201
|
+
? `Connector '${id}' has its credential owned externally — its token is pushed via POST /v1/connectors/${id}/oauth/receive, not set here.`
|
|
202
|
+
: `Connector '${id}' uses OAuth sign-in — start it via POST /v1/connectors/custom/${id}/oauth/start instead`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (entry.secretNames.length !== 1) {
|
|
206
|
+
return {
|
|
207
|
+
status: 400,
|
|
208
|
+
error: entry.secretNames.length === 0
|
|
209
|
+
? `Connector '${id}' has no secrets to set — nothing to connect here.`
|
|
210
|
+
: `Connector '${id}' needs ${entry.secretNames.length} secrets (${entry.secretNames.join(', ')}) — this route only accepts a single value. Remove and re-add it with every value via POST /v1/connectors/custom.`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (typeof token !== 'string' || !token.trim()) {
|
|
214
|
+
return { status: 400, error: 'token is required and must be a non-empty string' };
|
|
215
|
+
}
|
|
216
|
+
(0, token_env_1.setSecret)((0, custom_1.customSecretKey)(id, entry.secretNames[0]), token.trim());
|
|
217
|
+
return { status: 200, entry };
|
|
218
|
+
});
|
|
219
|
+
if (outcome.status !== 200) {
|
|
220
|
+
res.status(outcome.status).json({ error: outcome.error });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Outside the lock: restarting sessions is slow, needs nothing from the
|
|
224
|
+
// entry beyond the copy taken above, and a runner that rewrites config.json
|
|
225
|
+
// while we still held it would deadlock (see withEntry's doc).
|
|
226
|
+
await restartSessionsUsing(id, { overlay: { [id]: outcome.entry } });
|
|
227
|
+
res.json({ id, connected: true });
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
res.status(500).json({ error: err.message });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
// Receive a fresh access_token + full connector shape pushed by an external
|
|
234
|
+
// control plane that owns the sign-in for this connector (github/gmail/
|
|
235
|
+
// google-drive/google-calendar, say) — this is how those connect (and stay
|
|
236
|
+
// fresh on refresh) now. Admin-gated exactly like /connect; the caller is that
|
|
237
|
+
// control plane itself, reaching this over the internal network with the
|
|
238
|
+
// same admin API key any other admin caller would use.
|
|
239
|
+
//
|
|
240
|
+
// The entry is written into gateway.customConnectors — the same storage a
|
|
241
|
+
// user-pasted connector uses — with `credentialOwner: 'external'` recording
|
|
242
|
+
// that the credential is that control plane's to renew, not this gateway's
|
|
243
|
+
// (see ConnectorCredentialOwner). `secretNames` is never trusted from the
|
|
244
|
+
// request body — derived from `config` via extractPlaceholders, the same
|
|
245
|
+
// helper /custom's add route uses, and required to be exactly
|
|
246
|
+
// ['access_token'] (this route only ever manages one pushed secret).
|
|
247
|
+
router.post('/v1/connectors/:id/oauth/receive', async (req, res) => {
|
|
248
|
+
if (!requireAdmin(req, res))
|
|
249
|
+
return;
|
|
250
|
+
const id = req.params.id;
|
|
251
|
+
// The one route that takes a connector id verbatim rather than minting it
|
|
252
|
+
// through slugify(), so it is also the only one that can name a server the
|
|
253
|
+
// session writer generates itself — which would silently drop the entry at
|
|
254
|
+
// injection time while every status surface still reported "Connected ✓".
|
|
255
|
+
if ((0, custom_1.isReservedConnectorId)(id)) {
|
|
256
|
+
res.status(400).json({
|
|
257
|
+
error: `Connector id '${id}' is reserved by the gateway's own MCP servers`,
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const body = req.body;
|
|
262
|
+
const accessToken = body?.access_token;
|
|
263
|
+
if (typeof accessToken !== 'string' || !accessToken.trim()) {
|
|
264
|
+
res.status(400).json({ error: 'access_token is required and must be a non-empty string' });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (typeof body.label !== 'string' || !body.label.trim()) {
|
|
268
|
+
res.status(400).json({ error: 'label is required and must be a non-empty string' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (typeof body.config !== 'object' || body.config === null || Array.isArray(body.config)) {
|
|
272
|
+
res.status(400).json({ error: 'config is required and must be a JSON object' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (body.description !== undefined && typeof body.description !== 'string') {
|
|
276
|
+
res.status(400).json({ error: 'description must be a string' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (body.sourceUrl !== undefined && typeof body.sourceUrl !== 'string') {
|
|
280
|
+
res.status(400).json({ error: 'sourceUrl must be a string' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const placeholders = (0, custom_1.extractPlaceholders)(body.config);
|
|
284
|
+
if (placeholders.length !== 1 || placeholders[0] !== 'access_token') {
|
|
285
|
+
res.status(400).json({
|
|
286
|
+
error: "config must contain exactly one {access_token} placeholder and no others",
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const entry = {
|
|
291
|
+
label: body.label.trim(),
|
|
292
|
+
description: typeof body.description === 'string' ? body.description : undefined,
|
|
293
|
+
config: body.config,
|
|
294
|
+
secretNames: ['access_token'],
|
|
295
|
+
sourceUrl: typeof body.sourceUrl === 'string' && body.sourceUrl.trim()
|
|
296
|
+
? body.sourceUrl.trim()
|
|
297
|
+
: undefined,
|
|
298
|
+
credentialOwner: 'external',
|
|
299
|
+
};
|
|
300
|
+
// This route always writes credentialOwner 'external', so pushing onto an id
|
|
301
|
+
// that was 'gateway'-owned is a handover: from here on nothing refreshes it —
|
|
302
|
+
// the sweep only ever acts on 'gateway' entries — and DELETE only clears the
|
|
303
|
+
// sweep's internal keys under that same `credentialOwner === 'gateway'` guard,
|
|
304
|
+
// which no longer holds. The refresh_token, cached DCR client_id and failure
|
|
305
|
+
// bookkeeping left behind would therefore outlive every path that could remove
|
|
306
|
+
// them, sitting in mcp-token.env as a live credential belonging to a grant the
|
|
307
|
+
// gateway has stopped managing. Cleared here, in the same write that stores the
|
|
308
|
+
// pushed token, so the handover is atomic rather than a two-step that can be
|
|
309
|
+
// interrupted between halves.
|
|
310
|
+
//
|
|
311
|
+
// The same argument applies to the previous entry's OWN secrets, under names
|
|
312
|
+
// this route does not reuse: it overwrites `secretNames` with ['access_token'],
|
|
313
|
+
// and every path that could remove a custom secret enumerates the CURRENT
|
|
314
|
+
// secretNames — DELETE maps over them, /connect is closed to 'external'. A
|
|
315
|
+
// 'static' connector's pasted `{api_key}` would sit in the 0600 store as a live
|
|
316
|
+
// third-party key with no supported operation left that clears it.
|
|
317
|
+
// Captured INSIDE store.mutate, not from a `read()` before it. The two are
|
|
318
|
+
// the same value only if nothing writes config.json in between, and the
|
|
319
|
+
// whole reason `mutate` exists is that things do: an unlocked read here
|
|
320
|
+
// races the entry this very route is about to overwrite. Lose that race —
|
|
321
|
+
// an admin completes a browser sign-in on this id, or /connect stores a
|
|
322
|
+
// pasted token, between the read and the locked write — and `previousOwner`
|
|
323
|
+
// says 'static' for an entry that is now 'gateway'-owned, so the
|
|
324
|
+
// internalSecretKeysOf() removal below is skipped and the fresh
|
|
325
|
+
// refresh_token survives a handover to 'external'. Nothing collects it
|
|
326
|
+
// afterwards: the sweep ignores non-'gateway' entries and DELETE clears
|
|
327
|
+
// internal keys only under that same guard, leaving a live credential in
|
|
328
|
+
// mcp-token.env for a grant this gateway no longer manages — precisely the
|
|
329
|
+
// orphan the comment above exists to prevent. `mutate` reads the file under
|
|
330
|
+
// the write lock, so the entry it hands us is the one being replaced.
|
|
331
|
+
//
|
|
332
|
+
// Left at their defaults when there is no persistence target (tests), where
|
|
333
|
+
// `mutate` returns before invoking this callback — same as the `read()` that
|
|
334
|
+
// returned {} there.
|
|
335
|
+
let previousOwner;
|
|
336
|
+
let staleSecretKeys = [];
|
|
337
|
+
try {
|
|
338
|
+
// config.json first, secrets second — matching the /custom add route below.
|
|
339
|
+
// If the second write fails, `listConnectorStatus` finds no secret and reports
|
|
340
|
+
// the row disconnected, which is honest and re-pushable. The other order
|
|
341
|
+
// fails to an orphaned secret under an id that DELETE 404s on, so nothing
|
|
342
|
+
// can ever clear it.
|
|
343
|
+
await store.mutate((c) => {
|
|
344
|
+
const previous = c[id];
|
|
345
|
+
previousOwner = previous?.credentialOwner;
|
|
346
|
+
staleSecretKeys = (previous?.secretNames ?? [])
|
|
347
|
+
.filter((name) => !entry.secretNames.includes(name))
|
|
348
|
+
.map((name) => (0, custom_1.customSecretKey)(id, name));
|
|
349
|
+
c[id] = entry;
|
|
350
|
+
});
|
|
351
|
+
(0, token_env_1.updateSecrets)({ [(0, custom_1.customSecretKey)(id, 'access_token')]: accessToken.trim() }, [
|
|
352
|
+
...staleSecretKeys,
|
|
353
|
+
...(previousOwner === 'gateway' ? (0, oauth_refresh_sweep_1.internalSecretKeysOf)(id) : []),
|
|
354
|
+
]);
|
|
355
|
+
// A live session already resolved this connector with the stale (or
|
|
356
|
+
// absent) token baked into its MCP subprocess's env — restart it so the
|
|
357
|
+
// next spawn picks up the fresh one (session/process.ts resolves
|
|
358
|
+
// connector secrets fresh on every spawn, but a running subprocess
|
|
359
|
+
// can't be hot-patched). The entry is passed as an overlay because the
|
|
360
|
+
// runners' own view of config.json is refreshed by a file watcher that
|
|
361
|
+
// has almost certainly not fired yet for the write just above — on a
|
|
362
|
+
// first push, without it, no runner would consider itself a user of a
|
|
363
|
+
// connector it is about to have.
|
|
364
|
+
await restartSessionsUsing(id, { overlay: { [id]: entry } });
|
|
365
|
+
res.json({ id, connected: true });
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
res.status(500).json({ error: err.message });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
// Disconnect — clear the secret + wiring. Checks admin before "does the id
|
|
372
|
+
// exist" (this route used to do it the other way round, which leaked whether
|
|
373
|
+
// an id exists to a non-admin caller).
|
|
374
|
+
router.delete('/v1/connectors/:id', async (req, res) => {
|
|
375
|
+
if (!requireAdmin(req, res))
|
|
376
|
+
return;
|
|
377
|
+
const id = req.params.id;
|
|
378
|
+
try {
|
|
379
|
+
// `withEntry`, not `read()`. Every decision this route makes comes off the
|
|
380
|
+
// entry — `credentialOwner` picks soft-disconnect vs hard-delete AND whether
|
|
381
|
+
// the sweep's internal keys are cleared, `secretNames` picks which of its own
|
|
382
|
+
// secrets go — and /oauth/receive rewrites all three of those fields on an
|
|
383
|
+
// existing id in a single locked write. Read outside the lock, a push landing
|
|
384
|
+
// in between left this route clearing secrets under names the entry no longer
|
|
385
|
+
// declares, skipping the internal-key removal for an owner that had just
|
|
386
|
+
// stopped being 'gateway' (or performing it for one that had just become
|
|
387
|
+
// 'external'), and keeping an entry whose new owner says remove it. Every one
|
|
388
|
+
// of those leaves a live credential in mcp-token.env that no later route can
|
|
389
|
+
// reach. Deciding inside the lock is what /oauth/receive itself was fixed to
|
|
390
|
+
// do; this is the other side of the same race.
|
|
391
|
+
const outcome = await store.withEntry(id, ({ entry, remove }) => {
|
|
392
|
+
if (!entry)
|
|
393
|
+
return { found: false };
|
|
394
|
+
// Which of the entry's own secrets Disconnect clears depends on which of
|
|
395
|
+
// them a reconnect can put back.
|
|
396
|
+
//
|
|
397
|
+
// 'gateway': only `access_token` is the gateway's to mint — the OAuth
|
|
398
|
+
// callback writes that one key and nothing else from `secretNames` (see
|
|
399
|
+
// oauth-connectors-router.ts's updateSecrets call). Any OTHER placeholder
|
|
400
|
+
// on a `oauth: true` connector — a `{workspace_id}`, a `{team_domain}` —
|
|
401
|
+
// came from the `secrets` map pasted into POST /v1/connectors/custom, and
|
|
402
|
+
// no route can restore it: /connect is closed to 'gateway' owners and the
|
|
403
|
+
// add route mints a NEW id via slugify(). Clearing them all therefore made
|
|
404
|
+
// one Disconnect permanent for any multi-placeholder OAuth connector —
|
|
405
|
+
// sign-in succeeded, `secretNames.every(hasSecret)` stayed false because of
|
|
406
|
+
// the placeholder nothing had refilled, and the row read "Not connected"
|
|
407
|
+
// forever with no recovery short of hand-editing config.json. Only the
|
|
408
|
+
// credential the user asked to revoke is cleared; the rest is inert
|
|
409
|
+
// configuration that costs nothing to keep and is the difference between
|
|
410
|
+
// "reconnect is one sign-in" and "remove and re-add from scratch".
|
|
411
|
+
//
|
|
412
|
+
// Every other owner: `secretNames` is exactly what a reconnect re-supplies
|
|
413
|
+
// (one /connect paste for 'static', one /oauth/receive push for
|
|
414
|
+
// 'external', nothing at all for 'none'), so all of it goes.
|
|
415
|
+
const toDelete = entry.credentialOwner === 'gateway'
|
|
416
|
+
? [(0, custom_1.customSecretKey)(id, 'access_token')]
|
|
417
|
+
: entry.secretNames.map((name) => (0, custom_1.customSecretKey)(id, name));
|
|
418
|
+
// A 'gateway'-owned entry's refresh_token/client_id/expiry (and any
|
|
419
|
+
// recorded failure backoff, generation counter or cached DCR
|
|
420
|
+
// registration) live outside secretNames — they're sweep-internal
|
|
421
|
+
// bookkeeping, not a {placeholder} from the pasted config (see
|
|
422
|
+
// oauth-refresh-sweep.ts's storage note), so the names above never cover
|
|
423
|
+
// them. Left alone, the still-valid refresh_token would let
|
|
424
|
+
// refreshExpiringOAuthConnectors silently mint a fresh access_token and
|
|
425
|
+
// resurrect a connector the user just disconnected.
|
|
426
|
+
//
|
|
427
|
+
// Enumerated by `internalSecretKeysOf` rather than listed here, because
|
|
428
|
+
// this list drifted: `__dcr_client_id` and `__client_redirect_uri` were
|
|
429
|
+
// added to the OAuth start path and never added to any delete path, so a
|
|
430
|
+
// provider-side registration that had been deleted stayed cached forever.
|
|
431
|
+
// Disconnect-and-reconnect — the one recovery a user can perform from the
|
|
432
|
+
// UI — read the dead client back out, saw its redirect_uri still matched,
|
|
433
|
+
// skipped re-registration, and failed again every time.
|
|
434
|
+
if (entry.credentialOwner === 'gateway')
|
|
435
|
+
toDelete.push(...(0, oauth_refresh_sweep_1.internalSecretKeysOf)(id));
|
|
436
|
+
(0, token_env_1.deleteSecrets)(toDelete);
|
|
437
|
+
// Whether "Disconnect" keeps the entry or removes it follows from who owns
|
|
438
|
+
// the credential, which is the same question as "is there anything here
|
|
439
|
+
// worth preserving for a reconnect".
|
|
440
|
+
//
|
|
441
|
+
// 'static' and 'gateway': the definition IS this entry — the config the
|
|
442
|
+
// user pasted, its label and description exist nowhere else. Wiping it
|
|
443
|
+
// would make Disconnect silently discard all of that, with no way back
|
|
444
|
+
// short of re-adding from scratch. Clear only the secret and leave the row
|
|
445
|
+
// in place as "not connected", so reconnecting costs one paste or one
|
|
446
|
+
// sign-in.
|
|
447
|
+
//
|
|
448
|
+
// 'none': there is no secret to clear. listConnectorStatus's
|
|
449
|
+
// `secretNames.every(...)` is vacuously true on an empty array, so the row
|
|
450
|
+
// would report connected forever no matter what this route did — a
|
|
451
|
+
// soft disconnect is a no-op that looks like a bug (click Disconnect, row
|
|
452
|
+
// stays "Connected"). Nothing to preserve for a reconnect either.
|
|
453
|
+
//
|
|
454
|
+
// 'external': the definition lives in the control plane that pushed it,
|
|
455
|
+
// which the caller can re-push in full via /oauth/receive at any time.
|
|
456
|
+
if (entry.credentialOwner === 'static' || entry.credentialOwner === 'gateway') {
|
|
457
|
+
return { found: true, hard: false };
|
|
458
|
+
}
|
|
459
|
+
// The entry AND the per-agent enablement flags that reference it, in this
|
|
460
|
+
// one locked write: with the entry gone nothing would ever come back for
|
|
461
|
+
// orphaned flags, so splitting this in two would reintroduce exactly the
|
|
462
|
+
// orphan it exists to prevent (see dropConnector's doc). A soft
|
|
463
|
+
// disconnect above keeps the entry, so it keeps its enablement too.
|
|
464
|
+
remove();
|
|
465
|
+
return { found: true, hard: true };
|
|
466
|
+
});
|
|
467
|
+
if (!outcome.found) {
|
|
468
|
+
res.status(404).json({ error: `Unknown connector '${id}'` });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// `null` is the overlay's way of saying "this entry is gone", and the
|
|
472
|
+
// hard-delete branch is the one place that needs it. A 'none'-owner
|
|
473
|
+
// connector has no secret whose disappearance would change its resolved
|
|
474
|
+
// shape, and the runners' config snapshot is refreshed by a file watcher
|
|
475
|
+
// that has almost certainly not fired for the write above — so without the
|
|
476
|
+
// overlay every session's spawn-time fingerprint still matches, nothing
|
|
477
|
+
// restarts, and agents keep talking to a connector the API has just
|
|
478
|
+
// reported deleted until something unrelated restarts them.
|
|
479
|
+
//
|
|
480
|
+
// After the lock, not inside it: a restart can take seconds, and a runner
|
|
481
|
+
// that rewrites config.json on the way would deadlock against it.
|
|
482
|
+
await restartSessionsUsing(id, outcome.hard ? { overlay: { [id]: null } } : undefined);
|
|
483
|
+
res.json({ id, connected: false });
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
res.status(500).json({ error: err.message });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
// Add a custom (user-pasted) connector — raw mcpServers-entry JSON with
|
|
490
|
+
// {placeholder} tokens standing in for secrets. Admin-trusted, NOT
|
|
491
|
+
// code-reviewed (see CustomConnectorEntry's doc comment for the tradeoff).
|
|
492
|
+
router.post('/v1/connectors/custom', async (req, res) => {
|
|
493
|
+
if (!requireAdmin(req, res))
|
|
494
|
+
return;
|
|
495
|
+
const body = req.body;
|
|
496
|
+
if (typeof body.label !== 'string' || !body.label.trim()) {
|
|
497
|
+
res.status(400).json({ error: 'label is required and must be a non-empty string' });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (typeof body.config !== 'object' ||
|
|
501
|
+
body.config === null ||
|
|
502
|
+
Array.isArray(body.config)) {
|
|
503
|
+
res.status(400).json({ error: 'config is required and must be a JSON object' });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (body.oauth !== undefined && typeof body.oauth !== 'boolean') {
|
|
507
|
+
res.status(400).json({ error: 'oauth must be a boolean' });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (body.description !== undefined && typeof body.description !== 'string') {
|
|
511
|
+
res.status(400).json({ error: 'description must be a string' });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (body.sourceUrl !== undefined && typeof body.sourceUrl !== 'string') {
|
|
515
|
+
res.status(400).json({ error: 'sourceUrl must be a string' });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
let secrets = {};
|
|
519
|
+
if (body.secrets !== undefined) {
|
|
520
|
+
if (typeof body.secrets !== 'object' || body.secrets === null || Array.isArray(body.secrets)) {
|
|
521
|
+
res.status(400).json({ error: 'secrets must be an object of string values' });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
for (const v of Object.values(body.secrets)) {
|
|
525
|
+
if (typeof v !== 'string') {
|
|
526
|
+
res.status(400).json({ error: 'secrets values must all be strings' });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
secrets = body.secrets;
|
|
531
|
+
}
|
|
532
|
+
const oauth = body.oauth === true;
|
|
533
|
+
if (oauth && typeof body.config.url !== 'string') {
|
|
534
|
+
res.status(400).json({ error: 'config.url is required when oauth is true' });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const existing = await store.read();
|
|
538
|
+
const secretNames = (0, custom_1.extractPlaceholders)(body.config);
|
|
539
|
+
// `__`-prefixed names are the gateway's own (oauth-refresh-sweep.ts). They can no
|
|
540
|
+
// longer collide with it — internalSecretKey() uses a prefix customSecretKey()
|
|
541
|
+
// can't produce — but they would silently resolve to the empty string, so say so.
|
|
542
|
+
const reserved = secretNames.filter(custom_1.isReservedPlaceholder);
|
|
543
|
+
if (reserved.length) {
|
|
544
|
+
res.status(400).json({
|
|
545
|
+
error: `Placeholder names starting with "__" are reserved by the gateway: ${reserved.join(', ')}`,
|
|
546
|
+
});
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (oauth && !secretNames.includes('access_token')) {
|
|
550
|
+
res.status(400).json({
|
|
551
|
+
error: 'oauth connectors need an {access_token} placeholder in config (e.g. headers.Authorization: "Bearer {access_token}")',
|
|
552
|
+
});
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (oauth && secrets.access_token !== undefined) {
|
|
556
|
+
// A pasted access_token on a connector that says "the gateway signs this in"
|
|
557
|
+
// is a state nothing here can maintain. `oauth: true` makes the entry
|
|
558
|
+
// 'gateway'-owned, and the refresh sweep renews a 'gateway' token from the
|
|
559
|
+
// refresh_token the sign-in stores — which a paste cannot supply, since the
|
|
560
|
+
// gateway never saw the exchange it came out of.
|
|
561
|
+
//
|
|
562
|
+
// Accepted, the row read "connected" off `secretNames.every(hasSecret)`
|
|
563
|
+
// immediately and stayed that way: the sweep skips a connector with no
|
|
564
|
+
// refresh_token, so it records no failure, and `unrefreshable` could not
|
|
565
|
+
// catch it either because it keyed on a stored expiry that only a real
|
|
566
|
+
// sign-in ever writes. The connector then simply stopped working whenever
|
|
567
|
+
// the pasted token aged out, with a green checkmark still on it and nothing
|
|
568
|
+
// in any log — the exact state oauth-refresh-sweep.ts's module comment says
|
|
569
|
+
// it exists to prevent.
|
|
570
|
+
//
|
|
571
|
+
// Both real intents remain reachable: sign in through /oauth/start, or
|
|
572
|
+
// re-send this request without `oauth` to get a 'static' connector, which is
|
|
573
|
+
// what a hand-held token actually is. Other placeholders on an oauth
|
|
574
|
+
// connector are untouched — a {workspace_id} is inert configuration the
|
|
575
|
+
// sign-in neither writes nor can supply.
|
|
576
|
+
res.status(400).json({
|
|
577
|
+
error: 'access_token cannot be pasted into an oauth connector — the gateway mints it at' +
|
|
578
|
+
' POST /v1/connectors/custom/:id/oauth/start and needs the refresh_token from that' +
|
|
579
|
+
' exchange to keep it alive. Omit `oauth` to store a hand-held token as a static' +
|
|
580
|
+
' connector instead.',
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// A secret whose name is not a placeholder in `config` can never be read back:
|
|
585
|
+
// resolution only ever looks up `entry.secretNames`. Silently dropping it means
|
|
586
|
+
// the caller pasted a value it believes is stored — a typo in a placeholder name
|
|
587
|
+
// then looks like "the connector just doesn't work", with the real token sitting
|
|
588
|
+
// in a file nothing reads. Report it instead.
|
|
589
|
+
const unknownSecrets = Object.keys(secrets).filter((name) => !secretNames.includes(name));
|
|
590
|
+
if (unknownSecrets.length) {
|
|
591
|
+
res.status(400).json({
|
|
592
|
+
error: `secrets contains ${unknownSecrets.join(', ')}, which ${unknownSecrets.length === 1 ? 'is not a' : 'are not'} {placeholder} in config — expected ${secretNames.length ? secretNames.join(', ') : 'none'}`,
|
|
593
|
+
});
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const entry = {
|
|
598
|
+
label: body.label.trim(),
|
|
599
|
+
description: typeof body.description === 'string' ? body.description : undefined,
|
|
600
|
+
config: body.config,
|
|
601
|
+
secretNames,
|
|
602
|
+
sourceUrl: typeof body.sourceUrl === 'string' && body.sourceUrl.trim()
|
|
603
|
+
? body.sourceUrl.trim()
|
|
604
|
+
: undefined,
|
|
605
|
+
// The request field stays `oauth: boolean` — an instruction ("run the
|
|
606
|
+
// sign-in flow on this gateway"), not a report of state, so it has none
|
|
607
|
+
// of the ambiguity the stored flags had. It is resolved to an owner
|
|
608
|
+
// exactly once, here: this route can only ever produce these three.
|
|
609
|
+
// 'external' is written by /oauth/receive alone, because only a caller
|
|
610
|
+
// that already holds a token can claim to own one.
|
|
611
|
+
credentialOwner: oauth ? 'gateway' : secretNames.length ? 'static' : 'none',
|
|
612
|
+
};
|
|
613
|
+
// The id is picked inside the write lock, against the map actually being
|
|
614
|
+
// written. Choosing it from the `read()` above instead left a window in
|
|
615
|
+
// which two concurrent adds of the same label both saw the id as free and
|
|
616
|
+
// the second silently overwrote the first — including pointing it at the
|
|
617
|
+
// first one's already-stored secrets. `store.mutate` is a no-op when there
|
|
618
|
+
// is no config file to persist to (tests), so the pre-computed value below
|
|
619
|
+
// stands in for that case.
|
|
620
|
+
let id = (0, custom_1.slugify)(body.label, Object.keys(existing));
|
|
621
|
+
const label = body.label;
|
|
622
|
+
await store.mutate((c) => {
|
|
623
|
+
id = (0, custom_1.slugify)(label, Object.keys(c));
|
|
624
|
+
c[id] = entry;
|
|
625
|
+
});
|
|
626
|
+
// Secrets go in after the id is final — writing them first would key them
|
|
627
|
+
// to an id the lock might not hand us.
|
|
628
|
+
const values = {};
|
|
629
|
+
for (const name of secretNames) {
|
|
630
|
+
const value = secrets[name];
|
|
631
|
+
if (value?.trim())
|
|
632
|
+
values[(0, custom_1.customSecretKey)(id, name)] = value.trim();
|
|
633
|
+
}
|
|
634
|
+
if (Object.keys(values).length)
|
|
635
|
+
(0, token_env_1.setSecrets)(values);
|
|
636
|
+
const connected = secretNames.every((name) => (0, token_env_1.hasSecret)((0, custom_1.customSecretKey)(id, name)));
|
|
637
|
+
if (connected)
|
|
638
|
+
await restartSessionsUsing(id, { overlay: { [id]: entry } });
|
|
639
|
+
res.json({ id, label: entry.label, connected });
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
res.status(500).json({ error: err.message });
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
// Custom connector removal is now handled by `DELETE /v1/connectors/:id`
|
|
646
|
+
// above — this used to be a separate `/custom/:id` route; retired in favor
|
|
647
|
+
// of one delete path.
|
|
648
|
+
return router;
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=connectors-router.js.map
|