@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
package/dist/routes/ui-config.js
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
|
+
// `GET /ui/api/config`: operator contract v2 (#837, decisions/0006), the
|
|
2
|
+
// operator page's only gate. A maintainer gets the unfiltered view; a human
|
|
3
|
+
// pool member gets exactly their own projection; anyone else gets the private
|
|
4
|
+
// 404 every operator route gives them, and a signed-out caller the auth
|
|
5
|
+
// provider's challenge.
|
|
1
6
|
import { activityEventVisible } from "./activity-disclosure.js";
|
|
2
7
|
import { operatorActivityContext } from "./activity-context.js";
|
|
8
|
+
import { actorKey, principalActor, resolveActorLabels } from "./actor-labels.js";
|
|
3
9
|
import { bindActivityRequest } from "../activity-request.js";
|
|
10
|
+
import { AGENT_NAMESPACE } from "../agent-identity.js";
|
|
4
11
|
import { configValuePaths } from "../config-value-sources.js";
|
|
5
12
|
import { Effect } from "effect";
|
|
6
|
-
import {
|
|
13
|
+
import { credentialTestRule, storedCredentialShape } from "../credential-rules.js";
|
|
7
14
|
import { describedTools } from "../described.js";
|
|
8
15
|
import { resolveDiscoveryConcurrency } from "../concurrency.js";
|
|
9
|
-
import {
|
|
16
|
+
import { identityStorageKey } from "../identity.js";
|
|
17
|
+
import { oauthConnectUnavailable } from "../oauth-handoff.js";
|
|
18
|
+
import { failureRecord, logFailure, recordedToolName } from "../operator-record.js";
|
|
10
19
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
11
20
|
import { withDeadlineEffect } from "../runtime/run.js";
|
|
12
21
|
import { uiProblemFor } from "../ui.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
22
|
+
import { workspaceSubjectOf } from "../workspace-subject.js";
|
|
23
|
+
import { authorized, refuse, scopeFor, serveOperator } from "./operator.js";
|
|
24
|
+
import { isMaintainer, isMember } from "../membership.js";
|
|
25
|
+
import { admitsMaintainer, mayManageConnector, privateJson, readsActivity } from "./shared.js";
|
|
15
26
|
const attempt = (operation) => Effect.tryPromise({ try: operation, catch: (error) => error });
|
|
16
27
|
const validName = (name) => recordedToolName({ name }) === name;
|
|
28
|
+
const classified = (tool) => tool.classification === "read" ? "read" : "write";
|
|
17
29
|
function grants(access, ids) {
|
|
18
30
|
return ids
|
|
19
31
|
.filter((id) => access.connectorIds === "all" || access.connectorIds.includes(id))
|
|
@@ -21,27 +33,56 @@ function grants(access, ids) {
|
|
|
21
33
|
const tools = access.toolAccess?.get(connectorId);
|
|
22
34
|
return {
|
|
23
35
|
connectorId,
|
|
24
|
-
tools: tools
|
|
25
|
-
? [...tools].filter(validName).map((name) => ({
|
|
26
|
-
name,
|
|
27
|
-
requireReadOnly: access.guardedToolAccess?.get(connectorId)?.has(name) === true,
|
|
28
|
-
}))
|
|
29
|
-
: "all",
|
|
36
|
+
tools: tools ? [...tools].filter(validName).map((name) => ({ name, requireReadOnly: false })) : "all",
|
|
30
37
|
};
|
|
31
38
|
});
|
|
32
39
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
/** Whether a pool serves this connector at all. */
|
|
41
|
+
const servesConnector = (pool, id) => pool.access.connectorIds === "all" || pool.access.connectorIds.includes(id);
|
|
42
|
+
/** The humans a pool lists, as references; agents are never people. */
|
|
43
|
+
const humansOf = (pool) => pool.members.flatMap((member) => (member.kind === "human" ? [{ namespace: member.namespace, id: member.id }] : []));
|
|
44
|
+
const referenceKey = (reference) => JSON.stringify([reference.namespace, reference.id]);
|
|
45
|
+
/**
|
|
46
|
+
* One bounded probe of a connector, closed however it ends: the work runs
|
|
47
|
+
* under the deployment's probe deadline and the request's signal, a failure
|
|
48
|
+
* or defect becomes `fallback`, and the connector scope it opened is closed.
|
|
49
|
+
*/
|
|
50
|
+
function probe(context, registry, connector, scope, work, fallback) {
|
|
39
51
|
const { opts, baseUrl, request, defer } = context;
|
|
52
|
+
return withDeadlineEffect(work, {
|
|
53
|
+
timeoutMs: opts.config.discovery.probeTimeoutMs,
|
|
54
|
+
signal: request.signal,
|
|
55
|
+
timeoutError: new Error("Operator config observation timed out."),
|
|
56
|
+
}).pipe(Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch(() => Effect.sync(fallback)), Effect.ensuring(Effect.suspend(() => closeScope(connector, registry.contextFor(connector.id, baseUrl, scope), defer))));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One read the config build makes outside a connector probe — the vault, the
|
|
60
|
+
* token slots, activity — under the same deadline and request signal. A
|
|
61
|
+
* stalled store must never hang the page, so a read that fails, stalls or
|
|
62
|
+
* loses its caller becomes `fallback`, which callers keep fail-closed.
|
|
63
|
+
*/
|
|
64
|
+
function bounded(context, read, fallback) {
|
|
65
|
+
return withDeadlineEffect(() => read(), {
|
|
66
|
+
timeoutMs: context.opts.config.discovery.probeTimeoutMs,
|
|
67
|
+
signal: context.request.signal,
|
|
68
|
+
timeoutError: new Error("Operator config read timed out."),
|
|
69
|
+
}).pipe(Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch(fallback));
|
|
70
|
+
}
|
|
71
|
+
function requestScope(activity) {
|
|
40
72
|
const scope = {};
|
|
41
73
|
if (activity)
|
|
42
74
|
bindActivityRequest(scope, activity);
|
|
75
|
+
return scope;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Status first, then the catalog only when status is ok, as the operator
|
|
79
|
+
* page has always read a connector. `catalogFailed` tells a failed listing
|
|
80
|
+
* apart from an empty one.
|
|
81
|
+
*/
|
|
82
|
+
function observed(context, registry, connector, scope, signal) {
|
|
83
|
+
const { baseUrl, defer } = context;
|
|
43
84
|
const id = connector.id;
|
|
44
|
-
return
|
|
85
|
+
return Effect.gen(function* () {
|
|
45
86
|
const drift = yield* attempt(() => registry.credentialDriftFor(id));
|
|
46
87
|
const status = drift
|
|
47
88
|
? { state: "auth_required" }
|
|
@@ -53,38 +94,36 @@ function observe(context, registry, connector, activity) {
|
|
|
53
94
|
return Effect.succeed([]);
|
|
54
95
|
}))
|
|
55
96
|
: [];
|
|
56
|
-
|
|
97
|
+
return { status, drift: Boolean(drift), tools, catalogFailed };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function observe(context, registry, connector, activity) {
|
|
101
|
+
const scope = requestScope(activity);
|
|
102
|
+
const id = connector.id;
|
|
103
|
+
return probe(context, registry, connector, scope, (signal) => Effect.map(observed(context, registry, connector, scope, signal), ({ status, drift, tools, catalogFailed }) => {
|
|
104
|
+
const problem = uiProblemFor(connector, status.state, { credentialDrift: drift, catalogFailed });
|
|
57
105
|
return {
|
|
58
106
|
id,
|
|
59
107
|
status: status.state,
|
|
60
108
|
...(status.registrationPath ? { auth: { registrationPath: status.registrationPath } } : {}),
|
|
61
109
|
...(problem ? { problem } : {}),
|
|
62
|
-
// Catalog metadata is allowed for authenticated
|
|
63
|
-
// and its derived address are withheld when they fail the record grammar.
|
|
110
|
+
// Catalog metadata is allowed for authenticated maintainers. Only the
|
|
111
|
+
// name and its derived address are withheld when they fail the record grammar.
|
|
64
112
|
tools: describedTools(tools).map((tool) => {
|
|
65
113
|
const name = recordedToolName(tool);
|
|
66
|
-
return {
|
|
67
|
-
...tool,
|
|
68
|
-
name,
|
|
69
|
-
address: `${id}.${name}`,
|
|
70
|
-
classification: tool.classification === "read" ? "read" : "write",
|
|
71
|
-
};
|
|
114
|
+
return { ...tool, name, address: `${id}.${name}`, classification: classified(tool) };
|
|
72
115
|
}),
|
|
73
116
|
catalogAgeMs: registry.catalogAgeMs(id),
|
|
74
117
|
lastCall: null,
|
|
75
118
|
};
|
|
76
|
-
}), {
|
|
77
|
-
timeoutMs: opts.config.discovery.probeTimeoutMs,
|
|
78
|
-
signal: request.signal,
|
|
79
|
-
timeoutError: new Error("Operator config observation timed out."),
|
|
80
|
-
}).pipe(Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch(() => Effect.succeed({
|
|
119
|
+
}), () => ({
|
|
81
120
|
id,
|
|
82
121
|
status: "error",
|
|
83
122
|
problem: "connector_unavailable",
|
|
84
123
|
tools: [],
|
|
85
124
|
catalogAgeMs: registry.catalogAgeMs(id),
|
|
86
125
|
lastCall: null,
|
|
87
|
-
}))
|
|
126
|
+
}));
|
|
88
127
|
}
|
|
89
128
|
const OUTCOMES = new Set([
|
|
90
129
|
"success",
|
|
@@ -95,20 +134,26 @@ const OUTCOMES = new Set([
|
|
|
95
134
|
"approved",
|
|
96
135
|
]);
|
|
97
136
|
const isTimestamp = (value) => /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value));
|
|
98
|
-
|
|
137
|
+
/**
|
|
138
|
+
* One bounded scan of recent activity fills each row's last call and each
|
|
139
|
+
* declared agent's `lastSeenAt` from the rows this maintainer may see. Agent
|
|
140
|
+
* rows are recorded under the access-token directory with the agent's name.
|
|
141
|
+
*/
|
|
142
|
+
function scanActivity(context, authz, registry, rows, agents) {
|
|
99
143
|
const activity = context.opts.config.activity;
|
|
100
144
|
if (!activity?.store.list)
|
|
101
145
|
return Effect.succeed("unconfigured");
|
|
102
|
-
if (!authz
|
|
146
|
+
if (!readsActivity(context.opts, authz))
|
|
103
147
|
return Effect.succeed("forbidden");
|
|
104
148
|
return Effect.gen(function* () {
|
|
105
149
|
const permitted = activity.readGate
|
|
106
|
-
? yield* attempt(async () => (await activity.readGate(authz.actor)) === true)
|
|
150
|
+
? yield* bounded(context, () => attempt(async () => (await activity.readGate(authz.actor)) === true), () => Effect.succeed(false))
|
|
107
151
|
: true;
|
|
108
152
|
if (!permitted)
|
|
109
153
|
return "forbidden";
|
|
110
|
-
|
|
154
|
+
const scan = Effect.gen(function* () {
|
|
111
155
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
156
|
+
const byAgent = new Map(agents.map((agent) => [agent.name, agent]));
|
|
112
157
|
let cursor;
|
|
113
158
|
const seen = new Set();
|
|
114
159
|
// Bound the read even when a custom reader repeats a cursor or ignores
|
|
@@ -117,15 +162,20 @@ function lastCalls(context, authz, registry, rows, admittedPools) {
|
|
|
117
162
|
for (let pageIndex = 0; pageIndex < 10; pageIndex++) {
|
|
118
163
|
const page = yield* attempt(() => activity.store.list({ limit: 100, ...(cursor ? { cursor } : {}) }));
|
|
119
164
|
for (const event of page.events.slice(0, 100)) {
|
|
120
|
-
if (event.
|
|
165
|
+
if (!isTimestamp(event.occurredAt))
|
|
121
166
|
continue;
|
|
122
|
-
|
|
123
|
-
|
|
167
|
+
// A last call is a call; any row recorded for an agent, a catalog
|
|
168
|
+
// refresh included, is a sign it was seen.
|
|
169
|
+
const row = event.kind !== "catalog_drift" && OUTCOMES.has(event.outcome) ? byId.get(event.connectorId) : undefined;
|
|
170
|
+
const agent = event.actor.kind === "access_token" && event.actor.namespace === AGENT_NAMESPACE && event.actor.id
|
|
171
|
+
? byAgent.get(event.actor.id)
|
|
172
|
+
: undefined;
|
|
173
|
+
if ((!row && !agent) || !activityEventVisible(context, authz, registry, event))
|
|
124
174
|
continue;
|
|
125
|
-
if (!
|
|
126
|
-
continue;
|
|
127
|
-
if (!row.lastCall || event.occurredAt > row.lastCall.at)
|
|
175
|
+
if (row && (!row.lastCall || event.occurredAt > row.lastCall.at))
|
|
128
176
|
row.lastCall = { at: event.occurredAt, outcome: event.outcome };
|
|
177
|
+
if (agent && (!agent.lastSeenAt || event.occurredAt > agent.lastSeenAt))
|
|
178
|
+
agent.lastSeenAt = event.occurredAt;
|
|
129
179
|
}
|
|
130
180
|
cursor = page.nextCursor;
|
|
131
181
|
if (!cursor || seen.has(cursor))
|
|
@@ -133,89 +183,379 @@ function lastCalls(context, authz, registry, rows, admittedPools) {
|
|
|
133
183
|
seen.add(cursor);
|
|
134
184
|
}
|
|
135
185
|
return "available";
|
|
136
|
-
})
|
|
186
|
+
});
|
|
187
|
+
return yield* bounded(context, () => scan, () => Effect.sync(() => {
|
|
137
188
|
for (const row of rows)
|
|
138
189
|
row.lastCall = null;
|
|
190
|
+
for (const agent of agents)
|
|
191
|
+
agent.lastSeenAt = null;
|
|
139
192
|
return "unavailable";
|
|
140
193
|
}));
|
|
141
194
|
});
|
|
142
195
|
}
|
|
143
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Directory labels for listed people, from the same lookup activity actor
|
|
198
|
+
* labels use, and only on an interactive read, as there. A deployment without
|
|
199
|
+
* a directory (Cloudflare Access) labels nobody.
|
|
200
|
+
*/
|
|
201
|
+
function humanLabels(context, authz, people) {
|
|
202
|
+
const auth = authz.identity.interactive ? context.opts.config.auth : [];
|
|
203
|
+
const actors = people.flatMap((person) => principalActor(person, auth) ?? []);
|
|
204
|
+
return Effect.map(resolveActorLabels(actors, auth), (labels) => (person) => {
|
|
205
|
+
const actor = principalActor(person, auth);
|
|
206
|
+
return (actor && labels.get(actorKey(actor))) ?? null;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const MISSING = { kind: "shared", source: "missing", savedAt: null };
|
|
210
|
+
/**
|
|
211
|
+
* Where a shared connector's credential comes from (`OperatorSharedSource`).
|
|
212
|
+
* A declared slot is "vault" or "missing"; shared OAuth is "oauth" unless the
|
|
213
|
+
* connector reports it needs authorizing; a remote MCP server that declares
|
|
214
|
+
* no auth is "unauthenticated"; everything else lives in deployment code.
|
|
215
|
+
*/
|
|
216
|
+
function sharedSource(context, connector, row) {
|
|
217
|
+
const { opts } = context;
|
|
218
|
+
if (connector.credential) {
|
|
219
|
+
const vault = opts.config.vault;
|
|
220
|
+
if (!vault)
|
|
221
|
+
return Effect.succeed(MISSING);
|
|
222
|
+
return bounded(context, () => attempt(() => vault.metadata(connector.id)).pipe(Effect.map((metadata) => metadata ? { kind: "shared", source: "vault", savedAt: metadata.updatedAt } : MISSING)),
|
|
223
|
+
// An unreadable or stalled vault claims nothing was saved; the overlay says why.
|
|
224
|
+
() => Effect.succeed(MISSING));
|
|
225
|
+
}
|
|
226
|
+
const described = opts.configDescription.connectors.find((c) => c.id === connector.id);
|
|
227
|
+
if (connector.startAuth || described?.auth?.mode === "oauth") {
|
|
228
|
+
// Only a connector asking to be authorized has no grant; any other
|
|
229
|
+
// state, a credential prompt or an error included, has one. A grant
|
|
230
|
+
// carries no saved-at time of its own.
|
|
231
|
+
return Effect.succeed(row?.status === "auth_required" ? MISSING : { kind: "shared", source: "oauth", savedAt: null });
|
|
232
|
+
}
|
|
233
|
+
if (described?.auth?.mode === "none" && described.source.kind === "remote-mcp") {
|
|
234
|
+
return Effect.succeed({ kind: "shared", source: "unauthenticated", savedAt: null });
|
|
235
|
+
}
|
|
236
|
+
return Effect.succeed({ kind: "shared", source: "config", savedAt: null });
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The people among `people` who have connected this personal connector in
|
|
240
|
+
* their own partition: a stored credential of the declared shape, or a status
|
|
241
|
+
* of "ok". Counted, never listed.
|
|
242
|
+
*/
|
|
243
|
+
function connectedPeople(context, connector, people) {
|
|
244
|
+
const { opts, baseUrl, defer } = context;
|
|
245
|
+
const connected = new Set();
|
|
246
|
+
return Effect.forEach(people, (person) => Effect.gen(function* () {
|
|
247
|
+
const principalKey = yield* Effect.promise(() => identityStorageKey(person));
|
|
248
|
+
const credential = connector.credential;
|
|
249
|
+
let ok;
|
|
250
|
+
if (credential) {
|
|
251
|
+
const vault = opts.config.vault;
|
|
252
|
+
// An unreadable or stalled partition is not counted as connected.
|
|
253
|
+
ok = vault
|
|
254
|
+
? yield* bounded(context, () => attempt(() => vault.metadata(connector.id, principalKey)).pipe(Effect.map((metadata) => storedCredentialShape(credential, metadata?.fields ?? null).state === "valid")), () => Effect.succeed(false))
|
|
255
|
+
: false;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const registry = opts.registry.scoped({ connectorIds: [connector.id], principalKey });
|
|
259
|
+
const scope = {};
|
|
260
|
+
ok = yield* probe(context, registry, connector, scope, (signal) => Effect.map(attempt(() => registry.statusFor(connector.id, baseUrl, scope, { signal, ...(defer ? { defer } : {}) })), (status) => status.state === "ok"), () => false);
|
|
261
|
+
}
|
|
262
|
+
if (ok)
|
|
263
|
+
connected.add(referenceKey(person));
|
|
264
|
+
}), { concurrency: resolveDiscoveryConcurrency(opts.config.discovery.concurrency), discard: true }).pipe(Effect.as(connected));
|
|
265
|
+
}
|
|
266
|
+
/** Every connector × endpoint cell: plain /mcp (`pool: null`) and each pool that serves it. */
|
|
267
|
+
function matrixFor(context, visible, rows, ownersVisible) {
|
|
268
|
+
const { opts } = context;
|
|
269
|
+
const pools = [...opts.pools];
|
|
270
|
+
return Effect.forEach(visible, (connector) => Effect.gen(function* () {
|
|
271
|
+
const columns = [
|
|
272
|
+
{ pool: null },
|
|
273
|
+
...pools
|
|
274
|
+
.filter(([, pool]) => servesConnector(pool, connector.id))
|
|
275
|
+
.map(([pool, resolved]) => ({ pool, resolved })),
|
|
276
|
+
];
|
|
277
|
+
const humans = (resolved) => (resolved ? humansOf(resolved) : [...opts.maintainers]);
|
|
278
|
+
const workspace = workspaceSubjectOf(connector);
|
|
279
|
+
let runsAs;
|
|
280
|
+
if (workspace?.kind === "fixed") {
|
|
281
|
+
runsAs = () => ({ kind: "workspaceFixed", subject: workspace.subject });
|
|
282
|
+
}
|
|
283
|
+
else if (workspace) {
|
|
284
|
+
runsAs = (resolved) => ({
|
|
285
|
+
kind: "workspace",
|
|
286
|
+
humans: "self",
|
|
287
|
+
// Plain /mcp admits no agents; a pool's agents act as its owner.
|
|
288
|
+
agents: resolved && ownersVisible ? (resolved.owner ?? null) : null,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
else if (connector.authScope === "personal") {
|
|
292
|
+
const everyone = new Map();
|
|
293
|
+
for (const column of columns)
|
|
294
|
+
for (const person of humans(column.resolved))
|
|
295
|
+
everyone.set(referenceKey(person), person);
|
|
296
|
+
const connected = yield* connectedPeople(context, connector, [...everyone.values()]);
|
|
297
|
+
runsAs = (_, humansHere) => ({
|
|
298
|
+
kind: "personal",
|
|
299
|
+
connectedMembers: humansHere.filter((person) => connected.has(referenceKey(person))).length,
|
|
300
|
+
members: humansHere.length,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const shared = yield* sharedSource(context, connector, rows.find((row) => row.id === connector.id));
|
|
305
|
+
runsAs = () => shared;
|
|
306
|
+
}
|
|
307
|
+
return columns.map(({ pool, resolved }) => {
|
|
308
|
+
const tools = resolved?.access.toolAccess?.get(connector.id);
|
|
309
|
+
return {
|
|
310
|
+
connectorId: connector.id,
|
|
311
|
+
pool,
|
|
312
|
+
tools: tools ? [...tools].filter(validName) : "all",
|
|
313
|
+
runsAs: runsAs(resolved, humans(resolved)),
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
}), { concurrency: resolveDiscoveryConcurrency(opts.config.discovery.concurrency) }).pipe(Effect.map((cells) => cells.flat()));
|
|
317
|
+
}
|
|
318
|
+
function maintainerRead(context, authz) {
|
|
144
319
|
const { opts } = context;
|
|
145
320
|
return Effect.gen(function* () {
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const visible = registry.listConnectors();
|
|
321
|
+
const registry = opts.registry.scoped(scopeFor(authz));
|
|
322
|
+
const selected = context.url.searchParams.get("connector");
|
|
323
|
+
const visible = registry.listConnectors().filter((connector) => selected === null || connector.id === selected);
|
|
324
|
+
if (selected !== null && visible.length === 0)
|
|
325
|
+
return yield* refuse("unknown connector", 404);
|
|
149
326
|
const ids = visible.map((c) => c.id);
|
|
150
|
-
const
|
|
151
|
-
for (const [name, pool] of opts.pools) {
|
|
152
|
-
const admitted = yield* attempt(async () => (await pool.grant(authz.identity)) === true).pipe(Effect.orElseSucceed(() => false));
|
|
153
|
-
if (admitted)
|
|
154
|
-
pools.push({
|
|
155
|
-
name,
|
|
156
|
-
path: `/mcp/${name}`,
|
|
157
|
-
trust: pool.trust,
|
|
158
|
-
grants: grants(intersectAccess(authz, pool.access), ids),
|
|
159
|
-
});
|
|
160
|
-
}
|
|
327
|
+
const allIds = registry.listConnectors().map((c) => c.id);
|
|
161
328
|
const config = structuredClone(opts.configDescription);
|
|
329
|
+
// A connector-filtered read (`?connector=`) discloses only that connector
|
|
330
|
+
// in the config snapshot, its overlay and its matrix cells.
|
|
162
331
|
config.connectors = config.connectors
|
|
163
332
|
.filter((c) => ids.includes(c.id))
|
|
164
333
|
.map((c) => ({
|
|
165
334
|
...c,
|
|
166
|
-
...(c.tools
|
|
167
|
-
? { tools: c.tools.filter((tool) => toolVisible(authz, c.id, tool.name, tool.classification)) }
|
|
168
|
-
: {}),
|
|
335
|
+
...(c.tools ? { tools: c.tools.filter((tool) => validName(tool.name)) } : {}),
|
|
169
336
|
}));
|
|
170
337
|
config.classification = Object.fromEntries(Object.entries(config.classification)
|
|
171
338
|
.filter(([id]) => ids.includes(id))
|
|
172
|
-
.map(([id, tools]) => [
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
: grant.tools.map((tool) => `${grant.connectorId}.${tool.name}`)),
|
|
186
|
-
},
|
|
187
|
-
];
|
|
188
|
-
});
|
|
339
|
+
.map(([id, tools]) => [id, Object.fromEntries(Object.entries(tools).filter(([name]) => validName(name)))]));
|
|
340
|
+
// A pool owner is a Workspace mailbox address. Only an authenticated
|
|
341
|
+
// maintainer reads it; an open deployment's anonymous caller, which this
|
|
342
|
+
// route admits, gets null, the same as an unset owner (#837).
|
|
343
|
+
const ownersVisible = opts.config.auth.length > 0 && isMaintainer(opts.maintainers, authz.identity);
|
|
344
|
+
config.pools = config.pools.map((pool) => ({
|
|
345
|
+
...pool,
|
|
346
|
+
owner: ownersVisible ? pool.owner : null,
|
|
347
|
+
tools: grants(opts.pools.get(pool.name)?.access ?? { connectorIds: [] }, ids).flatMap((grant) => grant.tools === "all" ? [grant.connectorId] : grant.tools.map((tool) => `${grant.connectorId}.${tool.name}`)),
|
|
348
|
+
}));
|
|
349
|
+
const people = new Map();
|
|
350
|
+
for (const person of [...opts.maintainers, ...[...opts.pools.values()].flatMap(humansOf)])
|
|
351
|
+
people.set(referenceKey(person), person);
|
|
189
352
|
const requestActivity = operatorActivityContext(context, authz);
|
|
190
|
-
const rows = yield* Effect.
|
|
191
|
-
|
|
353
|
+
const [rows, label, slots] = yield* Effect.all([
|
|
354
|
+
Effect.forEach(visible, (c) => observe(context, registry, c, requestActivity), {
|
|
355
|
+
concurrency: resolveDiscoveryConcurrency(opts.config.discovery.concurrency),
|
|
356
|
+
}),
|
|
357
|
+
humanLabels(context, authz, [...people.values()]),
|
|
358
|
+
// Every declared agent's slot, exactly as GET /ui/access-tokens lists
|
|
359
|
+
// it. Without them the agents list cannot be whole, so a failed read
|
|
360
|
+
// fails the view rather than dropping agents from it.
|
|
361
|
+
opts.config.accessTokens
|
|
362
|
+
? bounded(context, () => attempt(() => opts.config.accessTokens.slots()), (error) => {
|
|
363
|
+
logFailure(opts.config.logger, "operator agent slots read failed", failureRecord({}, error));
|
|
364
|
+
return refuse("agent token slots could not be read", 503);
|
|
365
|
+
})
|
|
366
|
+
: Effect.succeed([]),
|
|
367
|
+
], { concurrency: "unbounded" });
|
|
368
|
+
const human = (person) => ({
|
|
369
|
+
kind: "human",
|
|
370
|
+
namespace: person.namespace,
|
|
371
|
+
id: person.id,
|
|
372
|
+
label: label(person),
|
|
192
373
|
});
|
|
193
|
-
const
|
|
374
|
+
const pools = [...opts.pools].map(([name, pool]) => ({
|
|
375
|
+
name,
|
|
376
|
+
humanPath: `/mcp/${name}`,
|
|
377
|
+
agentPath: `/agent/${name}`,
|
|
378
|
+
readOnly: pool.readOnly,
|
|
379
|
+
owner: ownersVisible ? (pool.owner ?? null) : null,
|
|
380
|
+
tools: grants(pool.access, allIds),
|
|
381
|
+
members: pool.members.map((member) => member.kind === "agent" ? { kind: "agent", name: member.name } : human(member)),
|
|
382
|
+
}));
|
|
383
|
+
const agents = slots.map(({ name, ...token }) => ({
|
|
384
|
+
name,
|
|
385
|
+
pools: [...opts.pools]
|
|
386
|
+
.filter(([, pool]) => pool.members.some((member) => member.kind === "agent" && member.name === name))
|
|
387
|
+
.map(([pool]) => pool),
|
|
388
|
+
token,
|
|
389
|
+
lastSeenAt: null,
|
|
390
|
+
}));
|
|
391
|
+
const activity = yield* scanActivity(context, authz, registry, rows, agents);
|
|
392
|
+
const matrix = yield* matrixFor(context, visible, rows, ownersVisible);
|
|
194
393
|
const contract = {
|
|
195
|
-
schemaVersion:
|
|
394
|
+
schemaVersion: 2,
|
|
395
|
+
role: "maintainer",
|
|
196
396
|
config,
|
|
197
397
|
configSources: Object.fromEntries(configValuePaths(config).map((path) => [path, opts.configValueSources?.[path] ?? "config"])),
|
|
198
398
|
live: { connectors: rows, activity },
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
399
|
+
root: { path: "/mcp", readOnly: opts.config.readOnly, maintainers: opts.maintainers.map(human) },
|
|
400
|
+
pools,
|
|
401
|
+
agents,
|
|
402
|
+
matrix,
|
|
403
|
+
connectors: [],
|
|
404
|
+
you: { interactive: authz.identity.interactive },
|
|
405
|
+
};
|
|
406
|
+
return privateJson(contract);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* What this member may do with one personal connector, from three facts: the
|
|
411
|
+
* member's own permission, what the deployment makes available (a vault for
|
|
412
|
+
* credentials; signed OAuth hand-off for connect), and the connector's hooks.
|
|
413
|
+
* Credential `test` and `clear` also need a value stored in the member's own
|
|
414
|
+
* partition, so the actions alone tell the page whether one is.
|
|
415
|
+
*/
|
|
416
|
+
function memberManage(context, authz, connector) {
|
|
417
|
+
const { opts } = context;
|
|
418
|
+
const principalKey = authz.principalKey;
|
|
419
|
+
if (connector.authScope !== "personal" || !principalKey || !mayManageConnector(opts, authz, connector)) {
|
|
420
|
+
return Effect.succeed({ kind: "none" });
|
|
421
|
+
}
|
|
422
|
+
const credential = connector.credential;
|
|
423
|
+
if (credential) {
|
|
424
|
+
const vault = opts.config.vault;
|
|
425
|
+
if (!vault)
|
|
426
|
+
return Effect.succeed({ kind: "none" });
|
|
427
|
+
const card = {
|
|
428
|
+
kind: "credential",
|
|
429
|
+
label: credential.label,
|
|
430
|
+
...(credential.description ? { description: credential.description } : {}),
|
|
431
|
+
...(credential.fields?.length
|
|
432
|
+
? {
|
|
433
|
+
fields: credential.fields.map((field) => ({
|
|
434
|
+
name: field.name,
|
|
435
|
+
label: field.label,
|
|
436
|
+
...(field.description ? { description: field.description } : {}),
|
|
212
437
|
})),
|
|
213
|
-
}
|
|
214
|
-
|
|
438
|
+
}
|
|
439
|
+
: {}),
|
|
440
|
+
};
|
|
441
|
+
return bounded(context, () => attempt(() => vault.metadata(connector.id, principalKey)).pipe(Effect.map((metadata) => {
|
|
442
|
+
if (!metadata)
|
|
443
|
+
return { ...card, actions: ["save"] };
|
|
444
|
+
const shape = storedCredentialShape(credential, metadata.fields ?? null);
|
|
445
|
+
const testable = credentialTestRule(connector).mode !== null && shape.state === "valid";
|
|
446
|
+
return { ...card, actions: testable ? ["save", "test", "clear"] : ["save", "clear"] };
|
|
447
|
+
})),
|
|
448
|
+
// Unreadable or stalled: whether a value is stored is unknown, so
|
|
449
|
+
// nothing that needs one is offered.
|
|
450
|
+
() => Effect.succeed({ ...card, actions: ["save"] }));
|
|
451
|
+
}
|
|
452
|
+
const connect = Boolean(connector.startAuth && connector.finishAuth && connector.verifyState && !oauthConnectUnavailable(opts));
|
|
453
|
+
const disconnect = Boolean(connector.disconnectAuth);
|
|
454
|
+
if (!connect && !disconnect)
|
|
455
|
+
return Effect.succeed({ kind: "none" });
|
|
456
|
+
return Effect.succeed({
|
|
457
|
+
kind: "oauth",
|
|
458
|
+
actions: [...(connect ? ["connect", "restart"] : []), ...(disconnect ? ["disconnect"] : [])],
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* One connector in a member's pools: the tools those pools grant, with the
|
|
463
|
+
* registry's classification, and for a personal connector the member's own
|
|
464
|
+
* connection. A shared connector's status is the deployment's and is never
|
|
465
|
+
* read here; only its catalog is.
|
|
466
|
+
*/
|
|
467
|
+
function memberConnector(context, authz, registry, connector, pools, activity) {
|
|
468
|
+
const { baseUrl, defer } = context;
|
|
469
|
+
const id = connector.id;
|
|
470
|
+
const personal = connector.authScope === "personal";
|
|
471
|
+
const serving = pools.filter(([, pool]) => servesConnector(pool, id));
|
|
472
|
+
const granted = serving.some(([, pool]) => !pool.access.toolAccess?.has(id))
|
|
473
|
+
? "all"
|
|
474
|
+
: new Set(serving.flatMap(([, pool]) => [...pool.access.toolAccess.get(id)]));
|
|
475
|
+
const scope = requestScope(activity);
|
|
476
|
+
return Effect.gen(function* () {
|
|
477
|
+
const seen = yield* probe(context, registry, connector, scope, (signal) => personal
|
|
478
|
+
? Effect.map(observed(context, registry, connector, scope, signal), ({ status, drift, tools, catalogFailed }) => {
|
|
479
|
+
const problem = uiProblemFor(connector, status.state, { credentialDrift: drift, catalogFailed });
|
|
480
|
+
return { connection: { state: status.state, ...(problem ? { problem } : {}) }, tools };
|
|
481
|
+
})
|
|
482
|
+
: attempt(() => registry.getTools(id, baseUrl, scope, { signal, ...(defer ? { defer } : {}) })).pipe(Effect.orElseSucceed(() => []), Effect.map((tools) => ({ connection: null, tools }))), () => ({ connection: personal ? { state: "error", problem: "connector_unavailable" } : null, tools: [] }));
|
|
483
|
+
const manage = yield* memberManage(context, authz, connector);
|
|
484
|
+
const addresses = new Set();
|
|
485
|
+
const tools = describedTools(seen.tools).flatMap((tool) => {
|
|
486
|
+
// A name outside the record grammar is withheld entirely, not shown as a placeholder.
|
|
487
|
+
if (!validName(tool.name) || (granted !== "all" && !granted.has(tool.name)))
|
|
488
|
+
return [];
|
|
489
|
+
const address = `${id}.${tool.name}`;
|
|
490
|
+
if (addresses.has(address))
|
|
491
|
+
return [];
|
|
492
|
+
addresses.add(address);
|
|
493
|
+
return [{ address, classification: classified(tool) }];
|
|
494
|
+
});
|
|
495
|
+
return {
|
|
496
|
+
id,
|
|
497
|
+
title: connector.title ?? id,
|
|
498
|
+
pools: serving.map(([name]) => name),
|
|
499
|
+
authScope: personal ? "personal" : "shared",
|
|
500
|
+
tools,
|
|
501
|
+
connection: personal ? seen.connection : null,
|
|
502
|
+
manage: personal ? manage : { kind: "none" },
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/** The human pool memberships of this identity. An agent is never a member here. */
|
|
507
|
+
function memberPools(context, authz) {
|
|
508
|
+
return [...context.opts.pools].filter(([, pool]) => pool.members.some((member) => member.kind === "human" && isMember(member, authz.identity)));
|
|
509
|
+
}
|
|
510
|
+
function memberRead(context, authz, pools) {
|
|
511
|
+
const { opts } = context;
|
|
512
|
+
return Effect.gen(function* () {
|
|
513
|
+
const allIds = opts.registry.listConnectors().map((c) => c.id);
|
|
514
|
+
const ids = new Set(allIds.filter((id) => pools.some(([, pool]) => servesConnector(pool, id))));
|
|
515
|
+
const registry = opts.registry.scoped(scopeFor(authz, [...ids]));
|
|
516
|
+
const selected = context.url.searchParams.get("connector");
|
|
517
|
+
const visible = registry
|
|
518
|
+
.listConnectors()
|
|
519
|
+
.filter((connector) => ids.has(connector.id) && (selected === null || connector.id === selected));
|
|
520
|
+
if (selected !== null && visible.length === 0)
|
|
521
|
+
return yield* refuse("unknown connector", 404);
|
|
522
|
+
const activity = operatorActivityContext(context, authz);
|
|
523
|
+
const connectors = yield* Effect.forEach(visible, (connector) => memberConnector(context, authz, registry, connector, pools, activity), { concurrency: resolveDiscoveryConcurrency(opts.config.discovery.concurrency) });
|
|
524
|
+
const contract = {
|
|
525
|
+
schemaVersion: 2,
|
|
526
|
+
role: "member",
|
|
527
|
+
config: null,
|
|
528
|
+
configSources: null,
|
|
529
|
+
live: { connectors: [], activity: "forbidden" },
|
|
530
|
+
root: null,
|
|
531
|
+
pools: pools.map(([name, pool]) => ({
|
|
532
|
+
name,
|
|
533
|
+
humanPath: `/mcp/${name}`,
|
|
534
|
+
agentPath: `/agent/${name}`,
|
|
535
|
+
readOnly: pool.readOnly,
|
|
536
|
+
owner: null,
|
|
537
|
+
tools: grants(pool.access, allIds),
|
|
538
|
+
members: [],
|
|
539
|
+
})),
|
|
540
|
+
agents: [],
|
|
541
|
+
matrix: [],
|
|
542
|
+
connectors,
|
|
543
|
+
you: { interactive: authz.identity.interactive },
|
|
215
544
|
};
|
|
216
545
|
return privateJson(contract);
|
|
217
546
|
});
|
|
218
547
|
}
|
|
548
|
+
function configRead(context) {
|
|
549
|
+
return Effect.gen(function* () {
|
|
550
|
+
const authz = yield* authorized(context);
|
|
551
|
+
if (admitsMaintainer(context.opts, authz))
|
|
552
|
+
return yield* maintainerRead(context, authz);
|
|
553
|
+
const pools = memberPools(context, authz);
|
|
554
|
+
if (pools.length > 0)
|
|
555
|
+
return yield* memberRead(context, authz, pools);
|
|
556
|
+
return yield* refuse("not found", 404);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
219
559
|
export async function routeUiConfig(context) {
|
|
220
560
|
if (context.path !== "/ui/api/config")
|
|
221
561
|
return null;
|