@zackbart/connecta 0.32.0 → 0.33.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/CHANGELOG.md +63 -0
- package/README.md +15 -7
- package/bin/connecta.mjs +24 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/config-value-sources.js +11 -9
- package/dist/config.d.ts +37 -75
- package/dist/config.js +110 -36
- package/dist/connector-access.d.ts +4 -34
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +3 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +29 -7
- package/dist/describe-config.js +7 -17
- package/dist/index.d.ts +4 -3
- package/dist/index.js +29 -10
- package/dist/invocation.js +5 -3
- package/dist/membership.d.ts +51 -0
- package/dist/membership.js +121 -0
- package/dist/module-contracts.d.ts +23 -6
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -4
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/contract.d.ts +149 -28
- package/dist/operator-ui/endpoints.js +192 -0
- package/dist/operator-ui/fix-prompts.js +4 -4
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +112 -0
- package/dist/operator-ui/model.d.ts +14 -6
- package/dist/operator-ui/view.d.ts +84 -19
- package/dist/operator-ui/view.js +82 -21
- package/dist/page-styles.js +1 -1
- package/dist/provider.d.ts +61 -8
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +53 -16
- package/dist/providers/_shared/google/workspace.js +30 -8
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.js +3 -3
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.js +3 -3
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.js +3 -3
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/gmail/index.js +10 -8
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/mixpanel/index.js +1 -1
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +100 -11
- package/dist/providers/notion/rest.js +45 -5
- package/dist/providers/notion/skill.generated.js +2 -2
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/sheets/index.d.ts +3 -0
- package/dist/providers/sheets/index.js +6 -3
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +8 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +145 -95
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +3 -9
- package/dist/routes/operator.js +24 -23
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +5 -0
- package/dist/storage/keys.js +9 -1
- package/dist/types.d.ts +11 -0
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +16 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +46 -22
- package/documentation/architecture.md +355 -50
- package/documentation/auth.md +775 -243
- package/documentation/code-mode.md +14 -11
- package/documentation/deploying.md +121 -24
- package/documentation/integrating.md +92 -21
- package/documentation/meta-tools.md +14 -9
- package/documentation/migration-0.33.md +344 -0
- package/documentation/operating.md +3 -1
- package/documentation/operator-tests.md +115 -28
- package/documentation/operator-ui.md +326 -61
- package/documentation/provider-auth-migration.md +37 -23
- package/documentation/provider-migration-0.29.md +2 -2
- package/examples/worker/AGENTS.md +30 -8
- package/examples/worker/README.md +92 -53
- package/examples/worker/src/connecta.config.ts +36 -20
- package/examples/worker/wrangler.jsonc +3 -0
- package/package.json +3 -2
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +27 -6
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +67 -42
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +28 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/templates/node/src/provision-token.ts +0 -17
package/dist/routes/mcp.js
CHANGED
|
@@ -8,13 +8,13 @@ import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
|
|
|
8
8
|
import { registerExecuteTool } from "../execute.js";
|
|
9
9
|
import { ExecutorAdmissionError } from "../executor-admission.js";
|
|
10
10
|
import { registerMetaTools } from "../meta-tools.js";
|
|
11
|
-
import { intersectAccess } from "../connector-access.js";
|
|
12
11
|
import { CONNECTA_INSTRUCTIONS } from "../skills.js";
|
|
13
12
|
import { registerSkills } from "./skills.js";
|
|
14
13
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
15
14
|
import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
|
|
16
15
|
import { detach } from "../runtime/run.js";
|
|
17
|
-
import {
|
|
16
|
+
import { agentEndpointPool, isAgentPath } from "./agent-path.js";
|
|
17
|
+
import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, } from "./shared.js";
|
|
18
18
|
/**
|
|
19
19
|
* The SDK's own bound on the body it reads. Since server 2.1.0 the SDK
|
|
20
20
|
* answers anything over 4 MiB with its own 413, which would quietly shadow
|
|
@@ -26,10 +26,22 @@ import { authorize, mayManageConnector, validateAuthPermissions, } from "./share
|
|
|
26
26
|
const SDK_BODY_BOUND = { maxRequestBodySize: Number.MAX_SAFE_INTEGER };
|
|
27
27
|
// Identity, not a status/header a custom auth response could imitate.
|
|
28
28
|
const deadlineResponses = new WeakSet();
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The most a `subscriptions/listen` classification reads. A listen is a small
|
|
31
|
+
* JSON-RPC request; this read happens before request admission, so it is
|
|
32
|
+
* bounded by this fixed cap rather than by the host's body limit (#857).
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_LISTEN_CLASSIFICATION_BYTES = 64 * 1024;
|
|
35
|
+
/**
|
|
36
|
+
* Whether an admitted, authenticated request is a modern `subscriptions/listen`.
|
|
37
|
+
* Reads at most `MAX_LISTEN_CLASSIFICATION_BYTES` of a clone, then stops and
|
|
38
|
+
* cancels the body: `"too_large"` past the cap, `"aborted"` when the request
|
|
39
|
+
* ended or its lifetime ran out while reading.
|
|
40
|
+
*/
|
|
41
|
+
async function classifyListen(request, signal) {
|
|
30
42
|
const reader = request.clone().body?.getReader();
|
|
31
43
|
if (!reader)
|
|
32
|
-
return
|
|
44
|
+
return "other";
|
|
33
45
|
const onAbort = () => {
|
|
34
46
|
void reader.cancel(signal.reason).catch(() => { });
|
|
35
47
|
void request.body?.cancel(signal.reason).catch(() => { });
|
|
@@ -46,12 +58,15 @@ async function isModernListen(request, signal) {
|
|
|
46
58
|
if (done)
|
|
47
59
|
break;
|
|
48
60
|
bytes += value.byteLength;
|
|
49
|
-
if (bytes >
|
|
50
|
-
|
|
61
|
+
if (bytes > MAX_LISTEN_CLASSIFICATION_BYTES) {
|
|
62
|
+
void reader.cancel().catch(() => { });
|
|
63
|
+
void request.body?.cancel().catch(() => { });
|
|
64
|
+
return "too_large";
|
|
65
|
+
}
|
|
51
66
|
text += decoder.decode(value, { stream: true });
|
|
52
67
|
}
|
|
53
68
|
if (signal.aborted)
|
|
54
|
-
return
|
|
69
|
+
return "aborted";
|
|
55
70
|
const protocolVersion = request.headers.get("MCP-Protocol-Version");
|
|
56
71
|
const method = request.headers.get("Mcp-Method");
|
|
57
72
|
const route = classifyInboundRequest({
|
|
@@ -60,11 +75,15 @@ async function isModernListen(request, signal) {
|
|
|
60
75
|
...(method !== null ? { mcpMethodHeader: method } : {}),
|
|
61
76
|
body: JSON.parse(text + decoder.decode()),
|
|
62
77
|
});
|
|
63
|
-
return
|
|
78
|
+
return route.kind === "modern" && route.messageKind === "request" && route.message.method === "subscriptions/listen"
|
|
79
|
+
? "listen"
|
|
80
|
+
: "other";
|
|
64
81
|
}
|
|
65
82
|
catch {
|
|
83
|
+
if (signal.aborted)
|
|
84
|
+
return "aborted";
|
|
66
85
|
// Leave unreadable, malformed, and legacy bodies to the normal SDK path.
|
|
67
|
-
return
|
|
86
|
+
return "other";
|
|
68
87
|
}
|
|
69
88
|
finally {
|
|
70
89
|
signal.removeEventListener("abort", onAbort);
|
|
@@ -456,6 +475,21 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
456
475
|
return new Response(text, { status: response.status, statusText: response.statusText, headers });
|
|
457
476
|
})));
|
|
458
477
|
}
|
|
478
|
+
function mcpEndpoint(path) {
|
|
479
|
+
if (isAgentPath(path)) {
|
|
480
|
+
const pool = agentEndpointPool(path);
|
|
481
|
+
return pool === undefined ? { kind: "refused" } : { kind: "agent", pool };
|
|
482
|
+
}
|
|
483
|
+
if (path === "/mcp")
|
|
484
|
+
return { kind: "default" };
|
|
485
|
+
if (path.startsWith("/mcp/"))
|
|
486
|
+
return { kind: "pool", pool: path.slice("/mcp/".length) };
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
/** Whether `path` is an MCP or agent endpoint path, valid or not. */
|
|
490
|
+
function isMcpPath(path) {
|
|
491
|
+
return mcpEndpoint(path) !== null;
|
|
492
|
+
}
|
|
459
493
|
export function createMcpRoute(opts) {
|
|
460
494
|
const configuredOrigins = opts.config.allowedOrigins;
|
|
461
495
|
const isExactOrigin = (value) => {
|
|
@@ -490,8 +524,7 @@ export function createMcpRoute(opts) {
|
|
|
490
524
|
return hostname === "localhost" || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
|
|
491
525
|
};
|
|
492
526
|
const rejectOrigin = (request) => {
|
|
493
|
-
|
|
494
|
-
if (path !== "/mcp" && !path.startsWith("/mcp/"))
|
|
527
|
+
if (!isMcpPath(new URL(request.url).pathname))
|
|
495
528
|
return null;
|
|
496
529
|
const origin = request.headers.get("Origin");
|
|
497
530
|
if (origin === null || allowsOrigin(origin))
|
|
@@ -516,25 +549,37 @@ export function createMcpRoute(opts) {
|
|
|
516
549
|
suppressedAdmissionWarnings = 0;
|
|
517
550
|
};
|
|
518
551
|
function routeMcp(context) {
|
|
519
|
-
const { path, request, baseUrl
|
|
552
|
+
const { path, request, baseUrl } = context;
|
|
520
553
|
// Protected-resource metadata stays public, including when a grant is
|
|
521
554
|
// configured, so a fresh OAuth client can discover how to authenticate.
|
|
522
|
-
|
|
555
|
+
const endpoint = mcpEndpoint(path);
|
|
556
|
+
if (!endpoint)
|
|
523
557
|
return Effect.succeed(null);
|
|
524
558
|
// Admission, refusal bodies, SDK serialization and connector work share
|
|
525
559
|
// one set. Connector contexts still receive an opaque scope, never the
|
|
526
560
|
// inbound Request or its headers.
|
|
527
561
|
const requestScope = {};
|
|
528
562
|
const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
|
|
529
|
-
const poolName =
|
|
563
|
+
const poolName = endpoint.kind === "pool" || endpoint.kind === "agent" ? endpoint.pool : undefined;
|
|
564
|
+
const agentEndpoint = endpoint.kind === "agent";
|
|
565
|
+
// Access attaches `ctx.access` even to a path it bypasses; an agent
|
|
566
|
+
// endpoint never reads it, so nothing past this point receives it.
|
|
567
|
+
const runtimeContext = agentEndpoint && context.runtimeContext
|
|
568
|
+
? { waitUntil: context.runtimeContext.waitUntil.bind(context.runtimeContext) }
|
|
569
|
+
: context.runtimeContext;
|
|
530
570
|
const origin = request.headers.get("Origin");
|
|
531
571
|
const allowed = origin === null || allowsOrigin(origin);
|
|
532
572
|
const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
|
|
573
|
+
const notFound = () => cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
533
574
|
// DNS-rebinding refusals cost neither a permit nor an auth lookup. This
|
|
534
575
|
// local header check also guards OPTIONS before any provider metadata.
|
|
535
576
|
const refusal = rejectOrigin(request);
|
|
536
577
|
if (refusal)
|
|
537
578
|
return Effect.succeed(refusal);
|
|
579
|
+
// Every other spelling in the agent namespace is the pool 404, before any
|
|
580
|
+
// admission or auth: no decoding, case folding, or slash collapsing.
|
|
581
|
+
if (endpoint.kind === "refused")
|
|
582
|
+
return Effect.succeed(notFound());
|
|
538
583
|
if (request.method === "OPTIONS") {
|
|
539
584
|
return Effect.succeed(cors(new Response(null, { status: 204 })));
|
|
540
585
|
}
|
|
@@ -555,38 +600,30 @@ export function createMcpRoute(opts) {
|
|
|
555
600
|
if (deadlineTimer !== undefined)
|
|
556
601
|
clearTimeout(deadlineTimer);
|
|
557
602
|
}));
|
|
558
|
-
//
|
|
559
|
-
//
|
|
603
|
+
// A request naming `subscriptions/listen` defers its permit: nothing of
|
|
604
|
+
// its body is read until auth and endpoint admission pass, and then at
|
|
605
|
+
// most MAX_LISTEN_CLASSIFICATION_BYTES (#857). Only a body-confirmed
|
|
606
|
+
// modern listen goes without a permit; trusting the header alone would
|
|
607
|
+
// let a legacy tools/call bypass the pool, so anything else takes one
|
|
608
|
+
// after classification. Every other request takes its permit here,
|
|
609
|
+
// before auth, and no body is read before auth on any path.
|
|
560
610
|
const startedAt = Date.now();
|
|
561
611
|
const maxDurationMs = opts.requestAdmission.maxDurationMs;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
const admission = listen
|
|
612
|
+
const listenHeader = request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen";
|
|
613
|
+
const refuseAdmission = (error) => {
|
|
614
|
+
if (!(error instanceof ExecutorAdmissionError))
|
|
615
|
+
return Effect.fail(error);
|
|
616
|
+
if (error.code === "executor_cancelled")
|
|
617
|
+
return Effect.fail(request.signal.reason ?? error);
|
|
618
|
+
if (error.code === "executor_overloaded")
|
|
619
|
+
warnAdmissionRejected(error);
|
|
620
|
+
return Effect.succeed(cors(requestAdmissionFailure(sentSecrets, error)));
|
|
621
|
+
};
|
|
622
|
+
const admission = listenHeader
|
|
575
623
|
? Result.succeed(undefined)
|
|
576
624
|
: yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
577
|
-
if (Result.isFailure(admission))
|
|
578
|
-
|
|
579
|
-
if (!(error instanceof ExecutorAdmissionError)) {
|
|
580
|
-
return yield* Effect.fail(error);
|
|
581
|
-
}
|
|
582
|
-
if (error.code === "executor_cancelled") {
|
|
583
|
-
return yield* Effect.fail(request.signal.reason ?? error);
|
|
584
|
-
}
|
|
585
|
-
if (error.code === "executor_overloaded") {
|
|
586
|
-
warnAdmissionRejected(error);
|
|
587
|
-
}
|
|
588
|
-
return cors(requestAdmissionFailure(sentSecrets, error));
|
|
589
|
-
}
|
|
625
|
+
if (Result.isFailure(admission))
|
|
626
|
+
return yield* refuseAdmission(admission.failure);
|
|
590
627
|
const remainingMs = admission.success
|
|
591
628
|
? admission.success.remainingMs()
|
|
592
629
|
: maxDurationMs === undefined
|
|
@@ -613,8 +650,34 @@ export function createMcpRoute(opts) {
|
|
|
613
650
|
// Effect `authorize` would put Effect in the /activity bundle, which
|
|
614
651
|
// shares it, to save only the lookups an abandoned authorization
|
|
615
652
|
// finishes on its own.
|
|
653
|
+
// Agent endpoints consult only the `cta_` adapter; human endpoints refuse `cta_`.
|
|
654
|
+
const admit = () => agentEndpoint
|
|
655
|
+
? authorizeAgent(localRequest, baseUrl, opts.config.auth)
|
|
656
|
+
: authorize(localRequest, baseUrl, opts.config.auth, runtimeContext);
|
|
657
|
+
// Plain /mcp admits maintainers and serves the whole catalog; a pool
|
|
658
|
+
// admits its members, people at /mcp/<pool> and agents at
|
|
659
|
+
// /agent/<pool>, and serves exactly its tools (INV-4).
|
|
660
|
+
const scopeFor = (admitted) => {
|
|
661
|
+
if (poolName === undefined) {
|
|
662
|
+
return admitsMaintainer(opts, admitted)
|
|
663
|
+
? { access: { connectorIds: "all" }, readOnly: opts.config.readOnly }
|
|
664
|
+
: undefined;
|
|
665
|
+
}
|
|
666
|
+
const pool = agentEndpoint
|
|
667
|
+
? agentPool(opts, poolName, admitted.identity)
|
|
668
|
+
: memberPool(opts, poolName, admitted.identity);
|
|
669
|
+
// The owner travels with the scope, so both pool endpoints set the
|
|
670
|
+
// same caller facts (#837 P5: agents act as the pool owner).
|
|
671
|
+
return pool
|
|
672
|
+
? {
|
|
673
|
+
access: pool.access,
|
|
674
|
+
readOnly: pool.readOnly,
|
|
675
|
+
...(pool.owner !== undefined ? { poolOwner: pool.owner } : {}),
|
|
676
|
+
}
|
|
677
|
+
: undefined;
|
|
678
|
+
};
|
|
616
679
|
const handled = Effect.gen(function* () {
|
|
617
|
-
const authz = yield* Effect.promise(
|
|
680
|
+
const authz = yield* Effect.promise(admit);
|
|
618
681
|
if (!authz.ok) {
|
|
619
682
|
const response = authz.response;
|
|
620
683
|
// Preserve the auth adapter's challenge and status. The MCP host must
|
|
@@ -641,79 +704,63 @@ export function createMcpRoute(opts) {
|
|
|
641
704
|
},
|
|
642
705
|
})), { status: response.status, headers }));
|
|
643
706
|
}
|
|
644
|
-
// A
|
|
645
|
-
//
|
|
646
|
-
// one identical 404 so a credential never enumerates the other pools;
|
|
707
|
+
// A non-maintainer, a non-member, and an undeclared pool are one
|
|
708
|
+
// identical 404 so a credential never enumerates the other pools;
|
|
647
709
|
// the operator log is where the reason lives.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
logFailure(opts.config.logger, "MCP default endpoint request denied", failureRecord({ reason: denialReason }));
|
|
663
|
-
return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
664
|
-
}
|
|
710
|
+
const scope = scopeFor(authz);
|
|
711
|
+
if (!scope) {
|
|
712
|
+
logFailure(opts.config.logger, poolName === undefined
|
|
713
|
+
? "MCP default endpoint request denied"
|
|
714
|
+
: agentEndpoint
|
|
715
|
+
? "MCP agent endpoint request denied"
|
|
716
|
+
: "MCP pool request denied", failureRecord({
|
|
717
|
+
reason: poolName === undefined
|
|
718
|
+
? "not_maintainer"
|
|
719
|
+
: opts.pools.has(poolName)
|
|
720
|
+
? "not_pool_member"
|
|
721
|
+
: "pool_not_declared",
|
|
722
|
+
}));
|
|
723
|
+
return notFound();
|
|
665
724
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
725
|
+
const { access, readOnly, poolOwner } = scope;
|
|
726
|
+
if (listenHeader) {
|
|
727
|
+
const verdict = yield* Effect.promise(() => classifyListen(localRequest, localAbort.signal));
|
|
728
|
+
// The deadline timer aborts a stalled read; its body is already cancelled.
|
|
729
|
+
if (verdict === "aborted") {
|
|
730
|
+
return cors(mcpRefusal(sentSecrets, 504, -33003, "MCP request lifetime exceeded."));
|
|
731
|
+
}
|
|
732
|
+
if (verdict === "too_large") {
|
|
733
|
+
return cors(mcpRefusal(sentSecrets, 413, -33006, "MCP request body too large."));
|
|
734
|
+
}
|
|
735
|
+
if (verdict === "other") {
|
|
736
|
+
const late = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
737
|
+
if (Result.isFailure(late))
|
|
738
|
+
return yield* refuseAdmission(late.failure);
|
|
681
739
|
}
|
|
682
|
-
access = intersectAccess(authz, pool.access);
|
|
683
|
-
readOnly = pool.readOnly;
|
|
684
740
|
}
|
|
685
741
|
let scopedRegistry;
|
|
686
742
|
try {
|
|
687
|
-
validateAuthPermissions(authz, opts.registry);
|
|
688
743
|
scopedRegistry = opts.registry.scoped({
|
|
689
744
|
connectorIds: access.connectorIds,
|
|
690
745
|
...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
|
|
691
|
-
...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
|
|
692
746
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
693
747
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
694
748
|
endpoint: new URL(request.url).pathname,
|
|
695
749
|
origin: request.headers.get("Origin"),
|
|
696
750
|
currentResultAccess: async (address, classification, signal, access) => {
|
|
697
751
|
signal?.throwIfAborted();
|
|
698
|
-
const current = await
|
|
752
|
+
const current = await admit();
|
|
699
753
|
if (!current.ok) {
|
|
700
754
|
await current.response.body?.cancel().catch(() => { });
|
|
701
755
|
return false;
|
|
702
756
|
}
|
|
703
757
|
if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
|
|
704
758
|
return false;
|
|
705
|
-
|
|
706
|
-
|
|
759
|
+
// Paging re-admits the caller at this endpoint, as a new request would.
|
|
760
|
+
const currentScope = scopeFor(current);
|
|
761
|
+
if (!currentScope)
|
|
707
762
|
return false;
|
|
708
|
-
|
|
709
|
-
let currentReadOnly = opts.config.readOnly;
|
|
710
|
-
if (poolName !== undefined) {
|
|
711
|
-
const pool = opts.pools.get(poolName);
|
|
712
|
-
if (!pool || (await pool.grant(current.identity)) !== true)
|
|
713
|
-
return false;
|
|
714
|
-
currentAccess = intersectAccess(current, pool.access);
|
|
715
|
-
currentReadOnly = pool.readOnly;
|
|
716
|
-
}
|
|
763
|
+
const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
|
|
717
764
|
const view = opts.registry.scoped({
|
|
718
765
|
...currentAccess,
|
|
719
766
|
...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
|
|
@@ -747,7 +794,10 @@ export function createMcpRoute(opts) {
|
|
|
747
794
|
// `authorize` admits an open deployment's every request as the
|
|
748
795
|
// anonymous actor; only a provider's `ok` is an authentication.
|
|
749
796
|
authenticated: opts.config.auth.length > 0,
|
|
797
|
+
// Both pool endpoints build the caller here from the same
|
|
798
|
+
// admitted scope, so an agent at /agent/<pool> carries the owner.
|
|
750
799
|
...(poolName !== undefined ? { pool: poolName } : {}),
|
|
800
|
+
...(poolOwner !== undefined ? { poolOwner } : {}),
|
|
751
801
|
},
|
|
752
802
|
});
|
|
753
803
|
}
|
|
@@ -759,7 +809,7 @@ export function createMcpRoute(opts) {
|
|
|
759
809
|
}
|
|
760
810
|
return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
|
|
761
811
|
const connector = scopedRegistry.getConnector(id);
|
|
762
|
-
return Boolean(connector && mayManageConnector(authz, connector));
|
|
812
|
+
return Boolean(connector && mayManageConnector(opts, authz, connector));
|
|
763
813
|
}, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
|
|
764
814
|
? {
|
|
765
815
|
kind: authz.actor.kind,
|
|
@@ -2,7 +2,7 @@ import { OAuthRevocationError } from "../auth/downstream-revocation.js";
|
|
|
2
2
|
import { Effect, Result } from "effect";
|
|
3
3
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
4
4
|
import { oauthConnectUrl, oauthConnectUnavailable } from "../oauth-handoff.js";
|
|
5
|
-
import { authorizedPerson, refuse, serveOperator,
|
|
5
|
+
import { authorizedPerson, refuse, serveOperator, managementRegistry, Answer } from "./operator.js";
|
|
6
6
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
7
7
|
import { mayManageConnector, isSameOrigin, privateJson } from "./shared.js";
|
|
8
8
|
/** A UI action signs its requested mode; the verified /connect visit applies it. */
|
|
@@ -20,14 +20,17 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
20
20
|
return yield* refuse("same-origin request required", 403);
|
|
21
21
|
}
|
|
22
22
|
const authz = yield* authorizedPerson(context, "OAuth management");
|
|
23
|
-
const registry = yield*
|
|
23
|
+
const registry = yield* managementRegistry(context, authz);
|
|
24
24
|
const connector = registry.getConnector(connectorId);
|
|
25
25
|
const disconnectAuth = connector?.disconnectAuth?.bind(connector);
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Each verb needs only its own hooks (#837): connecting needs the whole
|
|
27
|
+
// flow, through the callback's state check; disconnecting needs only
|
|
28
|
+
// disconnectAuth, so a grant provisioned out of band can still be dropped.
|
|
29
|
+
const connects = Boolean(connector?.startAuth && connector.finishAuth && connector.verifyState);
|
|
30
|
+
if (!connector || (!connects && !disconnectAuth)) {
|
|
28
31
|
return yield* refuse("unknown OAuth connector", 404);
|
|
29
32
|
}
|
|
30
|
-
if (!mayManageConnector(authz, connector)) {
|
|
33
|
+
if (!mayManageConnector(opts, authz, connector)) {
|
|
31
34
|
return yield* refuse("credential management is not permitted", 403);
|
|
32
35
|
}
|
|
33
36
|
if (connector.authScope === "personal" && !authz.principalKey) {
|
|
@@ -41,6 +44,9 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
41
44
|
if (mode === null) {
|
|
42
45
|
return yield* refuse('mode must be "continue" or "restart"', 400);
|
|
43
46
|
}
|
|
47
|
+
if (disconnecting ? !disconnectAuth : !connects) {
|
|
48
|
+
return yield* refuse("unknown OAuth connector", 404);
|
|
49
|
+
}
|
|
44
50
|
if (!disconnecting) {
|
|
45
51
|
const unavailable = oauthConnectUnavailable(opts);
|
|
46
52
|
if (unavailable)
|
package/dist/routes/oauth.js
CHANGED
|
@@ -7,7 +7,7 @@ import { failureRecord, logFailure } from "../operator-record.js";
|
|
|
7
7
|
import { oauthFlowKeys } from "../storage/keys.js";
|
|
8
8
|
import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
|
|
9
9
|
import { oauthCallbackOutcome, providerErrorReason } from "../oauth-callback-outcome.js";
|
|
10
|
-
import { authorizeUiIdentity, withSessionCookies, mayManageConnector,
|
|
10
|
+
import { authorizeUiIdentity, withSessionCookies, mayManageConnector, loggableValue, } from "./shared.js";
|
|
11
11
|
const TONE_MARKS = {
|
|
12
12
|
ok: { className: " ok", icon: STATUS_ICONS.ok, label: "Connected" },
|
|
13
13
|
declined: { className: "", icon: STATUS_ICONS.declined, label: "Not connected" },
|
|
@@ -167,7 +167,7 @@ async function finishOAuthCallback(context) {
|
|
|
167
167
|
}
|
|
168
168
|
try {
|
|
169
169
|
const expectedPrincipalKey = callbackTarget?.principalKey;
|
|
170
|
-
const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.config.auth, "OAuth callback", context.runtimeContext
|
|
170
|
+
const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.config.auth, "OAuth callback", context.runtimeContext);
|
|
171
171
|
if (!browserIdentity.ok) {
|
|
172
172
|
if (browserIdentity.response.status === 401 && authorizationCredential(context.request).kind !== "absent")
|
|
173
173
|
return browserIdentity.response;
|
|
@@ -179,13 +179,7 @@ async function finishOAuthCallback(context) {
|
|
|
179
179
|
}
|
|
180
180
|
if (!expectedPrincipalKey || browserIdentity.principalKey !== expectedPrincipalKey)
|
|
181
181
|
return refused();
|
|
182
|
-
|
|
183
|
-
validateAuthPermissions(browserIdentity, opts.registry);
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
return refused();
|
|
187
|
-
}
|
|
188
|
-
if (!mayManageConnector(browserIdentity, connector))
|
|
182
|
+
if (!mayManageConnector(opts, browserIdentity, connector))
|
|
189
183
|
return refused();
|
|
190
184
|
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
191
185
|
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
package/dist/routes/operator.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
// Imported by the /ui and /activity graphs only; never from the root entry.
|
|
23
23
|
import { Effect } from "effect";
|
|
24
24
|
import { runEdge } from "../runtime/run.js";
|
|
25
|
-
import {
|
|
25
|
+
import { isMaintainer } from "../membership.js";
|
|
26
|
+
import { admitsMaintainer, authorize, authorizeUiIdentity, memberConnectors, privateJson, } from "./shared.js";
|
|
26
27
|
/** A response the route has already decided on. */
|
|
27
28
|
export class Answer {
|
|
28
29
|
response;
|
|
@@ -40,42 +41,42 @@ function admitted(result) {
|
|
|
40
41
|
}
|
|
41
42
|
/** Any configured caller. The gate's refusal, challenge headers and all, is the answer. */
|
|
42
43
|
export function authorized({ request, baseUrl, opts, runtimeContext }, partitionIdentity = true) {
|
|
43
|
-
return Effect.suspend(() => admitted(authorize(request, baseUrl, opts.config.auth, runtimeContext,
|
|
44
|
+
return Effect.suspend(() => admitted(authorize(request, baseUrl, opts.config.auth, runtimeContext, partitionIdentity)));
|
|
44
45
|
}
|
|
45
46
|
/** A signed-in person, for a route that changes how a connector authenticates. */
|
|
46
47
|
export function authorizedPerson({ request, baseUrl, opts, runtimeContext }, purpose) {
|
|
47
|
-
return Effect.suspend(() => admitted(authorizeUiIdentity(request, baseUrl, opts.config.auth, purpose, runtimeContext
|
|
48
|
+
return Effect.suspend(() => admitted(authorizeUiIdentity(request, baseUrl, opts.config.auth, purpose, runtimeContext)));
|
|
48
49
|
}
|
|
49
|
-
/** The partition
|
|
50
|
-
export function scopeFor(authz, connectorIds =
|
|
50
|
+
/** The partition an identity carries into a registry view of the given connectors. */
|
|
51
|
+
export function scopeFor(authz, connectorIds = "all") {
|
|
51
52
|
return {
|
|
52
53
|
connectorIds,
|
|
53
|
-
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
54
|
-
...(authz.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
|
|
55
54
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
56
55
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
57
56
|
};
|
|
58
57
|
}
|
|
59
58
|
/**
|
|
60
|
-
* The registry
|
|
61
|
-
*
|
|
62
|
-
* included, rather than quietly granting what remains.
|
|
59
|
+
* The whole registry, for a maintainer only (#837). Everyone else gets the
|
|
60
|
+
* private 404 plain `/mcp` gives them.
|
|
63
61
|
*/
|
|
64
62
|
export function visibleRegistry({ opts }, authz) {
|
|
65
63
|
// Operator catalogs describe the default endpoint's view, never a pool view.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
if (!admitsMaintainer(opts, authz))
|
|
65
|
+
return refuse("not found", 404);
|
|
66
|
+
return Effect.succeed(opts.registry.scoped(scopeFor(authz)));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The connectors a connector-auth route may resolve (#837): every connector
|
|
70
|
+
* for a maintainer, the connectors of their pools for a pool member, and the
|
|
71
|
+
* private 404 for anyone else. mayManageConnector still decides each action.
|
|
72
|
+
*/
|
|
73
|
+
export function managementRegistry({ opts }, authz) {
|
|
74
|
+
if (isMaintainer(opts.maintainers, authz.identity))
|
|
75
|
+
return Effect.succeed(opts.registry.scoped(scopeFor(authz)));
|
|
76
|
+
const ids = memberConnectors(opts, authz.identity);
|
|
77
|
+
if (ids !== "all" && ids.size === 0)
|
|
78
|
+
return refuse("not found", 404);
|
|
79
|
+
return Effect.succeed(opts.registry.scoped(scopeFor(authz, ids === "all" ? "all" : [...ids])));
|
|
79
80
|
}
|
|
80
81
|
/**
|
|
81
82
|
* Run one route program to its Response.
|