@zackbart/connecta 0.23.0 → 0.24.0
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/AGENTS.md +5 -0
- package/CHANGELOG.md +40 -0
- package/README.md +18 -10
- package/dist/activity-friction.d.ts +3 -0
- package/dist/activity-friction.js +19 -0
- package/dist/activity.d.ts +11 -2
- package/dist/activity.js +15 -19
- package/dist/auth/downstream-oauth.d.ts +2 -1
- package/dist/auth/downstream-oauth.js +10 -1
- package/dist/branding.d.ts +67 -0
- package/dist/branding.js +176 -0
- package/dist/connectors/remote-mcp.js +3 -5
- package/dist/credential-contract.d.ts +24 -0
- package/dist/credential-contract.js +1 -0
- package/dist/credential-rules.d.ts +85 -0
- package/dist/credential-rules.js +107 -0
- package/dist/credentials.d.ts +4 -100
- package/dist/credentials.js +3 -107
- package/dist/index.d.ts +22 -55
- package/dist/index.js +30 -58
- package/dist/invocation.js +2 -3
- package/dist/meta-tools.d.ts +4 -0
- package/dist/meta-tools.js +8 -4
- package/dist/module-contracts.d.ts +19 -0
- package/dist/module-contracts.js +1 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +6 -3
- package/dist/operator-ui/view.d.ts +2 -18
- package/dist/operator-ui/view.js +3 -20
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -6
- package/dist/routes/activity.js +1 -1
- package/dist/routes/credentials.js +5 -2
- package/dist/routes/mcp.js +7 -3
- package/dist/routes/oauth-management.d.ts +2 -0
- package/dist/routes/oauth-management.js +108 -0
- package/dist/routes/oauth.d.ts +0 -1
- package/dist/routes/oauth.js +21 -121
- package/dist/routes/shared.d.ts +19 -17
- package/dist/routes/shared.js +48 -44
- package/dist/routes/ui.js +36 -33
- package/dist/server.js +6 -26
- package/dist/types.d.ts +2 -0
- package/dist/ui.d.ts +15 -70
- package/dist/ui.js +176 -317
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +26 -17
- package/documentation/auth.md +61 -106
- package/documentation/cloudflare.md +1 -1
- package/documentation/connectors.md +1 -1
- package/documentation/linear.md +1 -1
- package/documentation/meta-tools.md +6 -4
- package/documentation/mixpanel.md +1 -1
- package/documentation/notion.md +2 -2
- package/documentation/operations.md +12 -14
- package/documentation/operator-ui.md +82 -104
- package/documentation/optional-modules-upgrade.md +243 -0
- package/documentation/provider-conventions.md +5 -3
- package/documentation/revenuecat.md +1 -1
- package/documentation/storage-and-credentials.md +59 -40
- package/documentation/stripe.md +1 -1
- package/documentation/upgrading.md +29 -4
- package/ethos.md +22 -30
- package/examples/worker/AGENTS.md +3 -1
- package/examples/worker/README.md +68 -84
- package/examples/worker/src/d1-activity.ts +1 -1
- package/examples/worker/src/index.ts +11 -6
- package/package.json +18 -2
- package/templates/node/AGENTS.md +8 -6
- package/templates/node/README.md +56 -67
- package/templates/node/package.json +1 -1
- package/templates/node/src/file-activity.ts +1 -1
- package/templates/node/src/index.ts +11 -12
- package/dist/access-tokens.d.ts +0 -31
- package/dist/access-tokens.js +0 -236
- package/dist/routes/access-tokens.d.ts +0 -6
- package/dist/routes/access-tokens.js +0 -83
package/dist/ui.js
CHANGED
|
@@ -1,64 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { routeUi } from "./routes/ui.js";
|
|
2
|
+
import { routeCredentials } from "./routes/credentials.js";
|
|
3
|
+
import { routeOAuthManagement } from "./routes/oauth-management.js";
|
|
4
|
+
import { withDeadline } from "./timeout.js";
|
|
5
|
+
import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "./credential-rules.js";
|
|
2
6
|
import { closeConnectorScope, } from "./connector-scope.js";
|
|
3
7
|
import { mapSettledWithConcurrency, resolveDiscoveryConcurrency, } from "./concurrency.js";
|
|
4
8
|
import { OPERATOR_UI_CSS, OPERATOR_UI_SCRIPT, } from "./operator-ui/generated.js";
|
|
5
9
|
import { CONNECTA_VERSION } from "./version.js";
|
|
6
10
|
export { filterUiConnectors, } from "./operator-ui/model.js";
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
<style>
|
|
10
|
-
.fg { fill: #000 }
|
|
11
|
-
@media (prefers-color-scheme: dark) { .fg { fill: #fff } }
|
|
12
|
-
</style>
|
|
13
|
-
<path class="fg" d="M27 9.4A13 13 0 1 0 27 22.6l-4.4-2.5a8 8 0 1 1 0-8.2z"/>
|
|
14
|
-
</svg>`;
|
|
15
|
-
const DEFAULT_FAVICON_HREF = "/favicon.svg";
|
|
16
|
-
/**
|
|
17
|
-
* Branding arrives from operator config, which is untyped at a JS call site, so
|
|
18
|
-
* every field is treated as `unknown`: a non-string is read as unset rather than
|
|
19
|
-
* throwing on `.trim()`. Rendering must degrade to defaults for a malformed
|
|
20
|
-
* value, never fail — `createConnecta` calls this during construction.
|
|
21
|
-
*/
|
|
22
|
-
function trimmedString(value) {
|
|
23
|
-
return typeof value === "string" ? value.trim() || undefined : undefined;
|
|
24
|
-
}
|
|
25
|
-
export function resolveBranding(branding) {
|
|
26
|
-
const productName = trimmedString(branding?.productName) ?? "Connecta";
|
|
27
|
-
const ownerName = trimmedString(branding?.ownerName);
|
|
28
|
-
// Operator branding URLs become masthead/callback hrefs, so a non-http(s)
|
|
29
|
-
// scheme (javascript:, data:) is dropped the same as an unset URL — the
|
|
30
|
-
// callers already render a <span> instead of an <a> when it is absent.
|
|
31
|
-
const productUrl = trimmedString(branding?.productUrl);
|
|
32
|
-
const ownerUrl = trimmedString(branding?.ownerUrl);
|
|
33
|
-
const faviconHref = trimmedString(branding?.favicon?.href);
|
|
34
|
-
return {
|
|
35
|
-
productName,
|
|
36
|
-
...(productUrl && isSafeHttpUrl(productUrl) ? { productUrl } : {}),
|
|
37
|
-
...(ownerName ? { ownerName } : {}),
|
|
38
|
-
...(ownerUrl && isSafeHttpUrl(ownerUrl) ? { ownerUrl } : {}),
|
|
39
|
-
description: trimmedString(branding?.description) ??
|
|
40
|
-
`Manage the services this ${productName} instance makes available to agents.`,
|
|
41
|
-
pageTitle: trimmedString(branding?.pageTitle) ??
|
|
42
|
-
(ownerName ? `${productName} — ${ownerName}` : productName),
|
|
43
|
-
faviconHref: faviconHref && isSafeIconHref(faviconHref)
|
|
44
|
-
? faviconHref
|
|
45
|
-
: DEFAULT_FAVICON_HREF,
|
|
46
|
-
themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Whether the operator meant to supply a value here — the question every
|
|
51
|
-
* dropped-URL warning asks before naming a field, and one definition so the
|
|
52
|
-
* branding and `uiAuth` warnings cannot answer it differently. A non-string
|
|
53
|
-
* counts as set: the intent was there and is exactly what the warning reports
|
|
54
|
-
* on. A blank or whitespace-only string does not; that is indistinguishable
|
|
55
|
-
* from leaving the field alone, and both take the default silently.
|
|
56
|
-
*/
|
|
57
|
-
function isSetUrlValue(value) {
|
|
58
|
-
return typeof value === "string"
|
|
59
|
-
? trimmedString(value) !== undefined
|
|
60
|
-
: value !== undefined && value !== null;
|
|
61
|
-
}
|
|
11
|
+
import { resolveBranding, isSafeHttpsUrl } from "./branding.js";
|
|
12
|
+
export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
|
|
62
13
|
/**
|
|
63
14
|
* A JS string literal safe to inline in a script element. Escaping `/` keeps
|
|
64
15
|
* an operator-supplied `</script>` from terminating the element early.
|
|
@@ -66,140 +17,13 @@ function isSetUrlValue(value) {
|
|
|
66
17
|
function stringForInlineScript(value) {
|
|
67
18
|
return JSON.stringify(value).replace(/\//g, "\\/");
|
|
68
19
|
}
|
|
69
|
-
/**
|
|
70
|
-
* Names of the branding URLs the operator set that failed their gate and were
|
|
71
|
-
* replaced by a default. Lives beside the gates so the startup warning cannot
|
|
72
|
-
* drift from them, and takes `unknown` fields for the same reason
|
|
73
|
-
* `resolveBranding` does — a warning helper must never throw.
|
|
74
|
-
*/
|
|
75
|
-
export function droppedBrandingUrls(branding) {
|
|
76
|
-
if (!branding)
|
|
77
|
-
return [];
|
|
78
|
-
const resolved = resolveBranding(branding);
|
|
79
|
-
const faviconHref = branding.favicon?.href;
|
|
80
|
-
return [
|
|
81
|
-
...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
|
|
82
|
-
? ["productUrl"]
|
|
83
|
-
: []),
|
|
84
|
-
...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
|
|
85
|
-
? ["ownerUrl"]
|
|
86
|
-
: []),
|
|
87
|
-
...(isSetUrlValue(faviconHref) &&
|
|
88
|
-
trimmedString(faviconHref) !== resolved.faviconHref
|
|
89
|
-
? ["favicon.href"]
|
|
90
|
-
: []),
|
|
91
|
-
];
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* True only for absolute `http:`/`https:` URLs. Downstream connectors control
|
|
95
|
-
* their `authorizationUrl`, so a hostile/misconfigured one could hand back a
|
|
96
|
-
* `javascript:` (or other) scheme; gate it before it can become an href.
|
|
97
|
-
*/
|
|
98
|
-
function safeUrl(url, schemes) {
|
|
99
|
-
if (typeof url !== "string")
|
|
100
|
-
return false;
|
|
101
|
-
try {
|
|
102
|
-
return schemes.includes(new URL(url).protocol);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
export function isSafeHttpUrl(url) {
|
|
109
|
-
return safeUrl(url, ["http:", "https:"]);
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Only the second check's base; any origin works because the check is whether
|
|
113
|
-
* the href stays on whatever origin it is resolved against. It is deliberately
|
|
114
|
-
* never the sole gate: a value whose own authority equals this host (say
|
|
115
|
-
* `//connecta.invalid/x`) would resolve to this exact origin and pass, so the
|
|
116
|
-
* structural check below runs first and is what actually rejects `//host`.
|
|
117
|
-
*/
|
|
118
|
-
const SAME_ORIGIN_PROBE = "https://connecta.invalid";
|
|
119
|
-
/** Removed anywhere in a URL by the parser, so a gate must ignore them too. */
|
|
120
|
-
const URL_STRIPPED_CHARS = /[\t\n\r]/g;
|
|
121
|
-
/**
|
|
122
|
-
* True for values allowed in the page's `<link rel="icon" href>`: an absolute
|
|
123
|
-
* `http(s)` URL (an icon the operator hosts elsewhere) or a path rooted at this
|
|
124
|
-
* origin. The relative carve-out is deliberate rather than accidental — the
|
|
125
|
-
* default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
|
|
126
|
-
* reject — and it is kept narrow on both ends.
|
|
127
|
-
*
|
|
128
|
-
* Root-relative only, because operator and OAuth callback pages sit at
|
|
129
|
-
* different depths and a document-relative path would resolve differently.
|
|
130
|
-
*
|
|
131
|
-
* "Root-relative" is enforced structurally: exactly one leading `/` followed by
|
|
132
|
-
* a character that is neither `/` nor `\`. Both of those would make the value an
|
|
133
|
-
* authority (`//host`, and `/\host` because the URL parser folds `\` to `/` in
|
|
134
|
-
* special schemes), pointing at an origin this server does not control. The test
|
|
135
|
-
* runs on a copy with tab/newline/CR removed, since the parser strips those
|
|
136
|
-
* anywhere and `/\t/host` would otherwise slip through as single-slash. The
|
|
137
|
-
* origin comparison that follows is defense in depth, not the authority check —
|
|
138
|
-
* on its own it would accept an authority that happened to equal the probe host.
|
|
139
|
-
*/
|
|
140
|
-
export function isSafeIconHref(href) {
|
|
141
|
-
if (typeof href !== "string")
|
|
142
|
-
return false;
|
|
143
|
-
if (isSafeHttpUrl(href))
|
|
144
|
-
return true;
|
|
145
|
-
if (!/^\/(?![/\\])/.test(href.replace(URL_STRIPPED_CHARS, "")))
|
|
146
|
-
return false;
|
|
147
|
-
try {
|
|
148
|
-
return new URL(href, SAME_ORIGIN_PROBE).origin === SAME_ORIGIN_PROBE;
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
/** Absolute HTTPS gate for the `UiAuthConfig` URL fields documented in types.ts. */
|
|
155
|
-
export function isSafeHttpsUrl(url) {
|
|
156
|
-
return safeUrl(url, ["https:"]);
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Names of the `uiAuth` URLs an inbound-auth provider supplied that failed their
|
|
160
|
-
* gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
|
|
161
|
-
* startup warning cannot then drift from what rendering actually drops. Every
|
|
162
|
-
* field is read defensively rather than trusted, because a custom `InboundAuth`
|
|
163
|
-
* is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
|
|
164
|
-
* `uiAuth` that is not the clerk shape is reported as nothing to warn about.
|
|
165
|
-
*
|
|
166
|
-
* `frontendApiUrl` is required, so anything that fails its gate is a drop.
|
|
167
|
-
* `signInUrl` and `signUpUrl` are optional, so only a value the operator
|
|
168
|
-
* *supplied* and the gate then rejected is worth a warning — an unset field
|
|
169
|
-
* took no default away from anyone. `isSetUrlValue` decides that, the same way
|
|
170
|
-
* and for the same reasons it decides it for the branding URLs: a warning that
|
|
171
|
-
* fires for one and not the other would be reporting on the field rather than
|
|
172
|
-
* on the operator's intent. Rendering is not consulted for this: it drops on
|
|
173
|
-
* the gate alone, and a blank string fails that gate too — it is simply not
|
|
174
|
-
* *reported*, because a blank is indistinguishable from leaving the field
|
|
175
|
-
* alone.
|
|
176
|
-
*/
|
|
177
|
-
export function droppedUiAuthUrls(uiAuth) {
|
|
178
|
-
if (!uiAuth || uiAuth.kind !== "clerk")
|
|
179
|
-
return [];
|
|
180
|
-
return [
|
|
181
|
-
...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
|
|
182
|
-
...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
|
|
183
|
-
? ["uiAuth.signInUrl"]
|
|
184
|
-
: []),
|
|
185
|
-
...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
|
|
186
|
-
? ["uiAuth.signUpUrl"]
|
|
187
|
-
: []),
|
|
188
|
-
];
|
|
189
|
-
}
|
|
190
20
|
const OPERATOR_PAGE_LABELS = {
|
|
191
21
|
connections: "Connections",
|
|
192
|
-
credentials: "Credentials",
|
|
193
|
-
tokens: "Access tokens",
|
|
194
22
|
activity: "Activity",
|
|
195
23
|
};
|
|
196
24
|
export function operatorPageForPath(path) {
|
|
197
25
|
if (path === "/")
|
|
198
26
|
return "connections";
|
|
199
|
-
if (path === "/credentials")
|
|
200
|
-
return "credentials";
|
|
201
|
-
if (path === "/tokens")
|
|
202
|
-
return "tokens";
|
|
203
27
|
if (path === "/activity")
|
|
204
28
|
return "activity";
|
|
205
29
|
return undefined;
|
|
@@ -223,151 +47,157 @@ export function credentialManagementCapability(input) {
|
|
|
223
47
|
*/
|
|
224
48
|
export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false, credentialManagement = credentialVault
|
|
225
49
|
? "available"
|
|
226
|
-
: "requires_operator", defer, oauthManagement = false, discoveryConcurrency,
|
|
50
|
+
: "requires_operator", defer, oauthManagement = false, discoveryConcurrency, personalCredentialOwner, detailOptions = {}) {
|
|
227
51
|
const requestScope = {};
|
|
228
52
|
const connectorSet = registry.listConnectors();
|
|
229
53
|
const concurrency = resolveDiscoveryConcurrency(discoveryConcurrency);
|
|
230
54
|
const settled = await mapSettledWithConcurrency(connectorSet, concurrency, async (c) => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
55
|
+
try {
|
|
56
|
+
return await withDeadline(async (outerSignal) => {
|
|
57
|
+
const drift = await registry.credentialDriftFor(c.id);
|
|
58
|
+
outerSignal.throwIfAborted();
|
|
59
|
+
let tools = [];
|
|
60
|
+
let status;
|
|
61
|
+
try {
|
|
62
|
+
status = await withDeadline(async (signal) => {
|
|
63
|
+
if (drift)
|
|
64
|
+
return { state: "auth_required", message: drift };
|
|
65
|
+
const current = await registry.statusFor(c.id, baseUrl, requestScope, { signal });
|
|
66
|
+
if (current.state === "ok" && !signal.aborted) {
|
|
67
|
+
try {
|
|
68
|
+
tools = (await registry.getTools(c.id, baseUrl, requestScope, { signal })).map(t => ({ name: t.name, address: `${c.id}.${t.name}`, ...(t.description ? { description: t.description } : {}) }));
|
|
69
|
+
}
|
|
70
|
+
catch { /* The registry owns the failed catalog observation. */ }
|
|
71
|
+
}
|
|
72
|
+
return current;
|
|
73
|
+
}, { timeoutMs: detailOptions.timeoutMs ?? 30_000, signal: outerSignal, timeoutError: new Error("Connection details timed out. Retry this connection.") });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
status = { state: "error", message: error instanceof Error ? error.message : "Connection details unavailable" };
|
|
77
|
+
}
|
|
78
|
+
let credential;
|
|
79
|
+
const mayManageAuth = detailOptions.mayManage?.(c.id) ?? (c.authScope === "personal" ? Boolean(personalCredentialOwner) : oauthManagement);
|
|
80
|
+
if (c.credential && credentialVault && mayManageAuth) {
|
|
81
|
+
// One rule, shared with the test route: only the hook matching the
|
|
82
|
+
// declared credential shape can run, so the button is offered only
|
|
83
|
+
// where a click can succeed (src/credentials.ts).
|
|
84
|
+
const testRule = credentialTestRule(c);
|
|
85
|
+
const credentialFields = (metadata) => c.credential?.fields?.map((field) => {
|
|
86
|
+
const fieldMetadata = metadata?.fields?.[field.name];
|
|
87
|
+
return {
|
|
88
|
+
name: field.name,
|
|
89
|
+
label: field.label,
|
|
90
|
+
...(field.description
|
|
91
|
+
? { description: field.description }
|
|
92
|
+
: {}),
|
|
93
|
+
...(field.placeholder
|
|
94
|
+
? { placeholder: field.placeholder }
|
|
95
|
+
: {}),
|
|
96
|
+
inputType: field.inputType ?? "password",
|
|
97
|
+
configured: Boolean(fieldMetadata),
|
|
98
|
+
...(fieldMetadata
|
|
99
|
+
? {
|
|
100
|
+
lastFour: fieldMetadata.lastFour,
|
|
101
|
+
updatedAt: fieldMetadata.updatedAt,
|
|
102
|
+
}
|
|
103
|
+
: {}),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
const credentialCard = {
|
|
107
|
+
label: c.credential.label,
|
|
108
|
+
...(c.credential.description
|
|
109
|
+
? { description: c.credential.description }
|
|
110
|
+
: {}),
|
|
111
|
+
...(c.credential.placeholder
|
|
112
|
+
? { placeholder: c.credential.placeholder }
|
|
113
|
+
: {}),
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
const metadata = await credentialVault.metadata(c.id, c.authScope === "personal" ? personalCredentialOwner : undefined);
|
|
117
|
+
const fields = credentialFields(metadata);
|
|
118
|
+
const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
|
|
119
|
+
credential = {
|
|
120
|
+
...credentialCard,
|
|
121
|
+
...(fields?.length ? { fields } : {}),
|
|
122
|
+
configured: shape.state === "valid",
|
|
123
|
+
removable: Boolean(metadata),
|
|
124
|
+
...(metadata
|
|
125
|
+
? {
|
|
126
|
+
lastFour: metadata.lastFour,
|
|
127
|
+
updatedAt: metadata.updatedAt,
|
|
128
|
+
}
|
|
129
|
+
: {}),
|
|
130
|
+
testable: testRule.mode !== null && shape.state !== "mismatch",
|
|
131
|
+
...(shape.state === "mismatch"
|
|
132
|
+
? { error: shape.message }
|
|
133
|
+
: {}),
|
|
134
|
+
// A dropped field leaves its secret in the vault, and the field
|
|
135
|
+
// list below only renders fields the connector still declares —
|
|
136
|
+
// so without this line there is nowhere an operator could see it.
|
|
137
|
+
...(shape.state === "valid" && shape.undeclared.length
|
|
138
|
+
? {
|
|
139
|
+
notice: describeUndeclaredCredentialFields(shape.undeclared),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
const fields = credentialFields();
|
|
146
|
+
credential = {
|
|
147
|
+
...credentialCard,
|
|
148
|
+
...(fields?.length ? { fields } : {}),
|
|
149
|
+
configured: false,
|
|
150
|
+
removable: true,
|
|
151
|
+
testable: testRule.mode !== null,
|
|
152
|
+
error: "Stored credential could not be read.",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
outerSignal.throwIfAborted();
|
|
269
157
|
return {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
...(
|
|
273
|
-
|
|
158
|
+
id: c.id,
|
|
159
|
+
authScope: c.authScope ?? "shared",
|
|
160
|
+
...(c.title ? { title: c.title } : {}),
|
|
161
|
+
...(c.description !== undefined
|
|
162
|
+
? { description: c.description }
|
|
274
163
|
: {}),
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
164
|
+
status: status.state,
|
|
165
|
+
...(status.message ? { message: status.message } : {}),
|
|
166
|
+
toolCount: tools.length,
|
|
167
|
+
tools,
|
|
168
|
+
// Counts only, and only when a refresh in this runtime produced them.
|
|
169
|
+
// `Registry.statusFor` already rebuilt the report through
|
|
170
|
+
// `boundedCatalogDrift`, so what lands here cannot carry a name or a
|
|
171
|
+
// schema even if the plugin seam returned one.
|
|
172
|
+
...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
|
|
173
|
+
...(status.catalogAccess
|
|
174
|
+
? { catalogAccess: status.catalogAccess }
|
|
285
175
|
: {}),
|
|
176
|
+
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
177
|
+
...(credential ? { credential } : {}),
|
|
286
178
|
};
|
|
179
|
+
}, {
|
|
180
|
+
timeoutMs: detailOptions.timeoutMs ?? 30_000,
|
|
181
|
+
...(detailOptions.signal ? { signal: detailOptions.signal } : {}),
|
|
182
|
+
timeoutError: new Error("Connection details timed out. Retry this connection."),
|
|
287
183
|
});
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
id: c.id,
|
|
188
|
+
...(c.title ? { title: c.title } : {}),
|
|
189
|
+
authScope: c.authScope ?? "shared",
|
|
190
|
+
status: "error",
|
|
191
|
+
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
192
|
+
message: error instanceof Error ? error.message : "Connection details unavailable",
|
|
193
|
+
toolCount: 0,
|
|
194
|
+
tools: [],
|
|
296
195
|
};
|
|
297
|
-
try {
|
|
298
|
-
const metadata = await credentialVault.metadata(c.id, c.authScope === "personal" ? personalCredentialOwner : undefined);
|
|
299
|
-
const fields = credentialFields(metadata);
|
|
300
|
-
const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
|
|
301
|
-
credential = {
|
|
302
|
-
...credentialCard,
|
|
303
|
-
...(fields?.length ? { fields } : {}),
|
|
304
|
-
configured: shape.state === "valid",
|
|
305
|
-
removable: Boolean(metadata),
|
|
306
|
-
...(metadata
|
|
307
|
-
? {
|
|
308
|
-
lastFour: metadata.lastFour,
|
|
309
|
-
updatedAt: metadata.updatedAt,
|
|
310
|
-
}
|
|
311
|
-
: {}),
|
|
312
|
-
testable: testRule.mode !== null && shape.state !== "mismatch",
|
|
313
|
-
...(shape.state === "mismatch"
|
|
314
|
-
? { error: shape.message }
|
|
315
|
-
: {}),
|
|
316
|
-
// A dropped field leaves its secret in the vault, and the field
|
|
317
|
-
// list below only renders fields the connector still declares —
|
|
318
|
-
// so without this line there is nowhere an operator could see it.
|
|
319
|
-
...(shape.state === "valid" && shape.undeclared.length
|
|
320
|
-
? {
|
|
321
|
-
notice: describeUndeclaredCredentialFields(shape.undeclared),
|
|
322
|
-
}
|
|
323
|
-
: {}),
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
catch {
|
|
327
|
-
const fields = credentialFields();
|
|
328
|
-
credential = {
|
|
329
|
-
...credentialCard,
|
|
330
|
-
...(fields?.length ? { fields } : {}),
|
|
331
|
-
configured: false,
|
|
332
|
-
removable: true,
|
|
333
|
-
testable: testRule.mode !== null,
|
|
334
|
-
error: "Stored credential could not be read.",
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
196
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
...(c.title ? { title: c.title } : {}),
|
|
342
|
-
...(c.description !== undefined
|
|
343
|
-
? { description: c.description }
|
|
344
|
-
: {}),
|
|
345
|
-
status: status.state,
|
|
346
|
-
...(status.message ? { message: status.message } : {}),
|
|
347
|
-
...(isSafeHttpUrl(status.authorizationUrl)
|
|
348
|
-
? { authorizationUrl: status.authorizationUrl }
|
|
349
|
-
: {}),
|
|
350
|
-
toolCount: tools.length,
|
|
351
|
-
tools,
|
|
352
|
-
// Counts only, and only when a refresh in this runtime produced them.
|
|
353
|
-
// `Registry.statusFor` already rebuilt the report through
|
|
354
|
-
// `boundedCatalogDrift`, so what lands here cannot carry a name or a
|
|
355
|
-
// schema even if the plugin seam returned one.
|
|
356
|
-
...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
|
|
357
|
-
...(status.catalogAccess
|
|
358
|
-
? { catalogAccess: status.catalogAccess }
|
|
359
|
-
: {}),
|
|
360
|
-
...(c.disconnectAuth &&
|
|
361
|
-
c.startAuth &&
|
|
362
|
-
(oauthManagement ||
|
|
363
|
-
c.authScope === "personal" ||
|
|
364
|
-
!personalCredentialOwner)
|
|
365
|
-
? { oauth: true }
|
|
366
|
-
: {}),
|
|
367
|
-
...(credential ? { credential } : {}),
|
|
368
|
-
};
|
|
197
|
+
finally {
|
|
198
|
+
await closeConnectorScope(c, registry.contextFor(c.id, baseUrl, requestScope), defer);
|
|
199
|
+
}
|
|
369
200
|
});
|
|
370
|
-
await mapSettledWithConcurrency(connectorSet, concurrency, (connector) => closeConnectorScope(connector, registry.contextFor(connector.id, baseUrl, requestScope), defer));
|
|
371
201
|
const connectors = settled.map((result) => {
|
|
372
202
|
if (result.status === "rejected")
|
|
373
203
|
throw result.reason;
|
|
@@ -379,7 +209,6 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
379
209
|
connectors,
|
|
380
210
|
activityEnabled,
|
|
381
211
|
credentialManagement,
|
|
382
|
-
accessTokenManagement,
|
|
383
212
|
oauthManagement: oauthManagement || Boolean(personalCredentialOwner),
|
|
384
213
|
};
|
|
385
214
|
}
|
|
@@ -498,3 +327,33 @@ ${OPERATOR_UI_SCRIPT}</script>
|
|
|
498
327
|
</body>
|
|
499
328
|
</html>`;
|
|
500
329
|
}
|
|
330
|
+
/** Mount the connection UI without enabling any storage or activity module. */
|
|
331
|
+
export function operatorUi(options = {}) {
|
|
332
|
+
return {
|
|
333
|
+
...options,
|
|
334
|
+
reservedPaths: ["/", "/ui", "/ui/*", "/favicon.svg", "/favicon.ico"],
|
|
335
|
+
credentialHandoffUrl(baseUrl) {
|
|
336
|
+
return new URL("/", baseUrl).toString();
|
|
337
|
+
},
|
|
338
|
+
async handle(context) {
|
|
339
|
+
const routes = [
|
|
340
|
+
...(context.opts.credentialVault ? [routeCredentials] : []),
|
|
341
|
+
routeOAuthManagement,
|
|
342
|
+
routeUi,
|
|
343
|
+
];
|
|
344
|
+
for (const route of routes) {
|
|
345
|
+
const response = await route(context);
|
|
346
|
+
if (response) {
|
|
347
|
+
if (operatorPageForPath(context.path) || context.path === "/ui") {
|
|
348
|
+
response.headers.set("X-Frame-Options", "DENY");
|
|
349
|
+
if (!response.headers.has("Content-Security-Policy")) {
|
|
350
|
+
response.headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return response;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return context.opts.activityModule?.handle(context) ?? null;
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -66,19 +66,17 @@ read top to bottom.
|
|
|
66
66
|
| --- | --- | --- |
|
|
67
67
|
| 0 | HTTPS upgrade | 308 to `publicUrl` when it is HTTPS and the request arrived over HTTP. Path and query are *assigned* onto the configured URL, never resolved against it, so a `//host` pathname cannot replace the deployment origin. `/health` is exempt: a loopback container probe must not depend on public DNS and TLS. `/ui` is canonicalized to `/` while upgrading. |
|
|
68
68
|
| 0 | Cloudflare Access (Worker deployment, when enabled) | Edge admission before this route table. Managed OAuth owns its challenge and discovery metadata; an admitted direct invocation carries trusted identity in `ctx.access`. |
|
|
69
|
-
| 1 |
|
|
70
|
-
| 2 | `OPTIONS` |
|
|
71
|
-
| 3 | `/.well-known/*` | Auth
|
|
72
|
-
| 4 | `/health` | Open
|
|
73
|
-
| 5 | `/oauth/callback/<connectorId>` |
|
|
74
|
-
| 6 | `/
|
|
75
|
-
| 7 |
|
|
76
|
-
|
|
77
|
-
| 9 | — | 404. Custom HTTP routes belong to the deployment. |
|
|
69
|
+
| 1 | Mounted UI routes | The optional UI handles its shells, assets, data, details, and auth mutations before wildcard OPTIONS. Mutation routes refuse preflight rather than inheriting MCP CORS. No UI module means none of these routes. |
|
|
70
|
+
| 2 | `OPTIONS` | Auth metadata gets a chance, otherwise MCP CORS preflight. |
|
|
71
|
+
| 3 | `/.well-known/*` | Auth metadata, or 404. |
|
|
72
|
+
| 4 | `/health` | Open payload-free health, executor, admission, and deployment metadata; reserved routes reflect installed modules. |
|
|
73
|
+
| 5 | `/oauth/callback/<connectorId>` | Core downstream OAuth completion, state verification and personal ownership checks; independent of UI. |
|
|
74
|
+
| 6 | `/mcp` | Admission before auth, then a request-local MCP server. |
|
|
75
|
+
| 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
|
|
76
|
+
|
|
78
77
|
|
|
79
78
|
Every response leaves through `withSecurityHeaders`: `nosniff`, a no-referrer
|
|
80
|
-
policy, HSTS on HTTPS,
|
|
81
|
-
and framing denial. `test/server-route-contracts.test.ts` pins this ordering
|
|
79
|
+
policy, HSTS on HTTPS, while the UI module adds a nonce-based script CSP and framing denial to its shells. `test/server-route-contracts.test.ts` pins this ordering
|
|
82
80
|
and the exact refusal bodies; it exists because the ordering is invisible in
|
|
83
81
|
any one file and a reordering reads like a harmless refactor.
|
|
84
82
|
|
|
@@ -125,6 +123,19 @@ read-only check. `test/execute.test.ts` asserts that parity directly, because
|
|
|
125
123
|
the alternative — a sandbox path that quietly diverges — is how generated code
|
|
126
124
|
would mint a capability.
|
|
127
125
|
|
|
126
|
+
## Optional deployment modules
|
|
127
|
+
|
|
128
|
+
`createConnecta` takes closed typed `ui`, `vault`, and `activity` slots. Factories
|
|
129
|
+
live at `/ui`, `/credentials`, and `/activity`; bearer auth lives at
|
|
130
|
+
`/auth/bearer`. Root exports the contracts, never these implementations. There
|
|
131
|
+
is no module array, runtime registration, or plugin lifecycle.
|
|
132
|
+
|
|
133
|
+
Core keeps connector discovery, the executor contract, invocation, permissions,
|
|
134
|
+
and OAuth callback verification together. Optional modules contribute no
|
|
135
|
+
runtime work when omitted. The UI supplies credential handoff URLs only while
|
|
136
|
+
mounted. Status reads never initiate OAuth, and each lazy details request owns
|
|
137
|
+
its downstream scope. See [operator UI](./operator-ui.md).
|
|
138
|
+
|
|
128
139
|
## Import-graph purity
|
|
129
140
|
|
|
130
141
|
Nothing reachable from `src/index.ts` may import a `node:` builtin. The core is
|
|
@@ -142,8 +153,8 @@ specific to a direct Worker invocation carrying `ctx.access`.
|
|
|
142
153
|
|
|
143
154
|
`test/purity.test.ts` walks the relative-import graph from `src/index.ts` and
|
|
144
155
|
fails on (a) any `node:` specifier in a reachable file and (b) the Node
|
|
145
|
-
adapter, file storage, QuickJS parent or child,
|
|
146
|
-
reachable at all. `test/package-surface.test.ts` and
|
|
156
|
+
adapter, file storage, QuickJS parent or child, auth adapters, UI bundle,
|
|
157
|
+
encrypted vault implementation, or activity implementation being reachable at all. `test/package-surface.test.ts` and
|
|
147
158
|
`scripts/check-package.mjs` guard the other half — that the published tarball
|
|
148
159
|
matches the same boundary.
|
|
149
160
|
|
|
@@ -169,8 +180,7 @@ src/
|
|
|
169
180
|
invocation.ts one tool call, end to end
|
|
170
181
|
catalog-drift.ts vetted manifests and the counts a refresh produces
|
|
171
182
|
credentials.ts the AES-GCM connector vault over KVStorage
|
|
172
|
-
|
|
173
|
-
activity.ts payload-free event contracts + best-effort recorder
|
|
183
|
+
activity.ts optional history factory and best-effort recorder
|
|
174
184
|
call-admission.ts connector-partitioned downstream permits and budgets
|
|
175
185
|
executor-admission.ts the portable bounded queue both pools use
|
|
176
186
|
ui.ts the served operator shell and /ui/data payload
|
|
@@ -199,8 +209,7 @@ src/
|
|
|
199
209
|
the connector limiters, then the executor. Node's `listen()` calls it on
|
|
200
210
|
SIGTERM/SIGINT.
|
|
201
211
|
- **Structural mistakes throw at construction.** A duplicate connector id, an
|
|
202
|
-
invalid admission rule, `accessTokens`
|
|
203
|
-
executor: all refuse to boot. A deployment that starts in the wrong shape is
|
|
212
|
+
invalid admission rule, removed `accessTokens` option, or missing executor: all refuse to boot. A deployment that starts in the wrong shape is
|
|
204
213
|
worse than one that does not start.
|
|
205
214
|
|
|
206
215
|
## Tests that enforce this
|