@zackbart/connecta 0.24.3 → 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 +58 -30
- package/CHANGELOG.md +317 -1
- package/README.md +5 -6
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- 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 +3 -3
- package/dist/connectors/api.js +10 -2
- package/dist/connectors/guarded-fetch.js +34 -4
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +325 -245
- 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/errors.d.ts +11 -3
- package/dist/errors.js +2 -1
- 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 +11 -2
- package/dist/index.js +63 -21
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +304 -114
- 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 +109 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +85 -12
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +70 -5
- package/dist/providers/stripe.d.ts +10 -3
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +12 -2
- package/dist/registry.d.ts +13 -5
- package/dist/registry.js +317 -258
- 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 +64 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +68 -176
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +368 -182
- package/documentation/auth.md +277 -176
- package/documentation/code-mode.md +479 -329
- package/documentation/meta-tools.md +494 -417
- package/ethos.md +44 -47
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +62 -14
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +18 -21
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -4
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- 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 -92
- 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 -151
- 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/documentation/call-admission.md +0 -158
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -431
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -729
- package/documentation/request-admission.md +0 -204
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -254
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -768
- package/documentation/vercel.md +0 -241
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The Effect plumbing the operator and activity data routes share.
|
|
2
|
+
//
|
|
3
|
+
// Each JSON route under /ui/* is one Effect program, run at its Promise edge
|
|
4
|
+
// by serveOperator. A route that settles its answer early — a refusal, or an
|
|
5
|
+
// auth provider's own challenge — fails with an Answer carrying that exact
|
|
6
|
+
// Response, and the edge serves it. That is the whole error model: every
|
|
7
|
+
// status, body, and header is still built by privateJson or by the provider,
|
|
8
|
+
// so the wire format cannot drift from what these routes have always sent.
|
|
9
|
+
// Anything else that goes wrong is a defect and rejects the edge, as a throw
|
|
10
|
+
// from the async handlers did.
|
|
11
|
+
//
|
|
12
|
+
// Effect's HttpApi was measured for this and not used (P1-S18). Declaring
|
|
13
|
+
// these seven endpoints added about 101 KB gzip to ./ui and 131 KB to
|
|
14
|
+
// ./activity, most of it Schema, and matching the wire format would have
|
|
15
|
+
// meant opting out of nearly everything it does: unowned paths fall through
|
|
16
|
+
// to the next module rather than 404, a wrong method is a JSON 405, the
|
|
17
|
+
// Content-Type and size checks run before a body is read, and a credential
|
|
18
|
+
// field error names the field in words the page already shows. Plain routing
|
|
19
|
+
// over one error channel keeps the behavior and costs nothing, because ./ui
|
|
20
|
+
// already carries Effect through its deadlines.
|
|
21
|
+
//
|
|
22
|
+
// Imported by the /ui and /activity graphs only; never from the root entry.
|
|
23
|
+
import { Effect } from "effect";
|
|
24
|
+
import { runEdge } from "../runtime/run.js";
|
|
25
|
+
import { authorize, authorizeUiIdentity, msg, privateJson, validateAuthPermissions, } from "./shared.js";
|
|
26
|
+
/** A response the route has already decided on. */
|
|
27
|
+
export class Answer {
|
|
28
|
+
response;
|
|
29
|
+
_tag = "Answer";
|
|
30
|
+
constructor(response) {
|
|
31
|
+
this.response = response;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** End the route with a private JSON error, as every route here does. */
|
|
35
|
+
export function refuse(error, status) {
|
|
36
|
+
return Effect.fail(new Answer(privateJson({ error }, { status })));
|
|
37
|
+
}
|
|
38
|
+
function admitted(result) {
|
|
39
|
+
return Effect.flatMap(Effect.promise(() => result), (authz) => authz.ok ? Effect.succeed(authz) : Effect.fail(new Answer(authz.response)));
|
|
40
|
+
}
|
|
41
|
+
/** Any configured caller. The gate's refusal, challenge headers and all, is the answer. */
|
|
42
|
+
export function authorized({ request, baseUrl, opts, runtimeContext }, partitionIdentity = true) {
|
|
43
|
+
return Effect.suspend(() => admitted(authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity, partitionIdentity)));
|
|
44
|
+
}
|
|
45
|
+
/** A signed-in person, for a route that changes how a connector authenticates. */
|
|
46
|
+
export function authorizedPerson({ request, baseUrl, opts, runtimeContext }, purpose) {
|
|
47
|
+
return Effect.suspend(() => admitted(authorizeUiIdentity(request, baseUrl, opts.auth, purpose, runtimeContext, opts.identity)));
|
|
48
|
+
}
|
|
49
|
+
/** The partition and tool grants an identity carries into a registry view. */
|
|
50
|
+
export function scopeFor(authz, connectorIds = authz.connectorIds) {
|
|
51
|
+
return {
|
|
52
|
+
connectorIds,
|
|
53
|
+
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
54
|
+
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
55
|
+
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The registry as this identity may see it. A configured permission naming a
|
|
60
|
+
* connector that does not exist refuses the whole view, management rights
|
|
61
|
+
* included, rather than quietly granting what remains.
|
|
62
|
+
*/
|
|
63
|
+
export function visibleRegistry({ opts }, authz) {
|
|
64
|
+
return Effect.try({
|
|
65
|
+
try: () => {
|
|
66
|
+
validateAuthPermissions(authz, opts.registry);
|
|
67
|
+
return opts.registry.scoped(scopeFor(authz));
|
|
68
|
+
},
|
|
69
|
+
catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Run one route program to its Response.
|
|
74
|
+
*
|
|
75
|
+
* A read passes the request's signal, so a caller who leaves stops whatever
|
|
76
|
+
* the read was waiting on. A mutation passes none: once a vault write or an
|
|
77
|
+
* OAuth disconnect has started, the cache invalidation that follows it must
|
|
78
|
+
* run too, whether or not anyone is still waiting for the answer.
|
|
79
|
+
*/
|
|
80
|
+
export function serveOperator(program, signal) {
|
|
81
|
+
return runEdge(Effect.catch(program, (answer) => Effect.succeed(answer.response)), signal ? { signal } : undefined);
|
|
82
|
+
}
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ServerOptions {
|
|
|
37
37
|
maxHostCalls?: number | undefined;
|
|
38
38
|
/** Deadline per execute_code host call. Default 15_000. */
|
|
39
39
|
hostCallTimeoutMs?: number | undefined;
|
|
40
|
+
/** Hard ceiling on one execute_code run, outside the sandbox. Default 120_000. */
|
|
41
|
+
watchdogMs?: number | undefined;
|
|
40
42
|
/** Required sandbox backing the execute_code meta-tool. */
|
|
41
43
|
executor: Executor;
|
|
42
44
|
/** Sanitized identity of the configured sandbox, when it has one. */
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The Effect core of the operator payload: `buildUiData` in src/ui.ts is its
|
|
2
|
+
// Promise face, and the `/ui/connectors/<id>` route yields it directly.
|
|
3
|
+
//
|
|
4
|
+
// Every visible connector is one slot of a bounded fan-out: its probe runs
|
|
5
|
+
// under a deadline, any failure becomes that connector's error row rather
|
|
6
|
+
// than the payload's, and its connector scope closes as the slot ends,
|
|
7
|
+
// however it ends.
|
|
8
|
+
import { Effect } from "effect";
|
|
9
|
+
import { resolveDiscoveryConcurrency } from "../concurrency.js";
|
|
10
|
+
import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "../credential-rules.js";
|
|
11
|
+
import { closeScope } from "../runtime/connector-scope.js";
|
|
12
|
+
import { withDeadlineEffect } from "../runtime/run.js";
|
|
13
|
+
import { uiProblemFor, uiToolSafety } from "../ui.js";
|
|
14
|
+
import { CONNECTA_VERSION } from "../version.js";
|
|
15
|
+
const DETAIL_TIMEOUT_MS = 30_000;
|
|
16
|
+
const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
|
|
17
|
+
function attempt(operation) {
|
|
18
|
+
return Effect.tryPromise({ try: operation, catch: (error) => error });
|
|
19
|
+
}
|
|
20
|
+
export function uiData(registry, baseUrl, options) {
|
|
21
|
+
return Effect.suspend(() => {
|
|
22
|
+
const requestScope = {};
|
|
23
|
+
const vault = options.credentialVault;
|
|
24
|
+
const owner = options.personalCredentialOwner;
|
|
25
|
+
/**
|
|
26
|
+
* A connector's status message stops here. It can quote a downstream error
|
|
27
|
+
* body — and a downstream error body can quote the secret it just rejected —
|
|
28
|
+
* so the payload carries only the classified `problem` and the raw text goes
|
|
29
|
+
* to the deployment's log, where an operator debugging the failure already
|
|
30
|
+
* looks. An `ok` status's message is informational and simply dropped.
|
|
31
|
+
*/
|
|
32
|
+
const logStatus = (id, state, message) => {
|
|
33
|
+
if (!message || state === "ok")
|
|
34
|
+
return;
|
|
35
|
+
const logger = registry.contextFor(id, baseUrl, requestScope).logger;
|
|
36
|
+
const line = `[connecta] connector "${id}" operator status ${state}: ${message}`;
|
|
37
|
+
if (state === "auth_required")
|
|
38
|
+
logger.info(line);
|
|
39
|
+
else
|
|
40
|
+
logger.warn(line);
|
|
41
|
+
};
|
|
42
|
+
// Status first, then the catalog only when status is ok. A failed status
|
|
43
|
+
// read is an error status, not a failed row: the row still reports the
|
|
44
|
+
// connector's credential.
|
|
45
|
+
const observe = (c, drift, signal) => {
|
|
46
|
+
if (drift) {
|
|
47
|
+
return Effect.succeed({
|
|
48
|
+
status: { state: "auth_required", message: drift },
|
|
49
|
+
tools: [],
|
|
50
|
+
catalogFailed: false,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return Effect.gen(function* () {
|
|
54
|
+
const status = yield* attempt(() => registry.statusFor(c.id, baseUrl, requestScope, { signal }));
|
|
55
|
+
if (status.state !== "ok" || signal.aborted) {
|
|
56
|
+
return { status, tools: [], catalogFailed: false };
|
|
57
|
+
}
|
|
58
|
+
return yield* attempt(async () => (await registry.getTools(c.id, baseUrl, requestScope, { signal })).map((t) => ({
|
|
59
|
+
name: t.name,
|
|
60
|
+
address: `${c.id}.${t.name}`,
|
|
61
|
+
...(t.description ? { description: t.description } : {}),
|
|
62
|
+
safety: uiToolSafety(t),
|
|
63
|
+
}))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
|
|
64
|
+
// The registry owns the failed catalog observation; the page only
|
|
65
|
+
// needs to know there was one.
|
|
66
|
+
Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })));
|
|
67
|
+
}).pipe(Effect.catch((error) => Effect.succeed({
|
|
68
|
+
status: {
|
|
69
|
+
state: "error",
|
|
70
|
+
message: error instanceof Error
|
|
71
|
+
? error.message
|
|
72
|
+
: "Connection details unavailable",
|
|
73
|
+
},
|
|
74
|
+
tools: [],
|
|
75
|
+
catalogFailed: false,
|
|
76
|
+
})));
|
|
77
|
+
};
|
|
78
|
+
// The credential card, for someone who may manage this connector's auth.
|
|
79
|
+
// Never fails: a vault that cannot be read is the card's own error.
|
|
80
|
+
const credentialFor = (c) => {
|
|
81
|
+
const mayManageAuth = options.mayManage?.(c.id) ??
|
|
82
|
+
(c.authScope === "personal" ? Boolean(owner) : options.oauthManagement);
|
|
83
|
+
const declared = c.credential;
|
|
84
|
+
if (!declared || !vault || !mayManageAuth)
|
|
85
|
+
return Effect.succeed(undefined);
|
|
86
|
+
// One rule, shared with the test route: only the hook matching the
|
|
87
|
+
// declared credential shape can run, so the button is offered only
|
|
88
|
+
// where a click can succeed (src/credentials.ts).
|
|
89
|
+
const testRule = credentialTestRule(c);
|
|
90
|
+
const credentialFields = (metadata) => declared.fields?.map((field) => {
|
|
91
|
+
const fieldMetadata = metadata?.fields?.[field.name];
|
|
92
|
+
return {
|
|
93
|
+
name: field.name,
|
|
94
|
+
label: field.label,
|
|
95
|
+
...(field.description ? { description: field.description } : {}),
|
|
96
|
+
...(field.placeholder ? { placeholder: field.placeholder } : {}),
|
|
97
|
+
inputType: field.inputType ?? "password",
|
|
98
|
+
configured: Boolean(fieldMetadata),
|
|
99
|
+
...(fieldMetadata
|
|
100
|
+
? {
|
|
101
|
+
lastFour: fieldMetadata.lastFour,
|
|
102
|
+
updatedAt: fieldMetadata.updatedAt,
|
|
103
|
+
}
|
|
104
|
+
: {}),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
const card = {
|
|
108
|
+
label: declared.label,
|
|
109
|
+
...(declared.description ? { description: declared.description } : {}),
|
|
110
|
+
...(declared.placeholder ? { placeholder: declared.placeholder } : {}),
|
|
111
|
+
};
|
|
112
|
+
return attempt(async () => {
|
|
113
|
+
const metadata = await vault.metadata(c.id, c.authScope === "personal" ? owner : undefined);
|
|
114
|
+
const fields = credentialFields(metadata);
|
|
115
|
+
const shape = storedCredentialShape(declared, metadata?.fields ?? null);
|
|
116
|
+
return {
|
|
117
|
+
...card,
|
|
118
|
+
...(fields?.length ? { fields } : {}),
|
|
119
|
+
configured: shape.state === "valid",
|
|
120
|
+
removable: Boolean(metadata),
|
|
121
|
+
...(metadata
|
|
122
|
+
? { lastFour: metadata.lastFour, updatedAt: metadata.updatedAt }
|
|
123
|
+
: {}),
|
|
124
|
+
testable: testRule.mode !== null && shape.state !== "mismatch",
|
|
125
|
+
...(shape.state === "mismatch"
|
|
126
|
+
? { error: shape.message, problem: "credential_mismatch" }
|
|
127
|
+
: {}),
|
|
128
|
+
// A dropped field leaves its secret in the vault, and the field
|
|
129
|
+
// list below only renders fields the connector still declares —
|
|
130
|
+
// so without this line there is nowhere an operator could see it.
|
|
131
|
+
...(shape.state === "valid" && shape.undeclared.length
|
|
132
|
+
? { notice: describeUndeclaredCredentialFields(shape.undeclared) }
|
|
133
|
+
: {}),
|
|
134
|
+
};
|
|
135
|
+
}).pipe(Effect.catch(() => {
|
|
136
|
+
const fields = credentialFields();
|
|
137
|
+
return Effect.succeed({
|
|
138
|
+
...card,
|
|
139
|
+
...(fields?.length ? { fields } : {}),
|
|
140
|
+
configured: false,
|
|
141
|
+
removable: true,
|
|
142
|
+
testable: testRule.mode !== null,
|
|
143
|
+
error: "Stored credential could not be read.",
|
|
144
|
+
problem: "credential_unreadable",
|
|
145
|
+
});
|
|
146
|
+
}));
|
|
147
|
+
};
|
|
148
|
+
const detail = (c, signal) => Effect.gen(function* () {
|
|
149
|
+
const drift = yield* attempt(() => registry.credentialDriftFor(c.id));
|
|
150
|
+
// One deadline covers the whole row. The status and catalog reads once
|
|
151
|
+
// had a second one of the same length inside it, which could only fire
|
|
152
|
+
// after this one had already ended the row.
|
|
153
|
+
const { status, tools, catalogFailed } = yield* observe(c, drift, signal);
|
|
154
|
+
const credential = yield* credentialFor(c);
|
|
155
|
+
logStatus(c.id, status.state, status.message);
|
|
156
|
+
const problem = uiProblemFor(c, status.state, {
|
|
157
|
+
credentialDrift: Boolean(drift),
|
|
158
|
+
catalogFailed,
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
id: c.id,
|
|
162
|
+
authScope: c.authScope ?? "shared",
|
|
163
|
+
...(c.title ? { title: c.title } : {}),
|
|
164
|
+
...(c.description !== undefined ? { description: c.description } : {}),
|
|
165
|
+
status: status.state,
|
|
166
|
+
...(problem ? { problem } : {}),
|
|
167
|
+
toolCount: tools.length,
|
|
168
|
+
tools,
|
|
169
|
+
// Counts only, and only when a refresh in this runtime produced them.
|
|
170
|
+
// `Registry.statusFor` already rebuilt the report through
|
|
171
|
+
// `boundedCatalogDrift`, so what lands here cannot carry a name or a
|
|
172
|
+
// schema even if the plugin seam returned one.
|
|
173
|
+
...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
|
|
174
|
+
...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
|
|
175
|
+
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
176
|
+
...(credential ? { credential } : {}),
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
const unavailable = (c, error) => Effect.sync(() => {
|
|
180
|
+
logStatus(c.id, "failed", error instanceof Error ? error.message : String(error));
|
|
181
|
+
return {
|
|
182
|
+
id: c.id,
|
|
183
|
+
...(c.title ? { title: c.title } : {}),
|
|
184
|
+
authScope: c.authScope ?? "shared",
|
|
185
|
+
status: "error",
|
|
186
|
+
problem: "connector_unavailable",
|
|
187
|
+
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
188
|
+
toolCount: 0,
|
|
189
|
+
tools: [],
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
const row = (c) => withDeadlineEffect((signal) => detail(c, signal), {
|
|
193
|
+
timeoutMs: options.timeoutMs ?? DETAIL_TIMEOUT_MS,
|
|
194
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
195
|
+
timeoutError: new Error(DETAIL_TIMED_OUT),
|
|
196
|
+
}).pipe(
|
|
197
|
+
// A hook that throws where it should reject fails its own row too.
|
|
198
|
+
Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => unavailable(c, error)), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
|
|
199
|
+
return Effect.forEach(registry.listConnectors(), row, {
|
|
200
|
+
concurrency: resolveDiscoveryConcurrency(options.discoveryConcurrency),
|
|
201
|
+
}).pipe(Effect.map((connectors) => ({
|
|
202
|
+
serverInfo: options.serverInfo,
|
|
203
|
+
connectaVersion: CONNECTA_VERSION,
|
|
204
|
+
connectors,
|
|
205
|
+
activityEnabled: options.activityEnabled,
|
|
206
|
+
credentialManagement: options.credentialManagement,
|
|
207
|
+
oauthManagement: options.oauthManagement || Boolean(owner),
|
|
208
|
+
})));
|
|
209
|
+
});
|
|
210
|
+
}
|
package/dist/routes/ui.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import { CONNECTA_VERSION } from "../version.js";
|
|
2
3
|
import { CONNECTA_FAVICON_ICO } from "../favicon.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { CONNECTA_FAVICON_SVG, operatorPageForPath, renderUiHtml, } from "../ui.js";
|
|
5
|
+
import { authorized, refuse, scopeFor, serveOperator, visibleRegistry, } from "./operator.js";
|
|
6
|
+
import { mayManageConnector, privateJson, } from "./shared.js";
|
|
7
|
+
import { uiData } from "./ui-data.js";
|
|
5
8
|
/**
|
|
6
9
|
* Headers that make an operator-supplied favicon body inert on this origin.
|
|
7
10
|
* The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
|
|
@@ -38,7 +41,7 @@ function uiScriptNonce() {
|
|
|
38
41
|
return btoa(binary);
|
|
39
42
|
}
|
|
40
43
|
export async function routeUi(context) {
|
|
41
|
-
const { request, url, path, baseUrl, opts,
|
|
44
|
+
const { request, url, path, baseUrl, opts, runtimeContext } = context;
|
|
42
45
|
if (request.method === "GET" && path === "/favicon.svg") {
|
|
43
46
|
return new Response(opts.branding?.favicon?.svg ?? CONNECTA_FAVICON_SVG, {
|
|
44
47
|
headers: {
|
|
@@ -103,22 +106,11 @@ export async function routeUi(context) {
|
|
|
103
106
|
return null;
|
|
104
107
|
if (request.method !== "GET")
|
|
105
108
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
validateAuthPermissions(authz, opts.registry);
|
|
112
|
-
registry = opts.registry.scoped({
|
|
113
|
-
connectorIds: authz.connectorIds,
|
|
114
|
-
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
115
|
-
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
116
|
-
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
return privateJson({ error: msg(error) }, { status: 403 });
|
|
121
|
-
}
|
|
109
|
+
// Reads: a caller who leaves stops whatever the payload was waiting on.
|
|
110
|
+
return serveOperator(detail ? connectorDetail(context, detail[1]) : summary(context), request.signal);
|
|
111
|
+
}
|
|
112
|
+
/** What this identity may see and do, for the summary and a detail alike. */
|
|
113
|
+
function operatorView({ opts }, authz, registry) {
|
|
122
114
|
const visible = registry.listConnectors();
|
|
123
115
|
const mayManage = (id) => { const connector = registry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); };
|
|
124
116
|
const permissions = (connector) => ({
|
|
@@ -130,20 +122,60 @@ export async function routeUi(context) {
|
|
|
130
122
|
const credentialManagement = visible.some(c => c.credential && mayManage(c.id))
|
|
131
123
|
? opts.credentialVault ? "available" : "vault_not_configured"
|
|
132
124
|
: authz.identity.interactive && !visible.some(c => c.credential) ? "no_slots" : "requires_operator";
|
|
133
|
-
|
|
134
|
-
|
|
125
|
+
return { visible, mayManage, permissions, activityEnabled, credentialManagement };
|
|
126
|
+
}
|
|
127
|
+
/** One connector's probed row, the page's second request per card. */
|
|
128
|
+
function connectorDetail(context, id) {
|
|
129
|
+
const { request, baseUrl, opts, defer } = context;
|
|
130
|
+
return Effect.gen(function* () {
|
|
131
|
+
const authz = yield* authorized(context);
|
|
132
|
+
const registry = yield* visibleRegistry(context, authz);
|
|
133
|
+
const { mayManage, permissions, activityEnabled, credentialManagement } = operatorView(context, authz, registry);
|
|
134
|
+
const connector = registry.getConnector(id);
|
|
135
135
|
if (!connector)
|
|
136
|
-
return
|
|
137
|
-
const
|
|
138
|
-
|
|
136
|
+
return yield* refuse("unknown connector", 404);
|
|
137
|
+
const data = yield* uiData(opts.registry.scoped(scopeFor(authz, [connector.id])), baseUrl, {
|
|
138
|
+
serverInfo: opts.serverInfo,
|
|
139
|
+
credentialVault: opts.credentialVault,
|
|
140
|
+
activityEnabled,
|
|
141
|
+
credentialManagement,
|
|
142
|
+
defer,
|
|
143
|
+
oauthManagement: false,
|
|
144
|
+
discoveryConcurrency: 1,
|
|
145
|
+
personalCredentialOwner: authz.principalKey,
|
|
146
|
+
mayManage,
|
|
147
|
+
timeoutMs: opts.probeTimeoutMs ?? 30_000,
|
|
148
|
+
signal: request.signal,
|
|
149
|
+
});
|
|
139
150
|
return privateJson({ ...data.connectors[0], permissions: permissions(connector) });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** The summary: every visible connector as "loading", with no probe at all. */
|
|
154
|
+
function summary(context) {
|
|
155
|
+
const { opts } = context;
|
|
156
|
+
return Effect.gen(function* () {
|
|
157
|
+
const authz = yield* authorized(context);
|
|
158
|
+
const registry = yield* visibleRegistry(context, authz);
|
|
159
|
+
const { visible, mayManage, permissions, activityEnabled, credentialManagement } = operatorView(context, authz, registry);
|
|
160
|
+
// Setup commands are offered per pool, but only for pools this identity's
|
|
161
|
+
// grant admits: `/mcp/<name>` answers every other name with one flat 404 so
|
|
162
|
+
// a credential cannot enumerate them, and the page must not undo that.
|
|
163
|
+
const pools = [];
|
|
164
|
+
for (const [name, pool] of opts.pools ?? []) {
|
|
165
|
+
const granted = yield* Effect.tryPromise(async () => (await pool.grant(authz.identity)) === true).pipe(
|
|
166
|
+
// A throwing grant is a refusal at the endpoint, and so a refusal here.
|
|
167
|
+
Effect.orElseSucceed(() => false));
|
|
168
|
+
if (granted)
|
|
169
|
+
pools.push(name);
|
|
170
|
+
}
|
|
171
|
+
return privateJson({
|
|
172
|
+
...(pools.length ? { pools } : {}),
|
|
173
|
+
serverInfo: opts.serverInfo,
|
|
174
|
+
connectaVersion: CONNECTA_VERSION,
|
|
175
|
+
activityEnabled,
|
|
176
|
+
credentialManagement,
|
|
177
|
+
oauthManagement: visible.some(c => mayManage(c.id)),
|
|
178
|
+
connectors: visible.map(c => ({ id: c.id, ...(c.title ? { title: c.title } : {}), ...(c.description ? { description: c.description } : {}), authScope: c.authScope ?? "shared", status: "loading", toolCount: 0, tools: [], oauth: Boolean(c.startAuth && c.disconnectAuth), permissions: permissions(c) })),
|
|
179
|
+
});
|
|
148
180
|
});
|
|
149
181
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The Effect face of bounded admission (src/executor-admission.ts).
|
|
2
|
+
//
|
|
3
|
+
// AdmissionController is a published class, and its declaration — private
|
|
4
|
+
// member names included — is part of what ships, so its Effect program cannot
|
|
5
|
+
// hang off a new field or method. The class hands that program here from a
|
|
6
|
+
// static block instead: the one place outside its instance methods that may
|
|
7
|
+
// read its private bookkeeping, and one that emits nothing into the `.d.ts`.
|
|
8
|
+
// Promise callers keep `acquire()`, which runs the same program through
|
|
9
|
+
// runEdge; Effect callers come through this module and never see a Promise.
|
|
10
|
+
import { Effect } from "effect";
|
|
11
|
+
let program;
|
|
12
|
+
/**
|
|
13
|
+
* Install the controller's admission program. Called once, by
|
|
14
|
+
* AdmissionController's static block, when that module evaluates — which any
|
|
15
|
+
* importer of this module's value exports has already caused, since they need
|
|
16
|
+
* a controller to pass in.
|
|
17
|
+
*/
|
|
18
|
+
export function provideAdmissionProgram(admit) {
|
|
19
|
+
program = admit;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Admit one unit of work, as `controller.acquire()` does, without a Promise.
|
|
23
|
+
*
|
|
24
|
+
* Fails with the same ExecutorAdmissionError objects, bumps the same counters,
|
|
25
|
+
* and honours the same queue bound and timeout. Interrupting the fiber while
|
|
26
|
+
* it is queued withdraws it (counted as cancelled); interrupting it after a
|
|
27
|
+
* slot was handed over releases that slot rather than stranding it.
|
|
28
|
+
*/
|
|
29
|
+
export function admit(controller, options = {}) {
|
|
30
|
+
if (!program) {
|
|
31
|
+
throw new Error("AdmissionController's admission program was never installed.");
|
|
32
|
+
}
|
|
33
|
+
return program(controller, options.signal);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A lease owned by the enclosing Scope: released when the scope closes, on
|
|
37
|
+
* success, failure, or interruption alike. The wait itself stays
|
|
38
|
+
* interruptible — a queued request whose caller left must not hold its place
|
|
39
|
+
* in line until the queue timeout.
|
|
40
|
+
*/
|
|
41
|
+
export function acquireScoped(controller, options = {}) {
|
|
42
|
+
return Effect.acquireRelease(admit(controller, options), (lease) => Effect.sync(() => lease.release()), { interruptible: true });
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The Effect face of downstream call admission (src/call-admission.ts).
|
|
2
|
+
//
|
|
3
|
+
// ConnectorCallAdmissionController is a published class whose declaration —
|
|
4
|
+
// private member names included — ships, so its Effect program cannot hang
|
|
5
|
+
// off a new field or method. As with AdmissionController (see
|
|
6
|
+
// src/runtime/admission.ts), the class hands that program here from a static
|
|
7
|
+
// block: the one place outside its instance methods that may read its private
|
|
8
|
+
// bookkeeping, and one that emits nothing into the `.d.ts`.
|
|
9
|
+
//
|
|
10
|
+
// The two controllers stay separate on purpose (#453): request admission
|
|
11
|
+
// bounds executor work, this bounds calls to one downstream connector, and a
|
|
12
|
+
// shared queue between them was refused.
|
|
13
|
+
import { Effect } from "effect";
|
|
14
|
+
let program;
|
|
15
|
+
// What the live Clock reads, looked up on every call rather than bound once,
|
|
16
|
+
// so a Date faked after a caller queued (vitest's setSystemTime) still counts.
|
|
17
|
+
const liveNow = () => Date.now();
|
|
18
|
+
/**
|
|
19
|
+
* Install the controller's admission program. Called once, by
|
|
20
|
+
* ConnectorCallAdmissionController's static block, when that module
|
|
21
|
+
* evaluates — which any caller has already caused, since it needs a
|
|
22
|
+
* controller to pass in.
|
|
23
|
+
*/
|
|
24
|
+
export function provideCallAdmissionProgram(admit) {
|
|
25
|
+
program = admit;
|
|
26
|
+
}
|
|
27
|
+
function installed() {
|
|
28
|
+
if (!program) {
|
|
29
|
+
throw new Error("ConnectorCallAdmissionController's admission program was never installed.");
|
|
30
|
+
}
|
|
31
|
+
return program;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Promise shell's entry: decide now, on `Date.now()` — the value the live
|
|
35
|
+
* Clock reads — and return only the wait.
|
|
36
|
+
*
|
|
37
|
+
* Deciding eagerly is what keeps the limiter payload-free. The returned
|
|
38
|
+
* effect closes over the partition key, never the request, so a queued call
|
|
39
|
+
* does not retain its tool arguments for as long as it waits.
|
|
40
|
+
*/
|
|
41
|
+
export function startCallAdmission(controller, request) {
|
|
42
|
+
return installed()(controller, request, liveNow);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Admit one downstream call, as `controller.acquire()` does, without a
|
|
46
|
+
* Promise.
|
|
47
|
+
*
|
|
48
|
+
* Fails with the same CallAdmissionError objects and bumps the same counters.
|
|
49
|
+
* The rolling window and queue-wait times read the fiber's Clock. Nothing
|
|
50
|
+
* happens until the effect runs, and each run is a fresh admission.
|
|
51
|
+
* Interrupting the fiber while it is queued withdraws it (counted as
|
|
52
|
+
* cancelled); interrupting it after a slot was handed over releases that slot
|
|
53
|
+
* rather than stranding it.
|
|
54
|
+
*/
|
|
55
|
+
export function admitCall(controller, request) {
|
|
56
|
+
const admit = installed();
|
|
57
|
+
return Effect.clockWith((clock) => admit(controller, request, () => clock.currentTimeMillisUnsafe()));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A permit owned by the enclosing Scope: released when the scope closes, on
|
|
61
|
+
* success, failure, or interruption alike. The queued wait stays
|
|
62
|
+
* interruptible, so a caller that left does not hold its place in line until
|
|
63
|
+
* the queue timeout.
|
|
64
|
+
*/
|
|
65
|
+
export function acquireCallScoped(controller, request) {
|
|
66
|
+
return Effect.acquireRelease(admitCall(controller, request), (permit) => Effect.sync(() => permit.release()), { interruptible: true });
|
|
67
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The Effect face of connector scope teardown (src/connector-scope.ts).
|
|
2
|
+
//
|
|
3
|
+
// closeConnectorScope() is the Promise shell over closeScope() below. An
|
|
4
|
+
// Effect caller that owns a Scope registers the close as that scope's
|
|
5
|
+
// finalizer instead, so teardown runs however the work inside it ends.
|
|
6
|
+
import { Duration, Effect } from "effect";
|
|
7
|
+
import { detach } from "./run.js";
|
|
8
|
+
/** Enough for local transport abort/close without letting cleanup own latency. */
|
|
9
|
+
const CONNECTOR_SCOPE_CLOSE_BUDGET_MS = 100;
|
|
10
|
+
/**
|
|
11
|
+
* Bound on cleanup continued after the caller-facing window expires.
|
|
12
|
+
*
|
|
13
|
+
* `remoteMcp` spends at most one second asking the downstream to terminate its
|
|
14
|
+
* session, leaving another second for the local close. Custom hooks still get a
|
|
15
|
+
* finite background window: handing a never-settling promise to a Worker's
|
|
16
|
+
* `waitUntil` would otherwise keep the invocation alive until the platform cap.
|
|
17
|
+
*/
|
|
18
|
+
const CONNECTOR_SCOPE_DEFER_BUDGET_MS = 2_000;
|
|
19
|
+
/**
|
|
20
|
+
* Succeed when `work` settles or `budgetMs` expires, whichever is first; never
|
|
21
|
+
* fail. The loser is interrupted, so a close that finishes early leaves no
|
|
22
|
+
* timer behind. Interrupting the wait leaves `work` running: nothing here can
|
|
23
|
+
* cancel a connector's close, only stop waiting for it.
|
|
24
|
+
*/
|
|
25
|
+
function waitAtMost(work, budgetMs) {
|
|
26
|
+
return Effect.raceAllFirst([
|
|
27
|
+
Effect.promise(() => work),
|
|
28
|
+
Effect.sleep(Duration.millis(budgetMs)),
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Tell a connector that a scope owned by the core has ended, with the contract
|
|
33
|
+
* closeConnectorScope() documents: best-effort, never failing, waited on for a
|
|
34
|
+
* small fixed window, with a bounded tail handed to `defer` when the runtime
|
|
35
|
+
* has one. The hook is called when the effect runs, not when it is built.
|
|
36
|
+
*/
|
|
37
|
+
export function closeScope(connector, ctx, defer) {
|
|
38
|
+
return Effect.suspend(() => {
|
|
39
|
+
let settled;
|
|
40
|
+
try {
|
|
41
|
+
const closing = connector.closeScope?.(ctx);
|
|
42
|
+
if (!closing)
|
|
43
|
+
return Effect.void;
|
|
44
|
+
// Attach both handlers before either timer can win, so a late rejection
|
|
45
|
+
// is consumed rather than becoming an unhandled rejection.
|
|
46
|
+
settled = closing.then(() => { }, () => { });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// The scope is over whether or not the connector managed to clean it up.
|
|
50
|
+
return Effect.void;
|
|
51
|
+
}
|
|
52
|
+
if (defer) {
|
|
53
|
+
try {
|
|
54
|
+
detach(waitAtMost(settled, CONNECTOR_SCOPE_DEFER_BUDGET_MS), {
|
|
55
|
+
waitUntil: defer,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A runtime hook is best-effort too; the caller cap still applies.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return waitAtMost(settled, CONNECTOR_SCOPE_CLOSE_BUDGET_MS);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Close the connector scope when the enclosing Scope closes — on success,
|
|
67
|
+
* failure, or interruption alike. Callers still own the at-most-once
|
|
68
|
+
* guarantee: register the finalizer once per connector scope.
|
|
69
|
+
*/
|
|
70
|
+
export function closeScopeOnExit(connector, ctx, defer) {
|
|
71
|
+
return Effect.addFinalizer(() => closeScope(connector, ctx, defer));
|
|
72
|
+
}
|