@zackbart/connecta 0.26.0 → 0.26.2
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/CHANGELOG.md +141 -0
- package/README.md +11 -0
- package/dist/access-tokens.d.ts +38 -0
- package/dist/access-tokens.js +294 -0
- package/dist/artifacts/document.js +6 -2
- package/dist/artifacts/markdown.js +43 -14
- package/dist/artifacts/routes.js +27 -4
- package/dist/artifacts/validate.js +1 -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/connectors/remote-mcp.js +32 -7
- package/dist/execute.js +7 -8
- package/dist/identity.js +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +13 -2
- package/dist/module-contracts.d.ts +5 -0
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +1 -0
- package/dist/operator-ui/view.js +304 -33
- package/dist/page-styles.js +7 -0
- package/dist/registry.js +16 -13
- package/dist/routes/access-tokens.js +110 -0
- package/dist/routes/mcp.js +5 -1
- package/dist/routes/oauth-management.js +66 -12
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/shared.d.ts +3 -1
- package/dist/routes/shared.js +6 -1
- package/dist/routes/ui.js +9 -1
- package/dist/server.js +8 -5
- package/dist/skills.js +6 -6
- package/dist/types.d.ts +5 -0
- package/dist/ui.d.ts +6 -1
- package/dist/ui.js +27 -64
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +19 -9
- package/documentation/auth.md +166 -2
- package/documentation/code-mode.md +15 -15
- package/documentation/meta-tools.md +12 -5
- package/ethos.md +1 -1
- package/package.json +5 -1
- package/templates/node/README.md +11 -0
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +5 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { authorizeUiIdentity, isSameOrigin, privateJson, } from "./shared.js";
|
|
2
|
+
async function readName(request) {
|
|
3
|
+
if (!request.headers
|
|
4
|
+
.get("content-type")
|
|
5
|
+
?.toLowerCase()
|
|
6
|
+
.startsWith("application/json")) {
|
|
7
|
+
return {
|
|
8
|
+
ok: false,
|
|
9
|
+
response: privateJson({ error: "Content-Type must be application/json" }, { status: 415 }),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const reader = request.body?.getReader();
|
|
13
|
+
const chunks = [];
|
|
14
|
+
let size = 0;
|
|
15
|
+
if (reader) {
|
|
16
|
+
try {
|
|
17
|
+
while (true) {
|
|
18
|
+
const { done, value } = await reader.read();
|
|
19
|
+
if (done)
|
|
20
|
+
break;
|
|
21
|
+
size += value.byteLength;
|
|
22
|
+
if (size > 1_000) {
|
|
23
|
+
reader.cancel().catch(() => { });
|
|
24
|
+
return { ok: false, response: privateJson({ error: "request body is too large" }, { status: 413 }) };
|
|
25
|
+
}
|
|
26
|
+
chunks.push(value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
reader.releaseLock();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const bytes = new Uint8Array(size);
|
|
34
|
+
let offset = 0;
|
|
35
|
+
for (const chunk of chunks) {
|
|
36
|
+
bytes.set(chunk, offset);
|
|
37
|
+
offset += chunk.byteLength;
|
|
38
|
+
}
|
|
39
|
+
const raw = new TextDecoder().decode(bytes);
|
|
40
|
+
if (raw.length > 1_000) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
response: privateJson({ error: "request body is too large" }, { status: 413 }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const body = JSON.parse(raw);
|
|
48
|
+
return { ok: true, name: body.name };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Interactive-operator lifecycle for deployment access tokens. The token itself
|
|
59
|
+
* is deliberately never an administrator credential and cannot reach here.
|
|
60
|
+
*/
|
|
61
|
+
export async function routeAccessTokens(context, manager) {
|
|
62
|
+
const match = /^\/ui\/access-tokens(?:\/([0-9a-f-]{36}))?$/.exec(context.path);
|
|
63
|
+
if (!match)
|
|
64
|
+
return null;
|
|
65
|
+
const { request, baseUrl, opts } = context;
|
|
66
|
+
if (request.method === "OPTIONS") {
|
|
67
|
+
return privateJson({ error: "CORS is not allowed" }, { status: 403 });
|
|
68
|
+
}
|
|
69
|
+
const mutating = request.method !== "GET";
|
|
70
|
+
if (mutating && !isSameOrigin(request, baseUrl)) {
|
|
71
|
+
return privateJson({ error: "same-origin request required" }, { status: 403 });
|
|
72
|
+
}
|
|
73
|
+
const admin = await authorizeUiIdentity(request, baseUrl, opts.auth, "access token management", context.runtimeContext, opts.identity);
|
|
74
|
+
if (!admin.ok)
|
|
75
|
+
return admin.response;
|
|
76
|
+
if (!admin.accessTokenManagement || !admin.identity.principal) {
|
|
77
|
+
return privateJson({ error: "access token management permission required" }, { status: 403 });
|
|
78
|
+
}
|
|
79
|
+
const id = match[1];
|
|
80
|
+
try {
|
|
81
|
+
if (!id && request.method === "GET") {
|
|
82
|
+
return privateJson({ accessTokens: await manager.list() });
|
|
83
|
+
}
|
|
84
|
+
if (!id && request.method === "POST") {
|
|
85
|
+
const input = await readName(request);
|
|
86
|
+
if (!input.ok)
|
|
87
|
+
return input.response;
|
|
88
|
+
return privateJson(await manager.create(input.name, admin.identity.principal), { status: 201 });
|
|
89
|
+
}
|
|
90
|
+
if (id && request.method === "PUT") {
|
|
91
|
+
const input = await readName(request);
|
|
92
|
+
if (!input.ok)
|
|
93
|
+
return input.response;
|
|
94
|
+
const accessToken = await manager.rename(id, input.name);
|
|
95
|
+
return accessToken
|
|
96
|
+
? privateJson({ accessToken })
|
|
97
|
+
: privateJson({ error: "unknown access token" }, { status: 404 });
|
|
98
|
+
}
|
|
99
|
+
if (id && request.method === "DELETE") {
|
|
100
|
+
const accessToken = await manager.revoke(id, `${admin.identity.principal.namespace}:${admin.identity.principal.id}`);
|
|
101
|
+
return accessToken
|
|
102
|
+
? privateJson({ accessToken })
|
|
103
|
+
: privateJson({ error: "unknown access token" }, { status: 404 });
|
|
104
|
+
}
|
|
105
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return privateJson({ error: "Access token operation failed; check the name, capacity, and storage" }, { status: 400 });
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/routes/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
|
|
2
2
|
import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
|
|
3
|
+
import { notFoundResponse } from "../branding.js";
|
|
3
4
|
import { registerExecuteTool } from "../execute.js";
|
|
4
5
|
import { ExecutorAdmissionError, } from "../executor-admission.js";
|
|
5
6
|
import { registerMetaTools } from "../meta-tools.js";
|
|
@@ -495,7 +496,10 @@ export function createMcpRoute(opts) {
|
|
|
495
496
|
if (!pool || reason !== "granted") {
|
|
496
497
|
opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
|
|
497
498
|
(authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
|
|
498
|
-
|
|
499
|
+
// The server's own 404, so a browser sees the page every unserved
|
|
500
|
+
// path shows and an MCP client (which never asks for text/html)
|
|
501
|
+
// still reads a plain "Not Found".
|
|
502
|
+
return cors(notFoundResponse(request, opts));
|
|
499
503
|
}
|
|
500
504
|
access = intersectAccess(authz, pool.access);
|
|
501
505
|
}
|
|
@@ -1,8 +1,29 @@
|
|
|
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
28
|
const { request, baseUrl, defer, opts } = context;
|
|
8
29
|
return Effect.gen(function* () {
|
|
@@ -27,35 +48,65 @@ 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
|
+
}));
|
|
53
104
|
// Every failure below answers in the route's own fixed words. A hook's
|
|
54
105
|
// rejection and a status message can quote a token endpoint's error body
|
|
55
106
|
// or a provider's refusal, either of which can quote the secret it was
|
|
56
107
|
// sent, so that text goes to the deployment's log and nowhere else.
|
|
57
108
|
const failed = (detail, fixed, status) => {
|
|
58
|
-
opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" :
|
|
109
|
+
opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : mode} failed: ${detail}`);
|
|
59
110
|
return refuse(fixed, status);
|
|
60
111
|
};
|
|
61
112
|
const couldNot = disconnecting
|
|
@@ -64,6 +115,9 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
64
115
|
// A Result, not a caught value, decides whether it failed: a hook that
|
|
65
116
|
// rejects with no reason at all still failed.
|
|
66
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
|
+
}
|
|
67
121
|
return yield* failed(msg(operation.failure), couldNot, 400);
|
|
68
122
|
}
|
|
69
123
|
if (Result.isFailure(invalidated)) {
|
|
@@ -92,7 +146,7 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
92
146
|
// stopped shipping, and the state and the link are the whole answer.
|
|
93
147
|
return privateJson({
|
|
94
148
|
state: result.state,
|
|
95
|
-
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
149
|
+
...(authorizationUrl ? { authorizationUrl, reused } : {}),
|
|
96
150
|
});
|
|
97
151
|
}).pipe(Effect.scoped);
|
|
98
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/shared.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Implementation } from "@modelcontextprotocol/server";
|
|
2
|
-
import type { ActivityModule, ArtifactsModule, OperatorSurface } from "../module-contracts.js";
|
|
2
|
+
import type { AccessTokensModule, 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";
|
|
@@ -63,6 +63,7 @@ export interface ServerOptions {
|
|
|
63
63
|
activityModule?: ActivityModule | undefined;
|
|
64
64
|
/** Optional team pages; their routes mount only beside `ui`. */
|
|
65
65
|
artifactsModule?: ArtifactsModule | undefined;
|
|
66
|
+
accessTokens?: AccessTokensModule | undefined;
|
|
66
67
|
/** Optional browser UI and OAuth result-page labels. */
|
|
67
68
|
branding?: ConnectaBranding | undefined;
|
|
68
69
|
}
|
|
@@ -102,6 +103,7 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
|
|
|
102
103
|
/** Granted addresses that still require the catalog's read-only hint. */
|
|
103
104
|
guardedToolAccess?: ToolAccess;
|
|
104
105
|
operator: boolean;
|
|
106
|
+
accessTokenManagement: boolean;
|
|
105
107
|
credentialAdministration: ConnectorPermission;
|
|
106
108
|
personalConnection: ConnectorPermission;
|
|
107
109
|
/** Backward-compatible name used by operator views. */
|
package/dist/routes/shared.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
|
|
|
45
45
|
response: privateJson({ error: "identity access resolution failed" }, { status: 403 }),
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
return { ok: true, actor, identity, ...access, operator: false, credentialAdministration: "none", personalConnection: "none" };
|
|
48
|
+
return { ok: true, actor, identity, ...access, operator: false, accessTokenManagement: false, credentialAdministration: "none", personalConnection: "none" };
|
|
49
49
|
}
|
|
50
50
|
let lastResponse = null;
|
|
51
51
|
for (const provider of auth) {
|
|
@@ -75,6 +75,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
|
|
|
75
75
|
interactive,
|
|
76
76
|
};
|
|
77
77
|
let operator = interactive;
|
|
78
|
+
let accessTokenManagement = false;
|
|
78
79
|
let credentialAdministration = "none";
|
|
79
80
|
let personalConnection = "none";
|
|
80
81
|
let access;
|
|
@@ -86,6 +87,9 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
|
|
|
86
87
|
}
|
|
87
88
|
access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all", { allowReadOnly: true });
|
|
88
89
|
if (interactive) {
|
|
90
|
+
accessTokenManagement = identityConfig?.accessTokenManagement ? await identityConfig.accessTokenManagement(identity) : false;
|
|
91
|
+
if (typeof accessTokenManagement !== "boolean")
|
|
92
|
+
throw new Error("invalid token management permission");
|
|
89
93
|
credentialAdministration = identityConfig?.credentialAdministration ? await identityConfig.credentialAdministration(identity) : "none";
|
|
90
94
|
personalConnection = principal && identityConfig?.personalConnection ? await identityConfig.personalConnection(identity) : "none";
|
|
91
95
|
}
|
|
@@ -113,6 +117,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
|
|
|
113
117
|
? { principalKey: await identityStorageKey(principal) }
|
|
114
118
|
: {}),
|
|
115
119
|
...access,
|
|
120
|
+
accessTokenManagement,
|
|
116
121
|
credentialAdministration,
|
|
117
122
|
personalConnection,
|
|
118
123
|
operator,
|
package/dist/routes/ui.js
CHANGED
|
@@ -74,6 +74,7 @@ export async function routeUi(context) {
|
|
|
74
74
|
const artifactPage = operatorPage === "artifacts" || operatorPage === "artifact";
|
|
75
75
|
if (operatorPage &&
|
|
76
76
|
(operatorPage !== "activity" || opts.activity?.list) &&
|
|
77
|
+
(operatorPage !== "tokens" || opts.accessTokens) &&
|
|
77
78
|
(!artifactPage || opts.artifactsModule)) {
|
|
78
79
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
79
80
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
@@ -95,9 +96,15 @@ export async function routeUi(context) {
|
|
|
95
96
|
// An artifact shell frames the sandboxed page from its own origin and
|
|
96
97
|
// nothing else; Connections stays on the deployment's public origin.
|
|
97
98
|
const homeUrl = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
|
|
99
|
+
// The artifact host serves no favicon, so a shell there takes its icons
|
|
100
|
+
// from the public origin, as the 404 does; on the main host they stay
|
|
101
|
+
// root-relative.
|
|
102
|
+
const onArtifactHost = Boolean(opts.artifactOrigin) &&
|
|
103
|
+
url.origin === new URL(opts.artifactOrigin).origin;
|
|
104
|
+
const iconOrigin = onArtifactHost ? opts.publicUrl : undefined;
|
|
98
105
|
return new Response(request.method === "HEAD"
|
|
99
106
|
? null
|
|
100
|
-
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl }), {
|
|
107
|
+
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl, iconOrigin }), {
|
|
101
108
|
status: 200,
|
|
102
109
|
headers: {
|
|
103
110
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -181,6 +188,7 @@ function summary(context) {
|
|
|
181
188
|
serverInfo: opts.serverInfo,
|
|
182
189
|
connectaVersion: CONNECTA_VERSION,
|
|
183
190
|
activityEnabled,
|
|
191
|
+
...(opts.accessTokens ? { accessTokenManagement: authz.accessTokenManagement && authz.identity.principal ? "available" : "requires_operator" } : {}),
|
|
184
192
|
...(opts.artifactsModule && mayViewArtifacts(authz, opts.registry) ? { artifactsEnabled: true } : {}),
|
|
185
193
|
credentialManagement,
|
|
186
194
|
oauthManagement: visible.some(c => mayManage(c.id)),
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { notFoundResponse } from "./branding.js";
|
|
2
3
|
import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
|
|
3
4
|
import { isAdmittingExecutor } from "./executor-admission.js";
|
|
4
5
|
import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
|
|
@@ -76,6 +77,7 @@ export function createFetchHandler(opts) {
|
|
|
76
77
|
"/health",
|
|
77
78
|
...(opts.ui?.reservedPaths ?? []),
|
|
78
79
|
...(opts.ui && opts.activity?.list ? ["/activity"] : []),
|
|
80
|
+
...(opts.ui && opts.accessTokens ? ["/tokens"] : []),
|
|
79
81
|
...(opts.ui && opts.artifactsModule ? ["/artifacts", "/artifacts/*"] : []),
|
|
80
82
|
],
|
|
81
83
|
},
|
|
@@ -107,7 +109,7 @@ export function createFetchHandler(opts) {
|
|
|
107
109
|
const response = yield* metadata(request, baseUrl);
|
|
108
110
|
if (response)
|
|
109
111
|
return response;
|
|
110
|
-
return
|
|
112
|
+
return notFoundResponse(request, opts);
|
|
111
113
|
}
|
|
112
114
|
if (path === "/health")
|
|
113
115
|
return yield* Effect.promise(health);
|
|
@@ -117,7 +119,7 @@ export function createFetchHandler(opts) {
|
|
|
117
119
|
const mcp = yield* routeMcp.handle(context);
|
|
118
120
|
if (mcp)
|
|
119
121
|
return mcp;
|
|
120
|
-
return
|
|
122
|
+
return notFoundResponse(request, opts);
|
|
121
123
|
});
|
|
122
124
|
const serve = (request, runtimeContext) => Effect.suspend(() => {
|
|
123
125
|
const url = new URL(request.url);
|
|
@@ -127,15 +129,16 @@ export function createFetchHandler(opts) {
|
|
|
127
129
|
? runtimeContext.waitUntil.bind(runtimeContext)
|
|
128
130
|
: undefined;
|
|
129
131
|
// The artifact hostname has no MCP, health, OAuth, or operator routes.
|
|
130
|
-
// Dispatch before MCP origin checks so even a hostile /mcp
|
|
132
|
+
// Dispatch before MCP origin checks so even a hostile /mcp gets the same
|
|
133
|
+
// 404 as any other unserved path.
|
|
131
134
|
if (opts.artifactOrigin && url.origin === new URL(opts.artifactOrigin).origin) {
|
|
132
135
|
if (!isArtifactPath(path) || !opts.ui || !opts.artifactsModule) {
|
|
133
|
-
return Effect.succeed(withSecurityHeaders(
|
|
136
|
+
return Effect.succeed(withSecurityHeaders(notFoundResponse(request, opts), url, path));
|
|
134
137
|
}
|
|
135
138
|
const context = {
|
|
136
139
|
request, url, path, baseUrl: opts.artifactOrigin, opts, defer, runtimeContext,
|
|
137
140
|
};
|
|
138
|
-
return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ??
|
|
141
|
+
return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ?? notFoundResponse(request, opts), url, path));
|
|
139
142
|
}
|
|
140
143
|
if (opts.artifactOrigin && isArtifactPath(path) && opts.ui && opts.artifactsModule) {
|
|
141
144
|
const target = new URL(opts.artifactOrigin);
|