@zackbart/connecta 0.24.4 → 0.26.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 +43 -12
- package/CHANGELOG.md +524 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/advertised-schema.js +53 -0
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- 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 +330 -240
- 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 +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- 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 +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -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 +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- 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/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
- 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/routes/oauth.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
|
|
2
3
|
import { resolveBranding } from "../branding.js";
|
|
4
|
+
import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
|
|
3
5
|
import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
|
|
4
6
|
function escapeHtml(value) {
|
|
5
7
|
return value
|
|
@@ -8,8 +10,14 @@ function escapeHtml(value) {
|
|
|
8
10
|
.replaceAll(">", ">")
|
|
9
11
|
.replaceAll('"', """);
|
|
10
12
|
}
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The callback's one page. It renders a reason from a closed set and that
|
|
15
|
+
* reason's fixed copy, so nothing the request carried — the provider's `error`
|
|
16
|
+
* parameter, an exchange failure's message — can reach the body. Escaping
|
|
17
|
+
* stays anyway: branding and the configured connector id are still strings.
|
|
18
|
+
*/
|
|
19
|
+
function html(reason, branding, hasUi = false, connectorId) {
|
|
20
|
+
const outcome = oauthCallbackOutcome(reason, connectorId);
|
|
13
21
|
const brand = resolveBranding(branding);
|
|
14
22
|
const title = brand.pageTitle;
|
|
15
23
|
const owner = brand.ownerName
|
|
@@ -52,7 +60,10 @@ function html(body, status = 200, branding, hasUi = false) {
|
|
|
52
60
|
a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
|
|
53
61
|
text-underline-offset: .22em; }
|
|
54
62
|
a:hover { text-decoration-color: transparent; }
|
|
55
|
-
a:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
|
|
63
|
+
a:focus-visible, summary:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
|
|
64
|
+
summary { cursor: pointer; }
|
|
65
|
+
pre { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: .75rem 0 0;
|
|
66
|
+
padding: .75rem; white-space: pre-wrap; border: 1px solid #000; user-select: all; }
|
|
56
67
|
@media (max-width: 36.99rem) {
|
|
57
68
|
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
58
69
|
.product { grid-column: 2; }
|
|
@@ -66,15 +77,16 @@ function html(body, status = 200, branding, hasUi = false) {
|
|
|
66
77
|
${owner}
|
|
67
78
|
${product}
|
|
68
79
|
</header>
|
|
69
|
-
<main class="shell grid">
|
|
80
|
+
<main class="shell grid" data-oauth-callback="${outcome.reason}">
|
|
70
81
|
<h1>Connection status</h1>
|
|
71
82
|
<div class="copy">
|
|
72
|
-
<p>${escapeHtml(
|
|
83
|
+
<p>${escapeHtml(outcome.message)}</p>
|
|
84
|
+
${outcome.fixPrompt ? `<details><summary>Fix prompt for a coding agent</summary><pre>${escapeHtml(outcome.fixPrompt)}</pre></details>` : ""}
|
|
73
85
|
${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
|
|
74
86
|
</div>
|
|
75
87
|
</main>
|
|
76
88
|
</body>
|
|
77
|
-
</html>`, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
89
|
+
</html>`, { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
78
90
|
}
|
|
79
91
|
/**
|
|
80
92
|
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
@@ -107,16 +119,29 @@ async function equalizeRefusalCost(context) {
|
|
|
107
119
|
// Deliberately ignored — see above.
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The callback route as a step of the request's fiber, and the one step that
|
|
124
|
+
* the request's signal does not interrupt.
|
|
125
|
+
*
|
|
126
|
+
* An authorization code is single-use. Once a browser has delivered one, the
|
|
127
|
+
* handoff it consumes, the exchange, and the catalog invalidation after it are
|
|
128
|
+
* one commitment: abandoned halfway, a connector could hold fresh tokens
|
|
129
|
+
* behind a catalog still cached as unauthorized, or a consumed handoff with no
|
|
130
|
+
* exchange behind it. A caller that hangs up mid-exchange loses only the page.
|
|
131
|
+
*/
|
|
132
|
+
export function routeOAuthCallback(context) {
|
|
133
|
+
if (!context.path.startsWith("/oauth/callback/"))
|
|
134
|
+
return Effect.succeed(null);
|
|
135
|
+
return Effect.uninterruptible(Effect.promise(() => finishOAuthCallback(context)));
|
|
136
|
+
}
|
|
137
|
+
async function finishOAuthCallback(context) {
|
|
111
138
|
const { path, url, baseUrl, opts } = context;
|
|
112
|
-
if (!path.startsWith("/oauth/callback/"))
|
|
113
|
-
return null;
|
|
114
139
|
const error = url.searchParams.get("error");
|
|
115
140
|
if (error)
|
|
116
|
-
return html(
|
|
141
|
+
return html(providerErrorReason(error), opts.branding, Boolean(opts.ui));
|
|
117
142
|
const code = url.searchParams.get("code");
|
|
118
143
|
if (!code)
|
|
119
|
-
return html("
|
|
144
|
+
return html("invalid_callback", opts.branding, Boolean(opts.ui));
|
|
120
145
|
const id = path.slice("/oauth/callback/".length);
|
|
121
146
|
const state = url.searchParams.get("state");
|
|
122
147
|
const callbackTarget = await opts.registry.oauthCallbackView(id, state);
|
|
@@ -130,8 +155,7 @@ export async function routeOAuthCallback(context) {
|
|
|
130
155
|
const connectorContext = callbackRegistry
|
|
131
156
|
? callbackRegistry.contextFor(id, baseUrl)
|
|
132
157
|
: opts.registry.contextFor(id, baseUrl);
|
|
133
|
-
const refused = () => html("
|
|
134
|
-
"connecta and try again.", 400, opts.branding, Boolean(opts.ui));
|
|
158
|
+
const refused = () => html("invalid_callback", opts.branding, Boolean(opts.ui));
|
|
135
159
|
if (!connector || !connector.finishAuth) {
|
|
136
160
|
await equalizeRefusalCost(connectorContext);
|
|
137
161
|
return refused();
|
|
@@ -196,15 +220,19 @@ export async function routeOAuthCallback(context) {
|
|
|
196
220
|
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
197
221
|
`${loggableValue(id)} with 500: its principal handoff could not be ` +
|
|
198
222
|
`consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
|
|
199
|
-
return html("
|
|
223
|
+
return html("handoff_failed", opts.branding, Boolean(opts.ui), id);
|
|
200
224
|
}
|
|
201
225
|
}
|
|
202
226
|
try {
|
|
203
227
|
await connector.finishAuth(code, connectorContext, url.searchParams);
|
|
204
228
|
await callbackRegistry.invalidateStored(id);
|
|
205
|
-
return html(
|
|
229
|
+
return html("connected", opts.branding, Boolean(opts.ui), id);
|
|
206
230
|
}
|
|
207
231
|
catch (err) {
|
|
208
|
-
|
|
232
|
+
// The page names the reason and nothing else; what the exchange threw can
|
|
233
|
+
// quote a token endpoint's body, so it goes only to the operator log.
|
|
234
|
+
opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
|
|
235
|
+
`with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
|
|
236
|
+
return html("exchange_failed", opts.branding, Boolean(opts.ui), id);
|
|
209
237
|
}
|
|
210
238
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
|
|
55
|
+
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
56
|
+
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The registry as this identity may see it. A configured permission naming a
|
|
61
|
+
* connector that does not exist refuses the whole view, management rights
|
|
62
|
+
* included, rather than quietly granting what remains.
|
|
63
|
+
*/
|
|
64
|
+
export function visibleRegistry({ opts }, authz) {
|
|
65
|
+
return Effect.try({
|
|
66
|
+
try: () => {
|
|
67
|
+
validateAuthPermissions(authz, opts.registry);
|
|
68
|
+
return opts.registry.scoped(scopeFor(authz));
|
|
69
|
+
},
|
|
70
|
+
catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run one route program to its Response.
|
|
75
|
+
*
|
|
76
|
+
* A read passes the request's signal, so a caller who leaves stops whatever
|
|
77
|
+
* the read was waiting on. A mutation passes none: once a vault write or an
|
|
78
|
+
* OAuth disconnect has started, the cache invalidation that follows it must
|
|
79
|
+
* run too, whether or not anyone is still waiting for the answer.
|
|
80
|
+
*/
|
|
81
|
+
export function serveOperator(program, signal) {
|
|
82
|
+
return runEdge(Effect.catch(program, (answer) => Effect.succeed(answer.response)), signal ? { signal } : undefined);
|
|
83
|
+
}
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Implementation } from "@modelcontextprotocol/server";
|
|
2
|
-
import type { ActivityModule, OperatorSurface } from "../module-contracts.js";
|
|
2
|
+
import type { ActivityModule, ArtifactsModule, OperatorSurface } from "../module-contracts.js";
|
|
3
3
|
import type { ActivityActor, ActivityReadGate, ActivityStore } from "../activity.js";
|
|
4
4
|
import type { CredentialVault } from "../credential-contract.js";
|
|
5
5
|
import type { DeferredWork } from "../connector-scope.js";
|
|
@@ -7,6 +7,7 @@ import type { AdmissionController } from "../executor-admission.js";
|
|
|
7
7
|
import type { Registry, ToolAccess } from "../registry.js";
|
|
8
8
|
import type { ResolvedPool } from "../connector-access.js";
|
|
9
9
|
import type { AuthenticatedIdentity, ConnectaBranding, Executor, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
|
|
10
|
+
import type { ApprovalPolicy } from "../tool-safety.js";
|
|
10
11
|
import type { ConnectorPermission, ConnectaIdentityConfig } from "../index.js";
|
|
11
12
|
export { msg } from "../errors.js";
|
|
12
13
|
export interface ServerOptions {
|
|
@@ -16,6 +17,7 @@ export interface ServerOptions {
|
|
|
16
17
|
/** Validated named pools served at `/mcp/<name>`; empty when none declared. */
|
|
17
18
|
pools?: ReadonlyMap<string, ResolvedPool> | undefined;
|
|
18
19
|
publicUrl?: string | undefined;
|
|
20
|
+
artifactOrigin?: string | undefined;
|
|
19
21
|
allowedOrigins?: readonly string[] | "*" | undefined;
|
|
20
22
|
serverInfo: Implementation;
|
|
21
23
|
logger: Logger;
|
|
@@ -37,6 +39,17 @@ export interface ServerOptions {
|
|
|
37
39
|
maxHostCalls?: number | undefined;
|
|
38
40
|
/** Deadline per execute_code host call. Default 15_000. */
|
|
39
41
|
hostCallTimeoutMs?: number | undefined;
|
|
42
|
+
/** Hard ceiling on one execute_code run, outside the sandbox. Default 120_000. */
|
|
43
|
+
watchdogMs?: number | undefined;
|
|
44
|
+
/** Resumable writes' settings; absent when they are off. */
|
|
45
|
+
resumable?: {
|
|
46
|
+
maxWrites: number;
|
|
47
|
+
ttlSeconds: number;
|
|
48
|
+
} | undefined;
|
|
49
|
+
/** Config approval exemptions (`execute.approval`), resolved at construction. */
|
|
50
|
+
approval?: ApprovalPolicy | undefined;
|
|
51
|
+
/** Writes one program may send (`execute.maxWrites`), exempt ones included. */
|
|
52
|
+
maxWrites?: number | undefined;
|
|
40
53
|
/** Required sandbox backing the execute_code meta-tool. */
|
|
41
54
|
executor: Executor;
|
|
42
55
|
/** Sanitized identity of the configured sandbox, when it has one. */
|
|
@@ -48,6 +61,8 @@ export interface ServerOptions {
|
|
|
48
61
|
/** Optional browser routes, with no implementation import in core. */
|
|
49
62
|
ui?: OperatorSurface | undefined;
|
|
50
63
|
activityModule?: ActivityModule | undefined;
|
|
64
|
+
/** Optional team pages; their routes mount only beside `ui`. */
|
|
65
|
+
artifactsModule?: ArtifactsModule | undefined;
|
|
51
66
|
/** Optional browser UI and OAuth result-page labels. */
|
|
52
67
|
branding?: ConnectaBranding | undefined;
|
|
53
68
|
}
|
|
@@ -84,6 +99,8 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
|
|
|
84
99
|
connectorIds: "all" | readonly string[];
|
|
85
100
|
/** Per-connector tool allowlist for connectors granted by address only. */
|
|
86
101
|
toolAccess?: ToolAccess;
|
|
102
|
+
/** Granted addresses that still require the catalog's read-only hint. */
|
|
103
|
+
guardedToolAccess?: ToolAccess;
|
|
87
104
|
operator: boolean;
|
|
88
105
|
credentialAdministration: ConnectorPermission;
|
|
89
106
|
personalConnection: ConnectorPermission;
|
|
@@ -106,6 +123,13 @@ type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
|
|
|
106
123
|
}>;
|
|
107
124
|
/** Unknown configured ids refuse the complete view, including management rights. */
|
|
108
125
|
export declare function validateAuthPermissions(authz: AuthorizedIdentity, registry: Registry): void;
|
|
126
|
+
/**
|
|
127
|
+
* Whether this identity may open artifact pages: exactly whoever may read the
|
|
128
|
+
* `artifacts` connector — it is visible to them and `get_artifact` is among
|
|
129
|
+
* its tools they were granted. Viewing grants nothing a program could not
|
|
130
|
+
* already read (ethos, "Human routes manage auth, never capability").
|
|
131
|
+
*/
|
|
132
|
+
export declare function mayViewArtifacts(authz: AuthorizedIdentity, registry: Registry): boolean;
|
|
109
133
|
export declare function mayManageConnector(authz: AuthorizedIdentity, connector: {
|
|
110
134
|
id: string;
|
|
111
135
|
authScope?: "shared" | "personal";
|
package/dist/routes/shared.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseConnectorAccess } from "../connector-access.js";
|
|
2
2
|
import { identityStorageKey, validIdentityReference } from "../identity.js";
|
|
3
|
+
import { isExplicitlyReadOnly } from "../tool-safety.js";
|
|
3
4
|
export { msg } from "../errors.js";
|
|
4
5
|
export function privateJson(body, init = {}) {
|
|
5
6
|
const headers = new Headers(init.headers);
|
|
@@ -36,7 +37,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
|
|
|
36
37
|
const identity = { actor, interactive: false };
|
|
37
38
|
let access;
|
|
38
39
|
try {
|
|
39
|
-
access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all");
|
|
40
|
+
access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all", { allowReadOnly: true });
|
|
40
41
|
}
|
|
41
42
|
catch {
|
|
42
43
|
return {
|
|
@@ -83,7 +84,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
|
|
|
83
84
|
? await identityConfig.activityAccess(principal)
|
|
84
85
|
: false;
|
|
85
86
|
}
|
|
86
|
-
access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all");
|
|
87
|
+
access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all", { allowReadOnly: true });
|
|
87
88
|
if (interactive) {
|
|
88
89
|
credentialAdministration = identityConfig?.credentialAdministration ? await identityConfig.credentialAdministration(identity) : "none";
|
|
89
90
|
personalConnection = principal && identityConfig?.personalConnection ? await identityConfig.personalConnection(identity) : "none";
|
|
@@ -194,6 +195,23 @@ export function validateAuthPermissions(authz, registry) {
|
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Whether this identity may open artifact pages: exactly whoever may read the
|
|
200
|
+
* `artifacts` connector — it is visible to them and `get_artifact` is among
|
|
201
|
+
* its tools they were granted. Viewing grants nothing a program could not
|
|
202
|
+
* already read (ethos, "Human routes manage auth, never capability").
|
|
203
|
+
*/
|
|
204
|
+
export function mayViewArtifacts(authz, registry) {
|
|
205
|
+
if (authz.connectorIds !== "all" && !authz.connectorIds.includes("artifacts")) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
const tools = authz.toolAccess?.get("artifacts");
|
|
209
|
+
if (tools && !tools.has("get_artifact"))
|
|
210
|
+
return false;
|
|
211
|
+
if (!authz.guardedToolAccess?.get("artifacts")?.has("get_artifact"))
|
|
212
|
+
return true;
|
|
213
|
+
return registry.getConnector("artifacts")?.staticTools?.some((tool) => tool.name === "get_artifact" && isExplicitlyReadOnly(tool)) === true;
|
|
214
|
+
}
|
|
197
215
|
export function mayManageConnector(authz, connector) {
|
|
198
216
|
if (!authz.identity.interactive)
|
|
199
217
|
return false;
|
|
@@ -0,0 +1,211 @@
|
|
|
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 { isApprovalExempt, NO_EXEMPTIONS, } from "../tool-safety.js";
|
|
14
|
+
import { uiProblemFor, uiToolSafety } from "../ui.js";
|
|
15
|
+
import { CONNECTA_VERSION } from "../version.js";
|
|
16
|
+
const DETAIL_TIMEOUT_MS = 30_000;
|
|
17
|
+
const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
|
|
18
|
+
function attempt(operation) {
|
|
19
|
+
return Effect.tryPromise({ try: operation, catch: (error) => error });
|
|
20
|
+
}
|
|
21
|
+
export function uiData(registry, baseUrl, options) {
|
|
22
|
+
return Effect.suspend(() => {
|
|
23
|
+
const requestScope = {};
|
|
24
|
+
const vault = options.credentialVault;
|
|
25
|
+
const owner = options.personalCredentialOwner;
|
|
26
|
+
/**
|
|
27
|
+
* A connector's status message stops here. It can quote a downstream error
|
|
28
|
+
* body — and a downstream error body can quote the secret it just rejected —
|
|
29
|
+
* so the payload carries only the classified `problem` and the raw text goes
|
|
30
|
+
* to the deployment's log, where an operator debugging the failure already
|
|
31
|
+
* looks. An `ok` status's message is informational and simply dropped.
|
|
32
|
+
*/
|
|
33
|
+
const logStatus = (id, state, message) => {
|
|
34
|
+
if (!message || state === "ok")
|
|
35
|
+
return;
|
|
36
|
+
const logger = registry.contextFor(id, baseUrl, requestScope).logger;
|
|
37
|
+
const line = `[connecta] connector "${id}" operator status ${state}: ${message}`;
|
|
38
|
+
if (state === "auth_required")
|
|
39
|
+
logger.info(line);
|
|
40
|
+
else
|
|
41
|
+
logger.warn(line);
|
|
42
|
+
};
|
|
43
|
+
// Status first, then the catalog only when status is ok. A failed status
|
|
44
|
+
// read is an error status, not a failed row: the row still reports the
|
|
45
|
+
// connector's credential.
|
|
46
|
+
const observe = (c, drift, signal) => {
|
|
47
|
+
if (drift) {
|
|
48
|
+
return Effect.succeed({
|
|
49
|
+
status: { state: "auth_required", message: drift },
|
|
50
|
+
tools: [],
|
|
51
|
+
catalogFailed: false,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return Effect.gen(function* () {
|
|
55
|
+
const status = yield* attempt(() => registry.statusFor(c.id, baseUrl, requestScope, { signal }));
|
|
56
|
+
if (status.state !== "ok" || signal.aborted) {
|
|
57
|
+
return { status, tools: [], catalogFailed: false };
|
|
58
|
+
}
|
|
59
|
+
return yield* attempt(async () => (await registry.getTools(c.id, baseUrl, requestScope, { signal })).map((t) => ({
|
|
60
|
+
name: t.name,
|
|
61
|
+
address: `${c.id}.${t.name}`,
|
|
62
|
+
...(t.description ? { description: t.description } : {}),
|
|
63
|
+
safety: uiToolSafety(t, isApprovalExempt(options.approval ?? NO_EXEMPTIONS, c, t.name, t)),
|
|
64
|
+
}))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
|
|
65
|
+
// The registry owns the failed catalog observation; the page only
|
|
66
|
+
// needs to know there was one.
|
|
67
|
+
Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })));
|
|
68
|
+
}).pipe(Effect.catch((error) => Effect.succeed({
|
|
69
|
+
status: {
|
|
70
|
+
state: "error",
|
|
71
|
+
message: error instanceof Error
|
|
72
|
+
? error.message
|
|
73
|
+
: "Connection details unavailable",
|
|
74
|
+
},
|
|
75
|
+
tools: [],
|
|
76
|
+
catalogFailed: false,
|
|
77
|
+
})));
|
|
78
|
+
};
|
|
79
|
+
// The credential card, for someone who may manage this connector's auth.
|
|
80
|
+
// Never fails: a vault that cannot be read is the card's own error.
|
|
81
|
+
const credentialFor = (c) => {
|
|
82
|
+
const mayManageAuth = options.mayManage?.(c.id) ??
|
|
83
|
+
(c.authScope === "personal" ? Boolean(owner) : options.oauthManagement);
|
|
84
|
+
const declared = c.credential;
|
|
85
|
+
if (!declared || !vault || !mayManageAuth)
|
|
86
|
+
return Effect.succeed(undefined);
|
|
87
|
+
// One rule, shared with the test route: only the hook matching the
|
|
88
|
+
// declared credential shape can run, so the button is offered only
|
|
89
|
+
// where a click can succeed (src/credentials.ts).
|
|
90
|
+
const testRule = credentialTestRule(c);
|
|
91
|
+
const credentialFields = (metadata) => declared.fields?.map((field) => {
|
|
92
|
+
const fieldMetadata = metadata?.fields?.[field.name];
|
|
93
|
+
return {
|
|
94
|
+
name: field.name,
|
|
95
|
+
label: field.label,
|
|
96
|
+
...(field.description ? { description: field.description } : {}),
|
|
97
|
+
...(field.placeholder ? { placeholder: field.placeholder } : {}),
|
|
98
|
+
inputType: field.inputType ?? "password",
|
|
99
|
+
configured: Boolean(fieldMetadata),
|
|
100
|
+
...(fieldMetadata
|
|
101
|
+
? {
|
|
102
|
+
lastFour: fieldMetadata.lastFour,
|
|
103
|
+
updatedAt: fieldMetadata.updatedAt,
|
|
104
|
+
}
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
const card = {
|
|
109
|
+
label: declared.label,
|
|
110
|
+
...(declared.description ? { description: declared.description } : {}),
|
|
111
|
+
...(declared.placeholder ? { placeholder: declared.placeholder } : {}),
|
|
112
|
+
};
|
|
113
|
+
return attempt(async () => {
|
|
114
|
+
const metadata = await vault.metadata(c.id, c.authScope === "personal" ? owner : undefined);
|
|
115
|
+
const fields = credentialFields(metadata);
|
|
116
|
+
const shape = storedCredentialShape(declared, metadata?.fields ?? null);
|
|
117
|
+
return {
|
|
118
|
+
...card,
|
|
119
|
+
...(fields?.length ? { fields } : {}),
|
|
120
|
+
configured: shape.state === "valid",
|
|
121
|
+
removable: Boolean(metadata),
|
|
122
|
+
...(metadata
|
|
123
|
+
? { lastFour: metadata.lastFour, updatedAt: metadata.updatedAt }
|
|
124
|
+
: {}),
|
|
125
|
+
testable: testRule.mode !== null && shape.state !== "mismatch",
|
|
126
|
+
...(shape.state === "mismatch"
|
|
127
|
+
? { error: shape.message, problem: "credential_mismatch" }
|
|
128
|
+
: {}),
|
|
129
|
+
// A dropped field leaves its secret in the vault, and the field
|
|
130
|
+
// list below only renders fields the connector still declares —
|
|
131
|
+
// so without this line there is nowhere an operator could see it.
|
|
132
|
+
...(shape.state === "valid" && shape.undeclared.length
|
|
133
|
+
? { notice: describeUndeclaredCredentialFields(shape.undeclared) }
|
|
134
|
+
: {}),
|
|
135
|
+
};
|
|
136
|
+
}).pipe(Effect.catch(() => {
|
|
137
|
+
const fields = credentialFields();
|
|
138
|
+
return Effect.succeed({
|
|
139
|
+
...card,
|
|
140
|
+
...(fields?.length ? { fields } : {}),
|
|
141
|
+
configured: false,
|
|
142
|
+
removable: true,
|
|
143
|
+
testable: testRule.mode !== null,
|
|
144
|
+
error: "Stored credential could not be read.",
|
|
145
|
+
problem: "credential_unreadable",
|
|
146
|
+
});
|
|
147
|
+
}));
|
|
148
|
+
};
|
|
149
|
+
const detail = (c, signal) => Effect.gen(function* () {
|
|
150
|
+
const drift = yield* attempt(() => registry.credentialDriftFor(c.id));
|
|
151
|
+
// One deadline covers the whole row. The status and catalog reads once
|
|
152
|
+
// had a second one of the same length inside it, which could only fire
|
|
153
|
+
// after this one had already ended the row.
|
|
154
|
+
const { status, tools, catalogFailed } = yield* observe(c, drift, signal);
|
|
155
|
+
const credential = yield* credentialFor(c);
|
|
156
|
+
logStatus(c.id, status.state, status.message);
|
|
157
|
+
const problem = uiProblemFor(c, status.state, {
|
|
158
|
+
credentialDrift: Boolean(drift),
|
|
159
|
+
catalogFailed,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
id: c.id,
|
|
163
|
+
authScope: c.authScope ?? "shared",
|
|
164
|
+
...(c.title ? { title: c.title } : {}),
|
|
165
|
+
...(c.description !== undefined ? { description: c.description } : {}),
|
|
166
|
+
status: status.state,
|
|
167
|
+
...(problem ? { problem } : {}),
|
|
168
|
+
toolCount: tools.length,
|
|
169
|
+
tools,
|
|
170
|
+
// Counts only, and only when a refresh in this runtime produced them.
|
|
171
|
+
// `Registry.statusFor` already rebuilt the report through
|
|
172
|
+
// `boundedCatalogDrift`, so what lands here cannot carry a name or a
|
|
173
|
+
// schema even if the plugin seam returned one.
|
|
174
|
+
...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
|
|
175
|
+
...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
|
|
176
|
+
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
177
|
+
...(credential ? { credential } : {}),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const unavailable = (c, error) => Effect.sync(() => {
|
|
181
|
+
logStatus(c.id, "failed", error instanceof Error ? error.message : String(error));
|
|
182
|
+
return {
|
|
183
|
+
id: c.id,
|
|
184
|
+
...(c.title ? { title: c.title } : {}),
|
|
185
|
+
authScope: c.authScope ?? "shared",
|
|
186
|
+
status: "error",
|
|
187
|
+
problem: "connector_unavailable",
|
|
188
|
+
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
189
|
+
toolCount: 0,
|
|
190
|
+
tools: [],
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
const row = (c) => withDeadlineEffect((signal) => detail(c, signal), {
|
|
194
|
+
timeoutMs: options.timeoutMs ?? DETAIL_TIMEOUT_MS,
|
|
195
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
196
|
+
timeoutError: new Error(DETAIL_TIMED_OUT),
|
|
197
|
+
}).pipe(
|
|
198
|
+
// A hook that throws where it should reject fails its own row too.
|
|
199
|
+
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))));
|
|
200
|
+
return Effect.forEach(registry.listConnectors(), row, {
|
|
201
|
+
concurrency: resolveDiscoveryConcurrency(options.discoveryConcurrency),
|
|
202
|
+
}).pipe(Effect.map((connectors) => ({
|
|
203
|
+
serverInfo: options.serverInfo,
|
|
204
|
+
connectaVersion: CONNECTA_VERSION,
|
|
205
|
+
connectors,
|
|
206
|
+
activityEnabled: options.activityEnabled,
|
|
207
|
+
credentialManagement: options.credentialManagement,
|
|
208
|
+
oauthManagement: options.oauthManagement || Boolean(owner),
|
|
209
|
+
})));
|
|
210
|
+
});
|
|
211
|
+
}
|