@zackbart/connecta 0.25.0 → 0.26.1
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 +6 -5
- package/CHANGELOG.md +387 -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/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +80 -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 +478 -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 +217 -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 +653 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- package/dist/catalog-service.js +39 -29
- package/dist/catalog.js +42 -8
- 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/connectors/remote-mcp.js +48 -9
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +431 -75
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +79 -14
- package/dist/executors/quickjs.js +4 -2
- package/dist/index.d.ts +62 -6
- package/dist/index.js +230 -6
- package/dist/invocation.js +56 -10
- package/dist/meta-tools.js +7 -2
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +372 -28
- package/dist/page-styles.js +7 -0
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +164 -60
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +6 -69
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +27 -5
- package/dist/routes/mcp.js +120 -59
- package/dist/routes/oauth-management.js +87 -20
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/operator.js +1 -0
- package/dist/routes/shared.d.ts +23 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +2 -1
- package/dist/routes/ui.js +19 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +30 -2
- package/dist/skills.js +30 -11
- package/dist/timeout.js +26 -0
- package/dist/tool-safety.d.ts +35 -0
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +23 -0
- package/dist/ui.d.ts +17 -5
- package/dist/ui.js +51 -68
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +214 -27
- package/documentation/auth.md +186 -2
- package/documentation/code-mode.md +236 -63
- package/documentation/meta-tools.md +55 -29
- package/ethos.md +12 -10
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +174 -10
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/index.ts +36 -1
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +5 -1
- package/package.json +6 -2
- 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
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import { Effect, Result } from "effect";
|
|
2
|
+
import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
|
|
2
3
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
4
|
+
import { withDeadlineEffect } from "../runtime/run.js";
|
|
3
5
|
import { isSafeHttpUrl } from "../ui.js";
|
|
4
6
|
import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
|
|
5
7
|
import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
8
|
+
/** Bounds the start and its handoff; invalidation and scope close finish after it. */
|
|
9
|
+
const OAUTH_START_TIMEOUT_MS = 30_000;
|
|
10
|
+
/**
|
|
11
|
+
* How a POST starts authorization, from its `mode` query parameter:
|
|
12
|
+
* `restart` (the default) resets the connector to a fresh epoch — wiping the
|
|
13
|
+
* grant, the client registration, and discovery — before starting over;
|
|
14
|
+
* `continue` hands back a recent pending authorization URL when one exists
|
|
15
|
+
* and otherwise starts a flow in the current epoch, keeping the stored
|
|
16
|
+
* registration. `null` is a malformed request.
|
|
17
|
+
*/
|
|
18
|
+
function startMode(url) {
|
|
19
|
+
const modes = url.searchParams.getAll("mode");
|
|
20
|
+
if (modes.length === 0)
|
|
21
|
+
return "restart";
|
|
22
|
+
const [mode] = modes;
|
|
23
|
+
return modes.length === 1 && (mode === "continue" || mode === "restart")
|
|
24
|
+
? mode
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
6
27
|
function oauthManagementRequest(context, connectorId) {
|
|
7
|
-
const { request, baseUrl, defer } = context;
|
|
28
|
+
const { request, baseUrl, defer, opts } = context;
|
|
8
29
|
return Effect.gen(function* () {
|
|
9
30
|
if (!isSameOrigin(request, baseUrl)) {
|
|
10
31
|
return yield* refuse("same-origin request required", 403);
|
|
@@ -27,35 +48,81 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
27
48
|
if (!disconnecting && request.method !== "POST") {
|
|
28
49
|
return yield* refuse("method not allowed", 405);
|
|
29
50
|
}
|
|
30
|
-
const
|
|
51
|
+
const mode = disconnecting ? undefined : startMode(context.url);
|
|
52
|
+
if (mode === null) {
|
|
53
|
+
return yield* refuse('mode must be "continue" or "restart"', 400);
|
|
54
|
+
}
|
|
55
|
+
let ctx;
|
|
31
56
|
// The connector scope this request opened ends with it, however it ends.
|
|
32
|
-
yield* Effect.addFinalizer(() => closeScope(connector, ctx, defer));
|
|
33
|
-
const
|
|
57
|
+
yield* Effect.addFinalizer(() => ctx ? closeScope(connector, ctx, defer) : Effect.void);
|
|
58
|
+
const timeoutError = new Error("OAuth authorization start timed out");
|
|
59
|
+
const operation = yield* Effect.result(withDeadlineEffect((signal) => Effect.tryPromise({
|
|
34
60
|
try: async () => {
|
|
61
|
+
ctx = registry.contextFor(connectorId, baseUrl, {}, { signal });
|
|
35
62
|
if (disconnecting) {
|
|
36
63
|
await disconnectAuth(ctx);
|
|
37
64
|
return undefined;
|
|
38
65
|
}
|
|
39
|
-
const started = await startAuth(ctx, { force:
|
|
66
|
+
const started = await startAuth(ctx, { force: mode === "restart" });
|
|
67
|
+
if (signal.aborted)
|
|
68
|
+
throw signal.reason;
|
|
40
69
|
if (started.authorizationUrl) {
|
|
41
70
|
await registry.bindOAuthHandoff(connectorId, started.authorizationUrl);
|
|
42
71
|
}
|
|
43
72
|
return started;
|
|
44
73
|
},
|
|
45
74
|
catch: (error) => error,
|
|
75
|
+
}), {
|
|
76
|
+
// A disconnect deliberately ignores both browser cancellation and
|
|
77
|
+
// this start deadline so its reset reaches invalidation.
|
|
78
|
+
...(disconnecting ? {} : { timeoutMs: OAUTH_START_TIMEOUT_MS }),
|
|
79
|
+
...(disconnecting ? {} : { signal: request.signal }),
|
|
80
|
+
timeoutError,
|
|
46
81
|
}));
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
82
|
+
const opened = ctx;
|
|
83
|
+
if (Result.isFailure(operation) && opened) {
|
|
84
|
+
// Storage has no abort contract. If a reset's generation write is still
|
|
85
|
+
// pending, answering now would let it publish over a newer flow.
|
|
86
|
+
yield* Effect.promise(() => drainOAuthStartResets(opened.requestScope ?? opened));
|
|
87
|
+
}
|
|
88
|
+
// A disconnect or a restart invalidates the old grant and its cached
|
|
89
|
+
// catalog, even when a partially failed physical cleanup left its epoch
|
|
90
|
+
// fence standing. So does a continued start that had to begin a new flow:
|
|
91
|
+
// the grant it found was missing or refused. A continued start that
|
|
92
|
+
// reused a pending URL or found the connection healthy changed nothing,
|
|
93
|
+
// so the catalog stays. (A continue does reset a disconnected connector,
|
|
94
|
+
// but the disconnect already invalidated that catalog.)
|
|
95
|
+
const started = Result.isSuccess(operation) ? operation.success : undefined;
|
|
96
|
+
const reused = mode === "continue" && started?.authorizationReused === true;
|
|
97
|
+
const unchanged = mode === "continue" && (reused || started?.state === "ok");
|
|
98
|
+
const invalidated = unchanged
|
|
99
|
+
? Result.succeed(undefined)
|
|
100
|
+
: yield* Effect.result(Effect.tryPromise({
|
|
101
|
+
try: () => registry.invalidateStored(connectorId),
|
|
102
|
+
catch: (error) => error,
|
|
103
|
+
}));
|
|
104
|
+
// Every failure below answers in the route's own fixed words. A hook's
|
|
105
|
+
// rejection and a status message can quote a token endpoint's error body
|
|
106
|
+
// or a provider's refusal, either of which can quote the secret it was
|
|
107
|
+
// sent, so that text goes to the deployment's log and nowhere else.
|
|
108
|
+
const failed = (detail, fixed, status) => {
|
|
109
|
+
opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : mode} failed: ${detail}`);
|
|
110
|
+
return refuse(fixed, status);
|
|
111
|
+
};
|
|
112
|
+
const couldNot = disconnecting
|
|
113
|
+
? "OAuth disconnect failed"
|
|
114
|
+
: "OAuth authorization could not start";
|
|
53
115
|
// A Result, not a caught value, decides whether it failed: a hook that
|
|
54
116
|
// rejects with no reason at all still failed.
|
|
55
|
-
if (Result.isFailure(operation))
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
117
|
+
if (Result.isFailure(operation)) {
|
|
118
|
+
if (operation.failure === timeoutError) {
|
|
119
|
+
return yield* failed(`start exceeded ${OAUTH_START_TIMEOUT_MS} ms`, "OAuth authorization start timed out", 504);
|
|
120
|
+
}
|
|
121
|
+
return yield* failed(msg(operation.failure), couldNot, 400);
|
|
122
|
+
}
|
|
123
|
+
if (Result.isFailure(invalidated)) {
|
|
124
|
+
return yield* failed(`stored-state invalidation: ${msg(invalidated.failure)}`, couldNot, 400);
|
|
125
|
+
}
|
|
59
126
|
const result = operation.success;
|
|
60
127
|
if (!result) {
|
|
61
128
|
return new Response(null, {
|
|
@@ -70,16 +137,16 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
70
137
|
? result.authorizationUrl
|
|
71
138
|
: undefined;
|
|
72
139
|
if (result.state === "error") {
|
|
73
|
-
return yield*
|
|
140
|
+
return yield* failed(result.message || "the connector reported an error state", couldNot, 502);
|
|
74
141
|
}
|
|
75
142
|
if (result.state === "auth_required" && !authorizationUrl) {
|
|
76
|
-
return yield*
|
|
77
|
-
"OAuth authorization requires consent but no safe URL is available", 502);
|
|
143
|
+
return yield* failed(result.message || "consent is required but no safe authorization URL was returned", "OAuth authorization requires consent but no safe URL is available", 502);
|
|
78
144
|
}
|
|
145
|
+
// No message: a status message is the same text the Connections page
|
|
146
|
+
// stopped shipping, and the state and the link are the whole answer.
|
|
79
147
|
return privateJson({
|
|
80
148
|
state: result.state,
|
|
81
|
-
...(
|
|
82
|
-
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
149
|
+
...(authorizationUrl ? { authorizationUrl, reused } : {}),
|
|
83
150
|
});
|
|
84
151
|
}).pipe(Effect.scoped);
|
|
85
152
|
}
|
package/dist/routes/oauth.js
CHANGED
|
@@ -1,92 +1,59 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
|
|
3
|
-
import { resolveBranding } from "../branding.js";
|
|
3
|
+
import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
|
|
4
4
|
import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
|
|
5
5
|
import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
.replaceAll('"', """);
|
|
12
|
-
}
|
|
6
|
+
const TONE_MARKS = {
|
|
7
|
+
ok: { className: " ok", icon: STATUS_ICONS.ok, label: "Connected" },
|
|
8
|
+
declined: { className: "", icon: STATUS_ICONS.declined, label: "Not connected" },
|
|
9
|
+
problem: { className: " danger", icon: STATUS_ICONS.problem, label: "Not connected" },
|
|
10
|
+
};
|
|
13
11
|
/**
|
|
14
|
-
* The callback's one page. It renders a reason from a
|
|
15
|
-
* reason's fixed copy, so nothing the request carried —
|
|
16
|
-
* parameter, an exchange failure's message — can reach
|
|
17
|
-
*
|
|
12
|
+
* The callback's one page, in the shared layout. It renders a reason from a
|
|
13
|
+
* closed set and that reason's fixed copy, so nothing the request carried —
|
|
14
|
+
* the provider's `error` parameter, an exchange failure's message — can reach
|
|
15
|
+
* the body. The connector's title appears only when `connector` is passed,
|
|
16
|
+
* which the route does only after the state check, and `oauthCallbackOutcome`
|
|
17
|
+
* gates it by reason again; every refusal before that point is the same bytes
|
|
18
|
+
* for every connector id and path. Escaping stays anyway: branding and a
|
|
19
|
+
* configured title are still strings.
|
|
20
|
+
*
|
|
21
|
+
* The fix prompt is for whoever runs the deployment, and the person reading
|
|
22
|
+
* this page is often a teammate finishing personal auth, so it sits folded
|
|
23
|
+
* under a quiet disclosure rather than in the message.
|
|
18
24
|
*/
|
|
19
|
-
function html(reason,
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
: `<span class="product">${escapeHtml(brand.productName)}</span>`
|
|
25
|
+
function html(reason, opts, connector) {
|
|
26
|
+
const brand = resolveBranding(opts.branding);
|
|
27
|
+
const outcome = oauthCallbackOutcome(reason, connector, brand.productName);
|
|
28
|
+
const mark = TONE_MARKS[outcome.tone];
|
|
29
|
+
const uiMounted = Boolean(opts.ui);
|
|
30
|
+
const home = uiMounted
|
|
31
|
+
? `<div class="status-actions"><a class="btn${outcome.tone === "ok" ? "" : " primary"}" href="/">Return to ${escapeHtml(brand.productName)}</a></div>`
|
|
32
|
+
: "";
|
|
33
|
+
const details = outcome.fixPrompt
|
|
34
|
+
? `<details class="status-details">
|
|
35
|
+
<summary>Details for the operator</summary>
|
|
36
|
+
<p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is written for a coding agent working on the deployment and carries nothing from the provider's response.</p>
|
|
37
|
+
<pre>${escapeHtml(outcome.fixPrompt)}</pre>
|
|
38
|
+
</details>`
|
|
34
39
|
: "";
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
<head>
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
<meta name="theme-color" content="${escapeHtml(brand.themeColor)}">
|
|
41
|
-
<link rel="icon" href="${escapeHtml(brand.faviconHref)}" type="image/svg+xml">
|
|
42
|
-
<link rel="shortcut icon" href="/favicon.ico">
|
|
43
|
-
<title>${escapeHtml(title)}</title>
|
|
44
|
-
<style>
|
|
45
|
-
* { border-radius: 0; box-sizing: border-box; }
|
|
46
|
-
html { color: #000; background: #fff; font: 16px/1.5 "Helvetica Neue",
|
|
47
|
-
Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
48
|
-
body { margin: 0; min-height: 100vh; }
|
|
49
|
-
::selection { color: #fff; background: #000; }
|
|
50
|
-
.shell { margin: 0 auto; max-width: 70rem; padding: 1rem; }
|
|
51
|
-
.grid { display: grid; gap: 1rem 1.5rem;
|
|
52
|
-
grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
53
|
-
.brand { font-weight: 500; grid-column: 1; text-decoration: none; }
|
|
54
|
-
.product { grid-column: 2 / -1; }
|
|
55
|
-
main { margin-top: 5rem; }
|
|
56
|
-
h1, p { font: inherit; margin: 0; }
|
|
57
|
-
h1 { grid-column: 1; }
|
|
58
|
-
.copy { grid-column: 2 / -1; max-width: 34em; }
|
|
59
|
-
.copy > * + * { margin-top: 1.5rem; }
|
|
60
|
-
a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
|
|
61
|
-
text-underline-offset: .22em; }
|
|
62
|
-
a:hover { text-decoration-color: transparent; }
|
|
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; }
|
|
67
|
-
@media (max-width: 36.99rem) {
|
|
68
|
-
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
69
|
-
.product { grid-column: 2; }
|
|
70
|
-
main { margin-top: 3rem; }
|
|
71
|
-
h1, .copy { grid-column: 1 / -1; }
|
|
72
|
-
}
|
|
73
|
-
</style>
|
|
74
|
-
</head>
|
|
75
|
-
<body>
|
|
76
|
-
<header class="shell grid">
|
|
77
|
-
${owner}
|
|
78
|
-
${product}
|
|
79
|
-
</header>
|
|
80
|
-
<main class="shell grid" data-oauth-callback="${outcome.reason}">
|
|
81
|
-
<h1>Connection status</h1>
|
|
82
|
-
<div class="copy">
|
|
83
|
-
<p>${escapeHtml(outcome.message)}</p>
|
|
84
|
-
${outcome.fixPrompt ? `<details><summary>Fix prompt for a coding agent</summary><pre>${escapeHtml(outcome.fixPrompt)}</pre></details>` : ""}
|
|
85
|
-
${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
|
|
40
|
+
const body = `<main class="page shell" data-oauth-callback="${outcome.reason}">
|
|
41
|
+
<section class="status-page">
|
|
42
|
+
<div class="status-head">
|
|
43
|
+
<span class="status-mark${mark.className}" aria-hidden="true">${mark.icon}</span>
|
|
44
|
+
<p class="status-label${mark.className}">${mark.label}</p>
|
|
86
45
|
</div>
|
|
87
|
-
|
|
88
|
-
</
|
|
89
|
-
|
|
46
|
+
<h1>${escapeHtml(outcome.heading)}</h1>
|
|
47
|
+
<p class="status-copy">${escapeHtml(outcome.message)}</p>
|
|
48
|
+
${home}
|
|
49
|
+
${details}
|
|
50
|
+
</section>
|
|
51
|
+
</main>`;
|
|
52
|
+
return new Response(renderPage(opts.branding, {
|
|
53
|
+
title: `${outcome.heading} — ${brand.pageTitle}`,
|
|
54
|
+
uiMounted,
|
|
55
|
+
body,
|
|
56
|
+
}), { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
90
57
|
}
|
|
91
58
|
/**
|
|
92
59
|
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
@@ -138,10 +105,10 @@ async function finishOAuthCallback(context) {
|
|
|
138
105
|
const { path, url, baseUrl, opts } = context;
|
|
139
106
|
const error = url.searchParams.get("error");
|
|
140
107
|
if (error)
|
|
141
|
-
return html(providerErrorReason(error), opts
|
|
108
|
+
return html(providerErrorReason(error), opts);
|
|
142
109
|
const code = url.searchParams.get("code");
|
|
143
110
|
if (!code)
|
|
144
|
-
return html("invalid_callback", opts
|
|
111
|
+
return html("invalid_callback", opts);
|
|
145
112
|
const id = path.slice("/oauth/callback/".length);
|
|
146
113
|
const state = url.searchParams.get("state");
|
|
147
114
|
const callbackTarget = await opts.registry.oauthCallbackView(id, state);
|
|
@@ -155,7 +122,7 @@ async function finishOAuthCallback(context) {
|
|
|
155
122
|
const connectorContext = callbackRegistry
|
|
156
123
|
? callbackRegistry.contextFor(id, baseUrl)
|
|
157
124
|
: opts.registry.contextFor(id, baseUrl);
|
|
158
|
-
const refused = () => html("invalid_callback", opts
|
|
125
|
+
const refused = () => html("invalid_callback", opts);
|
|
159
126
|
if (!connector || !connector.finishAuth) {
|
|
160
127
|
await equalizeRefusalCost(connectorContext);
|
|
161
128
|
return refused();
|
|
@@ -220,19 +187,19 @@ async function finishOAuthCallback(context) {
|
|
|
220
187
|
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
221
188
|
`${loggableValue(id)} with 500: its principal handoff could not be ` +
|
|
222
189
|
`consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
|
|
223
|
-
return html("handoff_failed", opts
|
|
190
|
+
return html("handoff_failed", opts, connector);
|
|
224
191
|
}
|
|
225
192
|
}
|
|
226
193
|
try {
|
|
227
194
|
await connector.finishAuth(code, connectorContext, url.searchParams);
|
|
228
195
|
await callbackRegistry.invalidateStored(id);
|
|
229
|
-
return html("connected", opts
|
|
196
|
+
return html("connected", opts, connector);
|
|
230
197
|
}
|
|
231
198
|
catch (err) {
|
|
232
199
|
// The page names the reason and nothing else; what the exchange threw can
|
|
233
200
|
// quote a token endpoint's body, so it goes only to the operator log.
|
|
234
201
|
opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
|
|
235
202
|
`with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
|
|
236
|
-
return html("exchange_failed", opts
|
|
203
|
+
return html("exchange_failed", opts, connector);
|
|
237
204
|
}
|
|
238
205
|
}
|
package/dist/routes/operator.js
CHANGED
|
@@ -51,6 +51,7 @@ export function scopeFor(authz, connectorIds = authz.connectorIds) {
|
|
|
51
51
|
return {
|
|
52
52
|
connectorIds,
|
|
53
53
|
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
54
|
+
...(authz.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
|
|
54
55
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
55
56
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
56
57
|
};
|
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;
|
|
@@ -39,6 +41,15 @@ export interface ServerOptions {
|
|
|
39
41
|
hostCallTimeoutMs?: number | undefined;
|
|
40
42
|
/** Hard ceiling on one execute_code run, outside the sandbox. Default 120_000. */
|
|
41
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;
|
|
42
53
|
/** Required sandbox backing the execute_code meta-tool. */
|
|
43
54
|
executor: Executor;
|
|
44
55
|
/** Sanitized identity of the configured sandbox, when it has one. */
|
|
@@ -50,6 +61,8 @@ export interface ServerOptions {
|
|
|
50
61
|
/** Optional browser routes, with no implementation import in core. */
|
|
51
62
|
ui?: OperatorSurface | undefined;
|
|
52
63
|
activityModule?: ActivityModule | undefined;
|
|
64
|
+
/** Optional team pages; their routes mount only beside `ui`. */
|
|
65
|
+
artifactsModule?: ArtifactsModule | undefined;
|
|
53
66
|
/** Optional browser UI and OAuth result-page labels. */
|
|
54
67
|
branding?: ConnectaBranding | undefined;
|
|
55
68
|
}
|
|
@@ -86,6 +99,8 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
|
|
|
86
99
|
connectorIds: "all" | readonly string[];
|
|
87
100
|
/** Per-connector tool allowlist for connectors granted by address only. */
|
|
88
101
|
toolAccess?: ToolAccess;
|
|
102
|
+
/** Granted addresses that still require the catalog's read-only hint. */
|
|
103
|
+
guardedToolAccess?: ToolAccess;
|
|
89
104
|
operator: boolean;
|
|
90
105
|
credentialAdministration: ConnectorPermission;
|
|
91
106
|
personalConnection: ConnectorPermission;
|
|
@@ -108,6 +123,13 @@ type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
|
|
|
108
123
|
}>;
|
|
109
124
|
/** Unknown configured ids refuse the complete view, including management rights. */
|
|
110
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;
|
|
111
133
|
export declare function mayManageConnector(authz: AuthorizedIdentity, connector: {
|
|
112
134
|
id: string;
|
|
113
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;
|
package/dist/routes/ui-data.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveDiscoveryConcurrency } from "../concurrency.js";
|
|
|
10
10
|
import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "../credential-rules.js";
|
|
11
11
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
12
12
|
import { withDeadlineEffect } from "../runtime/run.js";
|
|
13
|
+
import { isApprovalExempt, NO_EXEMPTIONS, } from "../tool-safety.js";
|
|
13
14
|
import { uiProblemFor, uiToolSafety } from "../ui.js";
|
|
14
15
|
import { CONNECTA_VERSION } from "../version.js";
|
|
15
16
|
const DETAIL_TIMEOUT_MS = 30_000;
|
|
@@ -59,7 +60,7 @@ export function uiData(registry, baseUrl, options) {
|
|
|
59
60
|
name: t.name,
|
|
60
61
|
address: `${c.id}.${t.name}`,
|
|
61
62
|
...(t.description ? { description: t.description } : {}),
|
|
62
|
-
safety: uiToolSafety(t),
|
|
63
|
+
safety: uiToolSafety(t, isApprovalExempt(options.approval ?? NO_EXEMPTIONS, c, t.name, t)),
|
|
63
64
|
}))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
|
|
64
65
|
// The registry owns the failed catalog observation; the page only
|
|
65
66
|
// needs to know there was one.
|
package/dist/routes/ui.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CONNECTA_VERSION } from "../version.js";
|
|
|
3
3
|
import { CONNECTA_FAVICON_ICO } from "../favicon.js";
|
|
4
4
|
import { CONNECTA_FAVICON_SVG, operatorPageForPath, renderUiHtml, } from "../ui.js";
|
|
5
5
|
import { authorized, refuse, scopeFor, serveOperator, visibleRegistry, } from "./operator.js";
|
|
6
|
-
import { mayManageConnector, privateJson, } from "./shared.js";
|
|
6
|
+
import { mayManageConnector, mayViewArtifacts, privateJson, } from "./shared.js";
|
|
7
7
|
import { uiData } from "./ui-data.js";
|
|
8
8
|
/**
|
|
9
9
|
* Headers that make an operator-supplied favicon body inert on this origin.
|
|
@@ -71,7 +71,10 @@ export async function routeUi(context) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
const operatorPage = operatorPageForPath(path);
|
|
74
|
-
|
|
74
|
+
const artifactPage = operatorPage === "artifacts" || operatorPage === "artifact";
|
|
75
|
+
if (operatorPage &&
|
|
76
|
+
(operatorPage !== "activity" || opts.activity?.list) &&
|
|
77
|
+
(!artifactPage || opts.artifactsModule)) {
|
|
75
78
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
76
79
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
77
80
|
}
|
|
@@ -89,14 +92,24 @@ export async function routeUi(context) {
|
|
|
89
92
|
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
90
93
|
// — only script execution, the XSS sink, is gated.
|
|
91
94
|
const nonce = uiScriptNonce();
|
|
95
|
+
// An artifact shell frames the sandboxed page from its own origin and
|
|
96
|
+
// nothing else; Connections stays on the deployment's public origin.
|
|
97
|
+
const homeUrl = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
|
|
98
|
+
// The artifact host serves no favicon, so a shell there takes its icons
|
|
99
|
+
// from the public origin, as the 404 does; on the main host they stay
|
|
100
|
+
// root-relative.
|
|
101
|
+
const onArtifactHost = Boolean(opts.artifactOrigin) &&
|
|
102
|
+
url.origin === new URL(opts.artifactOrigin).origin;
|
|
103
|
+
const iconOrigin = onArtifactHost ? opts.publicUrl : undefined;
|
|
92
104
|
return new Response(request.method === "HEAD"
|
|
93
105
|
? null
|
|
94
|
-
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage), {
|
|
106
|
+
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl, iconOrigin }), {
|
|
95
107
|
status: 200,
|
|
96
108
|
headers: {
|
|
97
109
|
"Content-Type": "text/html; charset=utf-8",
|
|
98
110
|
"Content-Security-Policy": `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
99
|
-
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
|
111
|
+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'" +
|
|
112
|
+
(artifactPage ? "; frame-src 'self'" : ""),
|
|
100
113
|
"X-Content-Type-Options": "nosniff",
|
|
101
114
|
},
|
|
102
115
|
});
|
|
@@ -146,6 +159,7 @@ function connectorDetail(context, id) {
|
|
|
146
159
|
mayManage,
|
|
147
160
|
timeoutMs: opts.probeTimeoutMs ?? 30_000,
|
|
148
161
|
signal: request.signal,
|
|
162
|
+
approval: opts.approval,
|
|
149
163
|
});
|
|
150
164
|
return privateJson({ ...data.connectors[0], permissions: permissions(connector) });
|
|
151
165
|
});
|
|
@@ -173,6 +187,7 @@ function summary(context) {
|
|
|
173
187
|
serverInfo: opts.serverInfo,
|
|
174
188
|
connectaVersion: CONNECTA_VERSION,
|
|
175
189
|
activityEnabled,
|
|
190
|
+
...(opts.artifactsModule && mayViewArtifacts(authz, opts.registry) ? { artifactsEnabled: true } : {}),
|
|
176
191
|
credentialManagement,
|
|
177
192
|
oauthManagement: visible.some(c => mayManage(c.id)),
|
|
178
193
|
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) })),
|