@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.
Files changed (169) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +30 -20
  3. package/bin/connecta.mjs +25 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/auth-elicitation.js +1 -1
  10. package/dist/call-admission-context.js +15 -0
  11. package/dist/call-admission.js +30 -43
  12. package/dist/call-recovery.js +6 -2
  13. package/dist/catalog-service.js +7 -2
  14. package/dist/config-value-sources.js +14 -9
  15. package/dist/config.d.ts +58 -78
  16. package/dist/config.js +143 -47
  17. package/dist/connector-access.d.ts +4 -33
  18. package/dist/connector-access.js +15 -99
  19. package/dist/connector-caller.d.ts +7 -1
  20. package/dist/connector-caller.js +3 -2
  21. package/dist/connectors/api-connector.d.ts +27 -4
  22. package/dist/connectors/api-connector.js +2 -0
  23. package/dist/connectors/guarded-fetch.d.ts +9 -0
  24. package/dist/connectors/guarded-fetch.js +7 -0
  25. package/dist/connectors/remote-mcp.js +2 -0
  26. package/dist/describe-config.d.ts +32 -7
  27. package/dist/describe-config.js +9 -18
  28. package/dist/errors.d.ts +1 -1
  29. package/dist/errors.js +1 -0
  30. package/dist/execute.js +16 -14
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +12 -7
  34. package/dist/membership.d.ts +51 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/meta-tools.js +17 -19
  37. package/dist/module-contracts.d.ts +23 -6
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -2
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/contract.d.ts +151 -29
  43. package/dist/operator-ui/endpoints.js +192 -0
  44. package/dist/operator-ui/fix-prompts.js +4 -4
  45. package/dist/operator-ui/generated.js +5 -4
  46. package/dist/operator-ui/member-page.js +112 -0
  47. package/dist/operator-ui/model.d.ts +15 -7
  48. package/dist/operator-ui/view.d.ts +84 -19
  49. package/dist/operator-ui/view.js +83 -21
  50. package/dist/page-styles.js +1 -1
  51. package/dist/program-writes.js +2 -2
  52. package/dist/provider.d.ts +63 -11
  53. package/dist/provider.js +46 -7
  54. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  55. package/dist/providers/_shared/google/workspace.js +31 -9
  56. package/dist/providers/_shared/rest/tools.js +2 -2
  57. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  58. package/dist/providers/_shared/rest/value-safety.js +699 -7
  59. package/dist/providers/breeze/skill.generated.js +2 -2
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +3 -3
  62. package/dist/providers/cloudflare/rest.js +25 -3
  63. package/dist/providers/cloudflare/skill.generated.js +2 -2
  64. package/dist/providers/cloudflare/value-safety.js +32 -0
  65. package/dist/providers/docs/index.d.ts +1 -1
  66. package/dist/providers/docs/index.js +5 -5
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.d.ts +1 -1
  69. package/dist/providers/drive/index.js +4 -4
  70. package/dist/providers/drive/skill.generated.js +2 -2
  71. package/dist/providers/forms/index.d.ts +1 -1
  72. package/dist/providers/forms/index.js +5 -5
  73. package/dist/providers/forms/skill.generated.js +2 -2
  74. package/dist/providers/github/skill.generated.js +2 -2
  75. package/dist/providers/gmail/index.d.ts +1 -1
  76. package/dist/providers/gmail/index.js +279 -26
  77. package/dist/providers/gmail/skill.generated.js +2 -2
  78. package/dist/providers/linear/index.js +1 -1
  79. package/dist/providers/mixpanel/index.d.ts +27 -12
  80. package/dist/providers/mixpanel/index.js +58 -6
  81. package/dist/providers/mixpanel/operations.js +20 -0
  82. package/dist/providers/mixpanel/rest.js +603 -0
  83. package/dist/providers/mixpanel/skill.generated.js +3 -2
  84. package/dist/providers/mixpanel/value-safety.js +16 -0
  85. package/dist/providers/notion/index.d.ts +7 -0
  86. package/dist/providers/notion/index.js +116 -11
  87. package/dist/providers/notion/rest.js +183 -24
  88. package/dist/providers/notion/skill.generated.js +4 -4
  89. package/dist/providers/notion/value-safety.js +30 -5
  90. package/dist/providers/overflow/index.js +1 -1
  91. package/dist/providers/planning-center/index.d.ts +27 -0
  92. package/dist/providers/planning-center/index.js +287 -25
  93. package/dist/providers/planning-center/skill.generated.js +24 -4
  94. package/dist/providers/sheets/index.d.ts +4 -1
  95. package/dist/providers/sheets/index.js +9 -6
  96. package/dist/providers/sheets/skill.generated.js +2 -2
  97. package/dist/providers/slides/index.d.ts +1 -1
  98. package/dist/providers/slides/index.js +6 -6
  99. package/dist/providers/slides/skill.generated.js +2 -2
  100. package/dist/providers/stripe/rest.js +9 -2
  101. package/dist/providers/stripe/skill.generated.js +2 -2
  102. package/dist/providers/stripe/value-safety.js +22 -0
  103. package/dist/providers/vercel/index.js +2 -2
  104. package/dist/providers/vercel/rest.js +20 -2
  105. package/dist/providers/vercel/skill.generated.js +2 -2
  106. package/dist/providers/vercel/value-safety.js +22 -0
  107. package/dist/providers/wispr-flow/index.d.ts +14 -0
  108. package/dist/providers/wispr-flow/index.js +117 -0
  109. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  110. package/dist/registry.d.ts +2 -3
  111. package/dist/registry.js +8 -9
  112. package/dist/routes/access-tokens.js +82 -75
  113. package/dist/routes/activity-disclosure.js +17 -18
  114. package/dist/routes/activity.js +34 -54
  115. package/dist/routes/actor-labels.js +33 -4
  116. package/dist/routes/agent-path.js +43 -0
  117. package/dist/routes/connect.js +16 -12
  118. package/dist/routes/credentials.js +16 -40
  119. package/dist/routes/mcp.js +155 -85
  120. package/dist/routes/oauth-management.js +11 -5
  121. package/dist/routes/oauth.js +3 -9
  122. package/dist/routes/operator.js +25 -17
  123. package/dist/routes/shared.d.ts +60 -20
  124. package/dist/routes/shared.js +187 -96
  125. package/dist/routes/ui-config.js +437 -97
  126. package/dist/routes/ui-data.js +2 -2
  127. package/dist/routes/ui.js +9 -16
  128. package/dist/runtime/call-admission.js +9 -0
  129. package/dist/server.js +8 -0
  130. package/dist/skills.js +5 -4
  131. package/dist/storage/keys.js +9 -1
  132. package/dist/tool-safety.js +13 -5
  133. package/dist/types.d.ts +12 -1
  134. package/dist/ui.d.ts +2 -2
  135. package/dist/ui.js +17 -15
  136. package/dist/usage-guide.js +3 -3
  137. package/dist/version.d.ts +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/workspace-address.js +9 -0
  140. package/dist/workspace-subject.js +23 -0
  141. package/documentation/README.md +46 -22
  142. package/documentation/architecture.md +378 -58
  143. package/documentation/auth.md +806 -215
  144. package/documentation/code-mode.md +93 -81
  145. package/documentation/deploying.md +128 -26
  146. package/documentation/integrating.md +145 -22
  147. package/documentation/meta-tools.md +58 -46
  148. package/documentation/migration-0.33.md +344 -0
  149. package/documentation/operating.md +14 -9
  150. package/documentation/operator-tests.md +117 -31
  151. package/documentation/operator-ui.md +326 -55
  152. package/documentation/provider-auth-migration.md +42 -27
  153. package/documentation/provider-migration-0.29.md +3 -3
  154. package/examples/worker/AGENTS.md +35 -8
  155. package/examples/worker/README.md +92 -53
  156. package/examples/worker/src/connecta.config.ts +41 -20
  157. package/examples/worker/wrangler.jsonc +3 -0
  158. package/package.json +7 -2
  159. package/templates/node/.env.example +3 -0
  160. package/templates/node/AGENTS.md +33 -10
  161. package/templates/node/Dockerfile +2 -2
  162. package/templates/node/README.md +71 -44
  163. package/templates/node/docker-compose.yml +3 -2
  164. package/templates/node/package.json +4 -3
  165. package/templates/node/src/adopt-token.ts +23 -0
  166. package/templates/node/src/connecta.config.ts +33 -12
  167. package/templates/node/src/rotate-token.ts +17 -0
  168. package/dist/tool-safety.d.ts +0 -9
  169. package/templates/node/src/provision-token.ts +0 -17
@@ -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 { intersectAccess } from "../connector-access.js";
13
+ import { credentialTestRule, storedCredentialShape } from "../credential-rules.js";
7
14
  import { describedTools } from "../described.js";
8
15
  import { resolveDiscoveryConcurrency } from "../concurrency.js";
9
- import { recordedToolName } from "../operator-record.js";
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 { authorized, serveOperator, visibleRegistry } from "./operator.js";
14
- import { mayManageConnector, privateJson } from "./shared.js";
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
- function toolVisible(authz, id, name, classification) {
34
- return (validName(name) &&
35
- (!authz.toolAccess?.get(id) || authz.toolAccess.get(id).has(name)) &&
36
- (!authz.guardedToolAccess?.get(id)?.has(name) || classification === "read"));
37
- }
38
- function observe(context, registry, connector, activity) {
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 withDeadlineEffect((signal) => Effect.gen(function* () {
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
- const problem = uiProblemFor(connector, status.state, { credentialDrift: Boolean(drift), catalogFailed });
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 operators. Only the name
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
- })), Effect.ensuring(Effect.suspend(() => closeScope(connector, registry.contextFor(id, baseUrl, scope), defer))));
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
- function lastCalls(context, authz, registry, rows, admittedPools) {
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.operator)
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).pipe(Effect.orElseSucceed(() => false))
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
- return yield* Effect.gen(function* () {
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.kind === "catalog_drift")
165
+ if (!isTimestamp(event.occurredAt))
121
166
  continue;
122
- const row = byId.get(event.connectorId);
123
- if (!row || !isTimestamp(event.occurredAt) || !OUTCOMES.has(event.outcome))
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 (!activityEventVisible(context, authz, registry, admittedPools, event))
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
- }).pipe(Effect.catchDefect((defect) => Effect.fail(defect)), Effect.orElseSucceed(() => {
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
- function configRead(context) {
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 authz = yield* authorized(context);
147
- const registry = yield* visibleRegistry(context, authz);
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 pools = [];
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
- id,
174
- Object.fromEntries(Object.entries(tools).filter(([name, classification]) => toolVisible(authz, id, name, classification))),
175
- ]));
176
- config.pools = config.pools.flatMap((pool) => {
177
- const admitted = pools.find((p) => p.name === pool.name);
178
- if (!admitted)
179
- return [];
180
- return [
181
- {
182
- ...pool,
183
- tools: admitted.grants.flatMap((grant) => grant.tools === "all"
184
- ? [grant.connectorId]
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.forEach(visible, (c) => observe(context, registry, c, requestActivity), {
191
- concurrency: resolveDiscoveryConcurrency(opts.config.discovery.concurrency),
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 activity = yield* lastCalls(context, authz, registry, rows, new Set(pools.map((pool) => pool.name)));
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: 1,
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
- you: {
200
- interactive: authz.identity.interactive,
201
- grants: grants(authz, ids),
202
- trust: opts.config.trust,
203
- pools,
204
- permissions: {
205
- activity: activity === "available" || activity === "unavailable",
206
- accessTokenManagement: Boolean(opts.config.accessTokens && authz.accessTokenManagement && authz.identity.principal),
207
- connectors: visible.map((c) => ({
208
- id: c.id,
209
- use: true,
210
- manageSharedAuth: c.authScope !== "personal" && mayManageConnector(authz, c),
211
- connectPersonal: c.authScope === "personal" && mayManageConnector(authz, c),
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;