@zackbart/connecta 0.31.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 +97 -0
- package/README.md +30 -20
- package/bin/connecta.mjs +25 -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/auth-elicitation.js +1 -1
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/catalog-service.js +7 -2
- package/dist/config-value-sources.js +14 -9
- package/dist/config.d.ts +58 -78
- package/dist/config.js +143 -47
- package/dist/connector-access.d.ts +4 -33
- 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/guarded-fetch.d.ts +9 -0
- package/dist/connectors/guarded-fetch.js +7 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +32 -7
- package/dist/describe-config.js +9 -18
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.d.ts +4 -3
- package/dist/index.js +30 -10
- package/dist/invocation.js +12 -7
- package/dist/membership.d.ts +51 -0
- package/dist/membership.js +121 -0
- package/dist/meta-tools.js +17 -19
- package/dist/module-contracts.d.ts +23 -6
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -2
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/contract.d.ts +151 -29
- 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 +15 -7
- package/dist/operator-ui/view.d.ts +84 -19
- package/dist/operator-ui/view.js +83 -21
- package/dist/page-styles.js +1 -1
- package/dist/program-writes.js +2 -2
- package/dist/provider.d.ts +63 -11
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +53 -16
- package/dist/providers/_shared/google/workspace.js +31 -9
- 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/breeze/skill.generated.js +2 -2
- 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 +2 -2
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.d.ts +1 -1
- package/dist/providers/docs/index.js +5 -5
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.d.ts +1 -1
- package/dist/providers/drive/index.js +4 -4
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.d.ts +1 -1
- package/dist/providers/forms/index.js +5 -5
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/github/skill.generated.js +2 -2
- package/dist/providers/gmail/index.d.ts +1 -1
- package/dist/providers/gmail/index.js +279 -26
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/linear/index.js +1 -1
- package/dist/providers/mixpanel/index.d.ts +27 -12
- package/dist/providers/mixpanel/index.js +58 -6
- package/dist/providers/mixpanel/operations.js +20 -0
- package/dist/providers/mixpanel/rest.js +603 -0
- package/dist/providers/mixpanel/skill.generated.js +3 -2
- package/dist/providers/mixpanel/value-safety.js +16 -0
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +116 -11
- package/dist/providers/notion/rest.js +183 -24
- package/dist/providers/notion/skill.generated.js +4 -4
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.d.ts +27 -0
- package/dist/providers/planning-center/index.js +287 -25
- package/dist/providers/planning-center/skill.generated.js +24 -4
- package/dist/providers/sheets/index.d.ts +4 -1
- package/dist/providers/sheets/index.js +9 -6
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.d.ts +1 -1
- package/dist/providers/slides/index.js +6 -6
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +2 -2
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/vercel/index.js +2 -2
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +2 -2
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/providers/wispr-flow/index.d.ts +14 -0
- package/dist/providers/wispr-flow/index.js +117 -0
- package/dist/providers/wispr-flow/skill.generated.js +8 -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 +155 -85
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +3 -9
- package/dist/routes/operator.js +25 -17
- 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 +8 -0
- package/dist/skills.js +5 -4
- package/dist/storage/keys.js +9 -1
- package/dist/tool-safety.js +13 -5
- package/dist/types.d.ts +12 -1
- package/dist/ui.d.ts +2 -2
- package/dist/ui.js +17 -15
- package/dist/usage-guide.js +3 -3
- 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 +378 -58
- package/documentation/auth.md +806 -215
- package/documentation/code-mode.md +93 -81
- package/documentation/deploying.md +128 -26
- package/documentation/integrating.md +145 -22
- package/documentation/meta-tools.md +58 -46
- package/documentation/migration-0.33.md +344 -0
- package/documentation/operating.md +14 -9
- package/documentation/operator-tests.md +117 -31
- package/documentation/operator-ui.md +326 -55
- package/documentation/provider-auth-migration.md +42 -27
- package/documentation/provider-migration-0.29.md +3 -3
- package/examples/worker/AGENTS.md +35 -8
- package/examples/worker/README.md +92 -53
- package/examples/worker/src/connecta.config.ts +41 -20
- package/examples/worker/wrangler.jsonc +3 -0
- package/package.json +7 -2
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +33 -10
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +71 -44
- 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 +33 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/dist/tool-safety.d.ts +0 -9
- package/templates/node/src/provision-token.ts +0 -17
|
@@ -2,45 +2,10 @@ import { failureRecord, logFailure } from "../operator-record.js";
|
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
|
|
4
4
|
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape } from "../credential-rules.js";
|
|
5
|
-
import { authorizedPerson, refuse, serveOperator,
|
|
6
|
-
import { mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
|
|
5
|
+
import { authorizedPerson, refuse, serveOperator, managementRegistry } from "./operator.js";
|
|
6
|
+
import { boundedText, mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
|
|
7
7
|
/** The longest body, in characters, a credential write accepts. */
|
|
8
8
|
const MAX_BODY_CHARS = 20_000;
|
|
9
|
-
/**
|
|
10
|
-
* The most UTF-8 a body within MAX_BODY_CHARS can take. No UTF-16 unit costs
|
|
11
|
-
* more than three bytes, and a malformed sequence decodes to one U+FFFD per
|
|
12
|
-
* at most three bytes, so a body past this many bytes is past the character
|
|
13
|
-
* limit too — and reading stops there, instead of buffering whatever a client
|
|
14
|
-
* cares to send before measuring it.
|
|
15
|
-
*/
|
|
16
|
-
const MAX_BODY_BYTES = MAX_BODY_CHARS * 3;
|
|
17
|
-
/** The body as text, or undefined once it is longer than MAX_BODY_CHARS. */
|
|
18
|
-
async function boundedText(request) {
|
|
19
|
-
if (!request.body)
|
|
20
|
-
return "";
|
|
21
|
-
const reader = request.body.getReader();
|
|
22
|
-
const chunks = [];
|
|
23
|
-
let size = 0;
|
|
24
|
-
for (;;) {
|
|
25
|
-
const { done, value } = await reader.read();
|
|
26
|
-
if (done)
|
|
27
|
-
break;
|
|
28
|
-
size += value.byteLength;
|
|
29
|
-
if (size > MAX_BODY_BYTES) {
|
|
30
|
-
reader.cancel().catch(() => { });
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
chunks.push(value);
|
|
34
|
-
}
|
|
35
|
-
const bytes = new Uint8Array(size);
|
|
36
|
-
let offset = 0;
|
|
37
|
-
for (const chunk of chunks) {
|
|
38
|
-
bytes.set(chunk, offset);
|
|
39
|
-
offset += chunk.byteLength;
|
|
40
|
-
}
|
|
41
|
-
const text = new TextDecoder().decode(bytes);
|
|
42
|
-
return text.length > MAX_BODY_CHARS ? undefined : text;
|
|
43
|
-
}
|
|
44
9
|
/** Field-by-field checks, in words the Credentials page shows as written. */
|
|
45
10
|
function credentialInput(body, config) {
|
|
46
11
|
if (!config.fields?.length) {
|
|
@@ -73,7 +38,7 @@ function readCredentialInput(request, config) {
|
|
|
73
38
|
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
74
39
|
return yield* refuse("Content-Type must be application/json", 415);
|
|
75
40
|
}
|
|
76
|
-
const raw = yield* Effect.promise(() => boundedText(request));
|
|
41
|
+
const raw = yield* Effect.promise(() => boundedText(request, MAX_BODY_CHARS));
|
|
77
42
|
if (raw === undefined)
|
|
78
43
|
return yield* refuse("request body is too large", 413);
|
|
79
44
|
let body;
|
|
@@ -99,12 +64,12 @@ function credentialRequest(context, connectorId, action) {
|
|
|
99
64
|
return yield* refuse("same-origin request required", 403);
|
|
100
65
|
}
|
|
101
66
|
const authz = yield* authorizedPerson(context, "credential management");
|
|
102
|
-
const registry = yield*
|
|
67
|
+
const registry = yield* managementRegistry(context, authz);
|
|
103
68
|
const connector = registry.getConnector(connectorId);
|
|
104
69
|
const credential = connector?.credential;
|
|
105
70
|
if (!connector || !credential)
|
|
106
71
|
return yield* refuse("unknown credential slot", 404);
|
|
107
|
-
if (!mayManageConnector(authz, connector)) {
|
|
72
|
+
if (!mayManageConnector(opts, authz, connector)) {
|
|
108
73
|
return yield* refuse("credential management is not permitted", 403);
|
|
109
74
|
}
|
|
110
75
|
const personal = connector.authScope === "personal";
|
|
@@ -191,6 +156,17 @@ function credentialRequest(context, connectorId, action) {
|
|
|
191
156
|
}).pipe(Effect.catch((error) => refuse(msg(error), 400)));
|
|
192
157
|
}
|
|
193
158
|
if (request.method === "DELETE") {
|
|
159
|
+
// Clearing nothing is refused, as testing nothing is, so the route
|
|
160
|
+
// agrees with the operator contract, which offers `clear` only once a
|
|
161
|
+
// value is stored in the caller's partition (#837). A value that cannot
|
|
162
|
+
// be read may still be cleared: removing it is how one recovers.
|
|
163
|
+
const stored = yield* Effect.tryPromise({
|
|
164
|
+
try: () => vault.metadata(connectorId, owner),
|
|
165
|
+
catch: (error) => error,
|
|
166
|
+
}).pipe(Effect.orElseSucceed(() => "unreadable"));
|
|
167
|
+
if (stored === null) {
|
|
168
|
+
return privateJson({ error: "no credential is stored to clear", problem: "credential_required" }, { status: 409 });
|
|
169
|
+
}
|
|
194
170
|
yield* Effect.promise(() => vault.delete(connectorId, owner));
|
|
195
171
|
yield* Effect.promise(() => registry.invalidateStored(connectorId));
|
|
196
172
|
return new Response(null, {
|
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);
|
|
@@ -294,7 +313,7 @@ function toolkitRetired(logger, sentSecrets) {
|
|
|
294
313
|
},
|
|
295
314
|
});
|
|
296
315
|
}
|
|
297
|
-
function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext,
|
|
316
|
+
function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor) {
|
|
298
317
|
const sentSecrets = sentSecretsForRequest(requestScope);
|
|
299
318
|
// Every McpServer the request builds is fresh and closes with its scope.
|
|
300
319
|
// The modern handler tears its own down after the exchange; the legacy
|
|
@@ -361,7 +380,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
361
380
|
client,
|
|
362
381
|
baseUrl,
|
|
363
382
|
requestScope,
|
|
364
|
-
|
|
383
|
+
readOnly,
|
|
365
384
|
canManageAuth,
|
|
366
385
|
oauthConnectUrl: (id, force) => oauthConnectUrl(opts, baseUrl, id, principalKey, force),
|
|
367
386
|
oauthConnectUnavailable: oauthConnectUnavailable(opts),
|
|
@@ -385,7 +404,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
385
404
|
requestSignal,
|
|
386
405
|
...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
|
|
387
406
|
...executeLimits(opts.config),
|
|
388
|
-
|
|
407
|
+
readOnly,
|
|
389
408
|
});
|
|
390
409
|
servers.push(server);
|
|
391
410
|
return server;
|
|
@@ -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,23 +549,37 @@ export function createMcpRoute(opts) {
|
|
|
516
549
|
suppressedAdmissionWarnings = 0;
|
|
517
550
|
};
|
|
518
551
|
function routeMcp(context) {
|
|
519
|
-
const { path, request, baseUrl
|
|
520
|
-
|
|
552
|
+
const { path, request, baseUrl } = context;
|
|
553
|
+
// Protected-resource metadata stays public, including when a grant is
|
|
554
|
+
// configured, so a fresh OAuth client can discover how to authenticate.
|
|
555
|
+
const endpoint = mcpEndpoint(path);
|
|
556
|
+
if (!endpoint)
|
|
521
557
|
return Effect.succeed(null);
|
|
522
558
|
// Admission, refusal bodies, SDK serialization and connector work share
|
|
523
559
|
// one set. Connector contexts still receive an opaque scope, never the
|
|
524
560
|
// inbound Request or its headers.
|
|
525
561
|
const requestScope = {};
|
|
526
562
|
const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
|
|
527
|
-
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;
|
|
528
570
|
const origin = request.headers.get("Origin");
|
|
529
571
|
const allowed = origin === null || allowsOrigin(origin);
|
|
530
572
|
const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
|
|
573
|
+
const notFound = () => cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
531
574
|
// DNS-rebinding refusals cost neither a permit nor an auth lookup. This
|
|
532
575
|
// local header check also guards OPTIONS before any provider metadata.
|
|
533
576
|
const refusal = rejectOrigin(request);
|
|
534
577
|
if (refusal)
|
|
535
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());
|
|
536
583
|
if (request.method === "OPTIONS") {
|
|
537
584
|
return Effect.succeed(cors(new Response(null, { status: 204 })));
|
|
538
585
|
}
|
|
@@ -553,38 +600,30 @@ export function createMcpRoute(opts) {
|
|
|
553
600
|
if (deadlineTimer !== undefined)
|
|
554
601
|
clearTimeout(deadlineTimer);
|
|
555
602
|
}));
|
|
556
|
-
//
|
|
557
|
-
//
|
|
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.
|
|
558
610
|
const startedAt = Date.now();
|
|
559
611
|
const maxDurationMs = opts.requestAdmission.maxDurationMs;
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
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
|
|
573
623
|
? Result.succeed(undefined)
|
|
574
624
|
: yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
575
|
-
if (Result.isFailure(admission))
|
|
576
|
-
|
|
577
|
-
if (!(error instanceof ExecutorAdmissionError)) {
|
|
578
|
-
return yield* Effect.fail(error);
|
|
579
|
-
}
|
|
580
|
-
if (error.code === "executor_cancelled") {
|
|
581
|
-
return yield* Effect.fail(request.signal.reason ?? error);
|
|
582
|
-
}
|
|
583
|
-
if (error.code === "executor_overloaded") {
|
|
584
|
-
warnAdmissionRejected(error);
|
|
585
|
-
}
|
|
586
|
-
return cors(requestAdmissionFailure(sentSecrets, error));
|
|
587
|
-
}
|
|
625
|
+
if (Result.isFailure(admission))
|
|
626
|
+
return yield* refuseAdmission(admission.failure);
|
|
588
627
|
const remainingMs = admission.success
|
|
589
628
|
? admission.success.remainingMs()
|
|
590
629
|
: maxDurationMs === undefined
|
|
@@ -611,8 +650,34 @@ export function createMcpRoute(opts) {
|
|
|
611
650
|
// Effect `authorize` would put Effect in the /activity bundle, which
|
|
612
651
|
// shares it, to save only the lookups an abandoned authorization
|
|
613
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
|
+
};
|
|
614
679
|
const handled = Effect.gen(function* () {
|
|
615
|
-
const authz = yield* Effect.promise(
|
|
680
|
+
const authz = yield* Effect.promise(admit);
|
|
616
681
|
if (!authz.ok) {
|
|
617
682
|
const response = authz.response;
|
|
618
683
|
// Preserve the auth adapter's challenge and status. The MCP host must
|
|
@@ -639,67 +704,69 @@ export function createMcpRoute(opts) {
|
|
|
639
704
|
},
|
|
640
705
|
})), { status: response.status, headers }));
|
|
641
706
|
}
|
|
642
|
-
// A
|
|
643
|
-
//
|
|
644
|
-
// 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;
|
|
645
709
|
// the operator log is where the reason lives.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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();
|
|
724
|
+
}
|
|
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);
|
|
663
739
|
}
|
|
664
|
-
access = intersectAccess(authz, pool.access);
|
|
665
|
-
trust = pool.trust;
|
|
666
740
|
}
|
|
667
741
|
let scopedRegistry;
|
|
668
742
|
try {
|
|
669
|
-
validateAuthPermissions(authz, opts.registry);
|
|
670
743
|
scopedRegistry = opts.registry.scoped({
|
|
671
744
|
connectorIds: access.connectorIds,
|
|
672
745
|
...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
|
|
673
|
-
...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
|
|
674
746
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
675
747
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
676
748
|
endpoint: new URL(request.url).pathname,
|
|
677
749
|
origin: request.headers.get("Origin"),
|
|
678
750
|
currentResultAccess: async (address, classification, signal, access) => {
|
|
679
751
|
signal?.throwIfAborted();
|
|
680
|
-
const current = await
|
|
752
|
+
const current = await admit();
|
|
681
753
|
if (!current.ok) {
|
|
682
754
|
await current.response.body?.cancel().catch(() => { });
|
|
683
755
|
return false;
|
|
684
756
|
}
|
|
685
757
|
if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
|
|
686
758
|
return false;
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (!pool || (await pool.grant(current.identity)) !== true)
|
|
693
|
-
return false;
|
|
694
|
-
currentAccess = intersectAccess(current, pool.access);
|
|
695
|
-
currentTrust = pool.trust;
|
|
696
|
-
}
|
|
759
|
+
// Paging re-admits the caller at this endpoint, as a new request would.
|
|
760
|
+
const currentScope = scopeFor(current);
|
|
761
|
+
if (!currentScope)
|
|
762
|
+
return false;
|
|
763
|
+
const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
|
|
697
764
|
const view = opts.registry.scoped({
|
|
698
765
|
...currentAccess,
|
|
699
766
|
...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
|
|
700
767
|
...(current.principalKey ? { principalKey: current.principalKey } : {}),
|
|
701
768
|
});
|
|
702
|
-
if (classification === "write" &&
|
|
769
|
+
if (classification === "write" && currentReadOnly)
|
|
703
770
|
return false;
|
|
704
771
|
if (address === undefined)
|
|
705
772
|
return true;
|
|
@@ -716,7 +783,7 @@ export function createMcpRoute(opts) {
|
|
|
716
783
|
return Boolean(tool &&
|
|
717
784
|
(access === "catalog" ||
|
|
718
785
|
(classification === "read" && tool.classification === "read") ||
|
|
719
|
-
|
|
786
|
+
!currentReadOnly));
|
|
720
787
|
}
|
|
721
788
|
finally {
|
|
722
789
|
await closeConnectorScope(resolved.connector, view.contextFor(resolved.connector.id, baseUrl, requestScope), runtimeContext?.waitUntil?.bind(runtimeContext));
|
|
@@ -727,7 +794,10 @@ export function createMcpRoute(opts) {
|
|
|
727
794
|
// `authorize` admits an open deployment's every request as the
|
|
728
795
|
// anonymous actor; only a provider's `ok` is an authentication.
|
|
729
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.
|
|
730
799
|
...(poolName !== undefined ? { pool: poolName } : {}),
|
|
800
|
+
...(poolOwner !== undefined ? { poolOwner } : {}),
|
|
731
801
|
},
|
|
732
802
|
});
|
|
733
803
|
}
|
|
@@ -739,8 +809,8 @@ export function createMcpRoute(opts) {
|
|
|
739
809
|
}
|
|
740
810
|
return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
|
|
741
811
|
const connector = scopedRegistry.getConnector(id);
|
|
742
|
-
return Boolean(connector && mayManageConnector(authz, connector));
|
|
743
|
-
}, authz.principalKey, runtimeContext,
|
|
812
|
+
return Boolean(connector && mayManageConnector(opts, authz, connector));
|
|
813
|
+
}, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
|
|
744
814
|
? {
|
|
745
815
|
kind: authz.actor.kind,
|
|
746
816
|
id: authz.identity.principal.id,
|
|
@@ -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,35 +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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
// Operator catalogs describe the default endpoint's view, never a pool view.
|
|
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])));
|
|
72
80
|
}
|
|
73
81
|
/**
|
|
74
82
|
* Run one route program to its Response.
|