@zackbart/connecta 0.24.4 → 0.25.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 +40 -10
- package/CHANGELOG.md +253 -0
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +317 -241
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +51 -20
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +206 -92
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +38 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +305 -260
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +27 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +50 -166
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +184 -10
- package/documentation/auth.md +80 -0
- package/documentation/code-mode.md +69 -24
- package/documentation/meta-tools.md +164 -27
- package/ethos.md +44 -47
- package/examples/worker/README.md +51 -5
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +6 -6
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -3
- package/templates/node/package.json +1 -1
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
package/dist/storage/memory.js
CHANGED
|
@@ -12,27 +12,28 @@ export function memoryStorage() {
|
|
|
12
12
|
}
|
|
13
13
|
return e;
|
|
14
14
|
};
|
|
15
|
+
const write = (key, value, ttlSeconds) => {
|
|
16
|
+
// Rotate through at most 16 existing keys. Live entries cannot keep an
|
|
17
|
+
// expired tail resident forever, and no request starts a background job.
|
|
18
|
+
for (let i = 0; i < 16; i++) {
|
|
19
|
+
const next = sweep.next();
|
|
20
|
+
if (next.done) {
|
|
21
|
+
sweep = map.keys();
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
fresh(next.value);
|
|
25
|
+
}
|
|
26
|
+
map.set(key, {
|
|
27
|
+
value,
|
|
28
|
+
...(ttlSeconds ? { exp: Date.now() + ttlSeconds * 1000 } : {}),
|
|
29
|
+
});
|
|
30
|
+
};
|
|
15
31
|
return {
|
|
16
32
|
async get(key) {
|
|
17
33
|
return fresh(key)?.value ?? null;
|
|
18
34
|
},
|
|
19
35
|
async set(key, value, opts) {
|
|
20
|
-
|
|
21
|
-
// expired tail resident forever, and no request starts a background job.
|
|
22
|
-
for (let i = 0; i < 16; i++) {
|
|
23
|
-
const next = sweep.next();
|
|
24
|
-
if (next.done) {
|
|
25
|
-
sweep = map.keys();
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
fresh(next.value);
|
|
29
|
-
}
|
|
30
|
-
map.set(key, {
|
|
31
|
-
value,
|
|
32
|
-
...(opts?.ttlSeconds
|
|
33
|
-
? { exp: Date.now() + opts.ttlSeconds * 1000 }
|
|
34
|
-
: {}),
|
|
35
|
-
});
|
|
36
|
+
write(key, value, opts?.ttlSeconds);
|
|
36
37
|
},
|
|
37
38
|
async delete(key) {
|
|
38
39
|
map.delete(key);
|
|
@@ -45,5 +46,16 @@ export function memoryStorage() {
|
|
|
45
46
|
})
|
|
46
47
|
.sort();
|
|
47
48
|
},
|
|
49
|
+
// Atomic because nothing between the read and the write yields: the body
|
|
50
|
+
// runs to completion before any other call on this map can start.
|
|
51
|
+
async compareAndSet(key, expected, next, opts) {
|
|
52
|
+
if ((fresh(key)?.value ?? null) !== expected)
|
|
53
|
+
return false;
|
|
54
|
+
if (next === null)
|
|
55
|
+
map.delete(key);
|
|
56
|
+
else
|
|
57
|
+
write(key, next, opts?.ttlSeconds);
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
48
60
|
};
|
|
49
61
|
}
|
package/dist/timeout.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The deadline vocabulary shared by downstream discovery probes and tool calls.
|
|
2
2
|
// One definition keeps those waits bounded consistently.
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
3
5
|
/**
|
|
4
6
|
* Generous default bound for a single downstream probe/catalog call. High enough
|
|
5
7
|
* to trip only on a pathological hang, not a realistically slow probe.
|
|
@@ -12,54 +14,22 @@ export function normalizeTimeoutMs(value) {
|
|
|
12
14
|
}
|
|
13
15
|
return Math.max(1, Math.trunc(value));
|
|
14
16
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
const timer = setTimeout(() => finish(true), ms);
|
|
28
|
-
const cancel = () => finish(false);
|
|
29
|
-
signal?.addEventListener("abort", cancel, { once: true });
|
|
30
|
-
if (signal?.aborted)
|
|
31
|
-
cancel();
|
|
32
|
-
});
|
|
33
|
-
}
|
|
17
|
+
/**
|
|
18
|
+
* Run one operation under a deadline and the caller's signal, whichever ends
|
|
19
|
+
* it first.
|
|
20
|
+
*
|
|
21
|
+
* The operation gets a signal of its own: the caller's abort is forwarded to
|
|
22
|
+
* it, and the deadline aborts it with `timeoutError`. Either way the promise
|
|
23
|
+
* rejects with that signal's reason, without waiting for work that ignores
|
|
24
|
+
* the signal. A synchronous throw rejects like an asynchronous one. This is
|
|
25
|
+
* the Promise face of `withDeadlineEffect` (src/runtime/run.ts), which owns
|
|
26
|
+
* the contract; an Effect caller uses that directly.
|
|
27
|
+
*/
|
|
34
28
|
export function withDeadline(operation, options) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
forwardAbort();
|
|
40
|
-
let rejectAbort;
|
|
41
|
-
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
|
42
|
-
const onAbort = () => rejectAbort(controller.signal.reason ?? options.timeoutError);
|
|
43
|
-
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
44
|
-
if (controller.signal.aborted)
|
|
45
|
-
onAbort();
|
|
46
|
-
const timer = options.timeoutMs === undefined
|
|
47
|
-
? undefined
|
|
48
|
-
: setTimeout(() => controller.abort(options.timeoutError), options.timeoutMs);
|
|
49
|
-
let work;
|
|
50
|
-
try {
|
|
51
|
-
work = operation(controller.signal);
|
|
52
|
-
}
|
|
53
|
-
catch (error) {
|
|
54
|
-
work = Promise.reject(error);
|
|
55
|
-
}
|
|
56
|
-
return Promise.race([work, aborted]).finally(() => {
|
|
57
|
-
controller.abort();
|
|
58
|
-
if (timer)
|
|
59
|
-
clearTimeout(timer);
|
|
60
|
-
controller.signal.removeEventListener("abort", onAbort);
|
|
61
|
-
options.signal?.removeEventListener("abort", forwardAbort);
|
|
62
|
-
});
|
|
29
|
+
return runEdge(withDeadlineEffect((signal) => Effect.tryPromise({
|
|
30
|
+
try: () => operation(signal),
|
|
31
|
+
catch: (error) => error,
|
|
32
|
+
}), options));
|
|
63
33
|
}
|
|
64
34
|
/**
|
|
65
35
|
* Give one operation a caller-facing deadline and the matching cancellation
|
package/dist/types.d.ts
CHANGED
|
@@ -12,6 +12,26 @@ export interface KVStorage {
|
|
|
12
12
|
* subsystems that need independent, enumerable records require it explicitly.
|
|
13
13
|
*/
|
|
14
14
|
list?(prefix: string): Promise<string[]>;
|
|
15
|
+
/**
|
|
16
|
+
* Atomic compare-and-set: write `next` only if the key's current value is
|
|
17
|
+
* exactly `expected`, and report whether the write happened. `expected:
|
|
18
|
+
* null` means "absent", and an expired entry is absent. `next: null`
|
|
19
|
+
* deletes; otherwise `next` is stored with `options.ttlSeconds`, or with no
|
|
20
|
+
* expiry when that is omitted, exactly as `set` would. On `false` nothing
|
|
21
|
+
* changed.
|
|
22
|
+
*
|
|
23
|
+
* Atomic means linearizable against every other operation on the same
|
|
24
|
+
* store, `set` and `delete` included: of N concurrent `compareAndSet(key,
|
|
25
|
+
* null, value)` claims on an absent key, exactly one returns true.
|
|
26
|
+
*
|
|
27
|
+
* Optional, because not every backend can promise it: an eventually
|
|
28
|
+
* consistent store such as Cloudflare Workers KV must omit the method
|
|
29
|
+
* rather than emulate it with a read followed by a write. Subsystems that
|
|
30
|
+
* need an atomic claim require it explicitly.
|
|
31
|
+
*/
|
|
32
|
+
compareAndSet?(key: string, expected: string | null, next: string | null, options?: {
|
|
33
|
+
ttlSeconds?: number;
|
|
34
|
+
}): Promise<boolean>;
|
|
15
35
|
}
|
|
16
36
|
export interface Logger {
|
|
17
37
|
debug(...args: unknown[]): void;
|
|
@@ -106,7 +126,7 @@ export interface ConnectorCredentialFieldConfig {
|
|
|
106
126
|
name: string;
|
|
107
127
|
/** Short field label, e.g. "Account email". */
|
|
108
128
|
label: string;
|
|
109
|
-
/** Plain-language guidance shown in
|
|
129
|
+
/** Plain-language guidance shown in the connection's credential form in the operator UI. Never include the credential itself. */
|
|
110
130
|
description?: string;
|
|
111
131
|
/** Input placeholder, e.g. "you@example.com". */
|
|
112
132
|
placeholder?: string;
|
|
@@ -117,7 +137,7 @@ export interface ConnectorCredentialFieldConfig {
|
|
|
117
137
|
export interface ConnectorCredentialConfig {
|
|
118
138
|
/** Short group or field label, e.g. "API token" or "Service credentials". */
|
|
119
139
|
label: string;
|
|
120
|
-
/** Plain-language guidance shown in
|
|
140
|
+
/** Plain-language guidance shown in the connection's credential form in the operator UI. Never include the credential itself. */
|
|
121
141
|
description?: string;
|
|
122
142
|
/** Password-field placeholder, e.g. "Paste API token". */
|
|
123
143
|
placeholder?: string;
|
|
@@ -142,7 +162,7 @@ export interface ConnectorContext {
|
|
|
142
162
|
/**
|
|
143
163
|
* Read-only access to this connector's human-managed credential. Present
|
|
144
164
|
* only when the connector declares `credential` and the deployment configures
|
|
145
|
-
* `
|
|
165
|
+
* `vault`.
|
|
146
166
|
*/
|
|
147
167
|
credential?: ConnectorCredentialAccess;
|
|
148
168
|
/**
|
|
@@ -225,7 +245,7 @@ export interface Connector {
|
|
|
225
245
|
* Max inline result size (bytes) for this connector's tools before
|
|
226
246
|
* call_tool truncates and stashes the full text for get_result
|
|
227
247
|
* paging. Overrides `ConnectaConfig.calls.maxResultBytes`;
|
|
228
|
-
* omit to inherit it (which itself defaults to
|
|
248
|
+
* omit to inherit it (which itself defaults to 24_000). Must be a whole
|
|
229
249
|
* number of bytes >= 1; anything else warns at startup and is ignored, so
|
|
230
250
|
* the connector inherits the deployment-wide cap.
|
|
231
251
|
*/
|
|
@@ -248,11 +268,11 @@ export interface Connector {
|
|
|
248
268
|
* configuration; no runtime registration or shared mutable copy exists.
|
|
249
269
|
*/
|
|
250
270
|
usageGuide?: string | ConnectorUsageGuide;
|
|
251
|
-
/** Optional human-managed credential slot rendered
|
|
271
|
+
/** Optional human-managed credential slot rendered in the connection in the operator UI. */
|
|
252
272
|
credential?: ConnectorCredentialConfig;
|
|
253
|
-
/** Optional server-side check used by
|
|
273
|
+
/** Optional server-side check used by the connection's Test action in the operator UI. */
|
|
254
274
|
testCredential?(value: string, ctx: ConnectorContext): Promise<CredentialTestResult>;
|
|
255
|
-
/** Optional multi-field credential check used by
|
|
275
|
+
/** Optional multi-field credential check used by the connection's Test action in the operator UI. */
|
|
256
276
|
testCredentials?(values: ConnectorCredentialValues, ctx: ConnectorContext): Promise<CredentialTestResult>;
|
|
257
277
|
/**
|
|
258
278
|
* Statically-known tool defs, exposed by in-code connectors (`api()`) for
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import type { OperatorSurface } from "./module-contracts.js";
|
|
2
2
|
import type { CredentialVault } from "./credential-contract.js";
|
|
3
3
|
import { type DeferredWork } from "./connector-scope.js";
|
|
4
|
-
import { type CredentialManagementCapability, type UiData } from "./operator-ui/model.js";
|
|
4
|
+
import { type CredentialManagementCapability, type UiData, type UiProblem, type UiToolSafety } from "./operator-ui/model.js";
|
|
5
5
|
import type { RegistryView } from "./registry.js";
|
|
6
|
-
import type { ConnectaBranding, UiAuthConfig } from "./types.js";
|
|
6
|
+
import type { ConnectaBranding, Connector, ConnectorStatus, ToolDef, UiAuthConfig } from "./types.js";
|
|
7
7
|
export { filterUiConnectors, type CredentialManagementCapability, type UiConnector, type UiData, } from "./operator-ui/model.js";
|
|
8
8
|
export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
|
|
9
9
|
export type OperatorPage = "connections" | "activity";
|
|
10
10
|
export declare function operatorPageForPath(path: string): OperatorPage | undefined;
|
|
11
11
|
export declare function operatorPageTitle(page: OperatorPage, configuredTitle: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* The badge a tool earns, from the one predicate core enforces with. Computed
|
|
14
|
+
* here so the page reports the rule rather than restating it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function uiToolSafety(definition: ToolDef): UiToolSafety;
|
|
17
|
+
/**
|
|
18
|
+
* The fix-prompt key for a connector that is not usable. Chosen from the
|
|
19
|
+
* status state and the connector's declared auth shape — configuration, never
|
|
20
|
+
* the status message, which can carry a downstream error body.
|
|
21
|
+
*/
|
|
22
|
+
export declare function uiProblemFor(connector: Pick<Connector, "credential" | "startAuth">, status: ConnectorStatus["state"], observed: {
|
|
23
|
+
credentialDrift: boolean;
|
|
24
|
+
catalogFailed: boolean;
|
|
25
|
+
}): UiProblem | undefined;
|
|
12
26
|
export declare function credentialManagementCapability(input: {
|
|
13
27
|
eligibleOperator: boolean;
|
|
14
28
|
hasCredentialSlots: boolean;
|
package/dist/ui.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { routeUi } from "./routes/ui.js";
|
|
2
2
|
import { routeCredentials } from "./routes/credentials.js";
|
|
3
3
|
import { routeOAuthManagement } from "./routes/oauth-management.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { mapSettledWithConcurrency, resolveDiscoveryConcurrency, } from "./concurrency.js";
|
|
4
|
+
import { uiData } from "./routes/ui-data.js";
|
|
5
|
+
import { runEdge } from "./runtime/run.js";
|
|
6
|
+
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
8
7
|
import { OPERATOR_UI_CSS, OPERATOR_UI_SCRIPT, } from "./operator-ui/generated.js";
|
|
9
|
-
import { CONNECTA_VERSION } from "./version.js";
|
|
10
8
|
export { filterUiConnectors, } from "./operator-ui/model.js";
|
|
11
9
|
import { resolveBranding, isSafeHttpsUrl, themeCss } from "./branding.js";
|
|
12
10
|
export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
|
|
@@ -31,6 +29,30 @@ export function operatorPageForPath(path) {
|
|
|
31
29
|
export function operatorPageTitle(page, configuredTitle) {
|
|
32
30
|
return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
|
|
33
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The badge a tool earns, from the one predicate core enforces with. Computed
|
|
34
|
+
* here so the page reports the rule rather than restating it.
|
|
35
|
+
*/
|
|
36
|
+
export function uiToolSafety(definition) {
|
|
37
|
+
return isExplicitlyReadOnly(definition) ? "runs_in_programs" : "needs_approval";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The fix-prompt key for a connector that is not usable. Chosen from the
|
|
41
|
+
* status state and the connector's declared auth shape — configuration, never
|
|
42
|
+
* the status message, which can carry a downstream error body.
|
|
43
|
+
*/
|
|
44
|
+
export function uiProblemFor(connector, status, observed) {
|
|
45
|
+
if (observed.credentialDrift)
|
|
46
|
+
return "credential_mismatch";
|
|
47
|
+
if (status === "error")
|
|
48
|
+
return "connector_unavailable";
|
|
49
|
+
if (status === "auth_required") {
|
|
50
|
+
if (connector.startAuth)
|
|
51
|
+
return "oauth_required";
|
|
52
|
+
return connector.credential ? "credential_required" : "auth_required";
|
|
53
|
+
}
|
|
54
|
+
return observed.catalogFailed ? "catalog_failed" : undefined;
|
|
55
|
+
}
|
|
34
56
|
export function credentialManagementCapability(input) {
|
|
35
57
|
if (!input.eligibleOperator)
|
|
36
58
|
return "requires_operator";
|
|
@@ -48,169 +70,17 @@ export function credentialManagementCapability(input) {
|
|
|
48
70
|
export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false, credentialManagement = credentialVault
|
|
49
71
|
? "available"
|
|
50
72
|
: "requires_operator", defer, oauthManagement = false, discoveryConcurrency, personalCredentialOwner, detailOptions = {}) {
|
|
51
|
-
|
|
52
|
-
const connectorSet = registry.listConnectors();
|
|
53
|
-
const concurrency = resolveDiscoveryConcurrency(discoveryConcurrency);
|
|
54
|
-
const settled = await mapSettledWithConcurrency(connectorSet, concurrency, async (c) => {
|
|
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();
|
|
157
|
-
return {
|
|
158
|
-
id: c.id,
|
|
159
|
-
authScope: c.authScope ?? "shared",
|
|
160
|
-
...(c.title ? { title: c.title } : {}),
|
|
161
|
-
...(c.description !== undefined
|
|
162
|
-
? { description: c.description }
|
|
163
|
-
: {}),
|
|
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 }
|
|
175
|
-
: {}),
|
|
176
|
-
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
177
|
-
...(credential ? { credential } : {}),
|
|
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."),
|
|
183
|
-
});
|
|
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: [],
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
finally {
|
|
198
|
-
await closeConnectorScope(c, registry.contextFor(c.id, baseUrl, requestScope), defer);
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
const connectors = settled.map((result) => {
|
|
202
|
-
if (result.status === "rejected")
|
|
203
|
-
throw result.reason;
|
|
204
|
-
return result.value;
|
|
205
|
-
});
|
|
206
|
-
return {
|
|
73
|
+
return runEdge(uiData(registry, baseUrl, {
|
|
207
74
|
serverInfo,
|
|
208
|
-
|
|
209
|
-
connectors,
|
|
75
|
+
credentialVault,
|
|
210
76
|
activityEnabled,
|
|
211
77
|
credentialManagement,
|
|
212
|
-
|
|
213
|
-
|
|
78
|
+
defer,
|
|
79
|
+
oauthManagement,
|
|
80
|
+
discoveryConcurrency,
|
|
81
|
+
personalCredentialOwner,
|
|
82
|
+
...detailOptions,
|
|
83
|
+
}));
|
|
214
84
|
}
|
|
215
85
|
function escapeHtmlAttr(value) {
|
|
216
86
|
return value
|
|
@@ -335,15 +205,29 @@ ${OPERATOR_UI_SCRIPT}</script>
|
|
|
335
205
|
</body>
|
|
336
206
|
</html>`;
|
|
337
207
|
}
|
|
208
|
+
function ownsOperatorPath(reserved, path) {
|
|
209
|
+
if (path === "/activity")
|
|
210
|
+
return true;
|
|
211
|
+
return reserved.some((pattern) => pattern.endsWith("/*")
|
|
212
|
+
? path.startsWith(pattern.slice(0, -1))
|
|
213
|
+
: path === pattern);
|
|
214
|
+
}
|
|
338
215
|
/** Mount the connection UI without enabling any storage or activity module. */
|
|
339
216
|
export function operatorUi(options = {}) {
|
|
217
|
+
const reservedPaths = ["/", "/ui", "/ui/*", "/favicon.svg", "/favicon.ico"];
|
|
340
218
|
return {
|
|
341
219
|
...options,
|
|
342
|
-
reservedPaths
|
|
220
|
+
reservedPaths,
|
|
343
221
|
credentialHandoffUrl(baseUrl) {
|
|
344
222
|
return new URL("/", baseUrl).toString();
|
|
345
223
|
},
|
|
346
224
|
async handle(context) {
|
|
225
|
+
// Everything else belongs to the server, and reaches it untouched: no
|
|
226
|
+
// route here, and no activity module, runs for a path this surface does
|
|
227
|
+
// not own. The Activity page is the one addition; the server reserves
|
|
228
|
+
// it only when history is readable, and routeUi checks the same thing.
|
|
229
|
+
if (!ownsOperatorPath(reservedPaths, context.path))
|
|
230
|
+
return null;
|
|
347
231
|
const routes = [
|
|
348
232
|
...(context.opts.credentialVault ? [routeCredentials] : []),
|
|
349
233
|
routeOAuthManagement,
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntactic destination checks for URLs a downstream hands connecta to fetch.
|
|
3
|
+
*
|
|
4
|
+
* A remote MCP server's OAuth metadata names further URLs — authorization
|
|
5
|
+
* servers, token and registration endpoints — that connecta then requests
|
|
6
|
+
* server-side. Config is the security model: the connector's configured
|
|
7
|
+
* origin is trusted because an operator wrote it down. Everything else was
|
|
8
|
+
* learned from the downstream, and a compromised downstream must not be able
|
|
9
|
+
* to steer connecta at the host's own network (cloud metadata, a LAN admin
|
|
10
|
+
* panel, a sidecar on loopback).
|
|
11
|
+
*
|
|
12
|
+
* The check is by syntax only. The WHATWG URL parser has already folded every
|
|
13
|
+
* legacy IPv4 spelling (`2130706433`, `0x7f.1`, `0177.0.0.1`) into dotted
|
|
14
|
+
* decimal and every IPv6 literal into compressed hex, so one canonical form is
|
|
15
|
+
* all this module reads. It never resolves a name: that needs DNS, which the
|
|
16
|
+
* Workers-safe core cannot do, and a name that resolves somewhere private — or
|
|
17
|
+
* rebinds after the check — is out of scope. Pure Web API, no `node:` import.
|
|
18
|
+
*/
|
|
19
|
+
function classifyIpv4(octets) {
|
|
20
|
+
const [a = 0, b = 0] = octets;
|
|
21
|
+
if (a === 127)
|
|
22
|
+
return "loopback";
|
|
23
|
+
if (a === 0 ||
|
|
24
|
+
a === 10 ||
|
|
25
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
26
|
+
(a === 192 && b === 168) ||
|
|
27
|
+
(a === 169 && b === 254) ||
|
|
28
|
+
(a === 100 && b >= 64 && b <= 127)) {
|
|
29
|
+
return "private";
|
|
30
|
+
}
|
|
31
|
+
return "public";
|
|
32
|
+
}
|
|
33
|
+
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
34
|
+
const HEXTET = /^[0-9a-f]{1,4}$/;
|
|
35
|
+
function ipv4Octets(host) {
|
|
36
|
+
const match = IPV4.exec(host);
|
|
37
|
+
if (!match)
|
|
38
|
+
return undefined;
|
|
39
|
+
const octets = match.slice(1).map(Number);
|
|
40
|
+
return octets.every((octet) => octet <= 255) ? octets : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** Eight 16-bit pieces of a bracketed IPv6 literal, or undefined. */
|
|
43
|
+
function ipv6Pieces(host) {
|
|
44
|
+
const body = host.slice(1, -1);
|
|
45
|
+
const halves = body.split("::");
|
|
46
|
+
if (halves.length > 2)
|
|
47
|
+
return undefined;
|
|
48
|
+
const pieces = (part) => {
|
|
49
|
+
if (!part)
|
|
50
|
+
return [];
|
|
51
|
+
const parsed = part.split(":").map((piece) => HEXTET.test(piece) ? Number.parseInt(piece, 16) : Number.NaN);
|
|
52
|
+
return parsed.some(Number.isNaN) ? undefined : parsed;
|
|
53
|
+
};
|
|
54
|
+
const head = pieces(halves[0]);
|
|
55
|
+
const tail = pieces(halves[1]);
|
|
56
|
+
if (!head || !tail)
|
|
57
|
+
return undefined;
|
|
58
|
+
const missing = 8 - head.length - tail.length;
|
|
59
|
+
if (halves.length === 1 ? missing !== 0 : missing < 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
return [...head, ...Array.from({ length: missing }, () => 0), ...tail];
|
|
62
|
+
}
|
|
63
|
+
function classifyIpv6(pieces) {
|
|
64
|
+
const [first = 0] = pieces;
|
|
65
|
+
const leadingZeros = pieces.slice(0, 5).every((piece) => piece === 0);
|
|
66
|
+
if (leadingZeros && pieces[5] === 0 && pieces[6] === 0) {
|
|
67
|
+
if (pieces[7] === 0)
|
|
68
|
+
return "private"; // ::
|
|
69
|
+
if (pieces[7] === 1)
|
|
70
|
+
return "loopback"; // ::1
|
|
71
|
+
}
|
|
72
|
+
// ::ffff:a.b.c.d reaches the mapped IPv4 destination; classify that.
|
|
73
|
+
if (leadingZeros && pieces[5] === 0xffff) {
|
|
74
|
+
const high = pieces[6] ?? 0;
|
|
75
|
+
const low = pieces[7] ?? 0;
|
|
76
|
+
return classifyIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]);
|
|
77
|
+
}
|
|
78
|
+
if ((first & 0xfe00) === 0xfc00)
|
|
79
|
+
return "private"; // fc00::/7
|
|
80
|
+
if ((first & 0xffc0) === 0xfe80)
|
|
81
|
+
return "private"; // fe80::/10
|
|
82
|
+
return "public";
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The hostname the WHATWG parser settles on. Idempotent for a `URL.hostname`;
|
|
86
|
+
* anything else — a bare IPv6 literal, a legacy IPv4 spelling — is folded the
|
|
87
|
+
* same way the parser would fold it on the way to a fetch.
|
|
88
|
+
*/
|
|
89
|
+
function canonicalHost(hostname) {
|
|
90
|
+
if (/[/\\?#@]/.test(hostname))
|
|
91
|
+
return undefined;
|
|
92
|
+
const bracketed = hostname.includes(":") && !hostname.startsWith("[")
|
|
93
|
+
? `[${hostname}]`
|
|
94
|
+
: hostname;
|
|
95
|
+
try {
|
|
96
|
+
return new URL(`http://${bracketed}/`).hostname;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Classify a URL host by syntax. A host that does not parse is `private`:
|
|
104
|
+
* the check fails closed rather than guessing.
|
|
105
|
+
*/
|
|
106
|
+
export function classifyHost(hostname) {
|
|
107
|
+
const host = canonicalHost(hostname);
|
|
108
|
+
if (!host)
|
|
109
|
+
return "private";
|
|
110
|
+
if (host.startsWith("[")) {
|
|
111
|
+
const pieces = ipv6Pieces(host);
|
|
112
|
+
return pieces ? classifyIpv6(pieces) : "private";
|
|
113
|
+
}
|
|
114
|
+
const octets = ipv4Octets(host);
|
|
115
|
+
if (octets)
|
|
116
|
+
return classifyIpv4(octets);
|
|
117
|
+
const name = host.endsWith(".") ? host.slice(0, -1) : host;
|
|
118
|
+
if (name === "localhost" || name.endsWith(".localhost"))
|
|
119
|
+
return "loopback";
|
|
120
|
+
return "public";
|
|
121
|
+
}
|
|
122
|
+
/** True for loopback and private hosts alike. */
|
|
123
|
+
export function isPrivateHost(hostname) {
|
|
124
|
+
return classifyHost(hostname) !== "public";
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Why connecta must not fetch `target`, a URL the downstream advertised, for a
|
|
128
|
+
* connector configured at `configured` — or undefined when it may.
|
|
129
|
+
*
|
|
130
|
+
* - The configured origin is trusted outright: the operator chose it.
|
|
131
|
+
* - Anything else must be `https` on a host that is not private by syntax.
|
|
132
|
+
* - A loopback-configured connector (local development) may also learn
|
|
133
|
+
* loopback URLs, over `http` too, so a local MCP server with a local
|
|
134
|
+
* authorization server keeps working. It still may not learn a LAN or
|
|
135
|
+
* link-local address.
|
|
136
|
+
*
|
|
137
|
+
* The reason names only the host, never the path, query, or credentials a
|
|
138
|
+
* learned URL may carry.
|
|
139
|
+
*/
|
|
140
|
+
export function learnedUrlRefusal(configured, target) {
|
|
141
|
+
if (target.protocol !== "https:" && target.protocol !== "http:") {
|
|
142
|
+
return `${target.host || "the target"} uses ${target.protocol}, not HTTPS`;
|
|
143
|
+
}
|
|
144
|
+
if (target.origin === configured.origin)
|
|
145
|
+
return undefined;
|
|
146
|
+
const targetClass = classifyHost(target.hostname);
|
|
147
|
+
if (targetClass === "loopback" &&
|
|
148
|
+
classifyHost(configured.hostname) === "loopback") {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
if (targetClass !== "public") {
|
|
152
|
+
return `${target.host} is a private, loopback, or link-local address`;
|
|
153
|
+
}
|
|
154
|
+
if (target.protocol !== "https:")
|
|
155
|
+
return `${target.host} is not HTTPS`;
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
package/dist/version.d.ts
CHANGED