@zackbart/connecta 0.32.0 → 0.34.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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +95 -116
  3. package/SECURITY.md +1 -1
  4. package/bin/connecta.mjs +24 -6
  5. package/bin/version.mjs +1 -1
  6. package/dist/access-tokens.d.ts +167 -23
  7. package/dist/access-tokens.js +676 -140
  8. package/dist/activity.d.ts +3 -0
  9. package/dist/agent-identity.js +47 -0
  10. package/dist/auth/cloudflare-access.js +13 -5
  11. package/dist/branding.d.ts +7 -0
  12. package/dist/branding.js +76 -9
  13. package/dist/call-admission-context.js +15 -0
  14. package/dist/call-admission.js +30 -43
  15. package/dist/call-recovery.js +6 -2
  16. package/dist/catalog-service.js +15 -1
  17. package/dist/config-value-sources.js +16 -9
  18. package/dist/config.d.ts +48 -75
  19. package/dist/config.js +144 -36
  20. package/dist/connector-access.d.ts +4 -34
  21. package/dist/connector-access.js +15 -99
  22. package/dist/connector-caller.d.ts +7 -1
  23. package/dist/connector-caller.js +4 -2
  24. package/dist/connectors/api-connector.d.ts +27 -4
  25. package/dist/connectors/api-connector.js +2 -0
  26. package/dist/connectors/remote-mcp.js +2 -0
  27. package/dist/describe-config.d.ts +31 -7
  28. package/dist/describe-config.js +8 -17
  29. package/dist/execute.js +1 -11
  30. package/dist/fix-prompt.js +2 -2
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +5 -3
  34. package/dist/membership.d.ts +53 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/module-contracts.d.ts +23 -6
  37. package/dist/oauth-callback-outcome.js +2 -2
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -4
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/config-summary.js +562 -0
  43. package/dist/operator-ui/connection-map.js +375 -0
  44. package/dist/operator-ui/contract.d.ts +149 -28
  45. package/dist/operator-ui/endpoints.js +221 -0
  46. package/dist/operator-ui/fix-prompts.js +5 -5
  47. package/dist/operator-ui/generated.js +5 -4
  48. package/dist/operator-ui/member-page.js +97 -0
  49. package/dist/operator-ui/model.d.ts +14 -6
  50. package/dist/operator-ui/overview.js +134 -0
  51. package/dist/operator-ui/view.d.ts +107 -37
  52. package/dist/operator-ui/view.js +163 -86
  53. package/dist/page-styles.js +5 -3
  54. package/dist/provider.d.ts +61 -8
  55. package/dist/provider.js +46 -7
  56. package/dist/providers/_shared/google/workspace.d.ts +56 -17
  57. package/dist/providers/_shared/google/workspace.js +34 -8
  58. package/dist/providers/_shared/rest/tools.js +2 -2
  59. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  60. package/dist/providers/_shared/rest/value-safety.js +699 -7
  61. package/dist/providers/ccb/index.js +2 -2
  62. package/dist/providers/cloudflare/index.js +3 -3
  63. package/dist/providers/cloudflare/rest.js +25 -3
  64. package/dist/providers/cloudflare/skill.generated.js +1 -1
  65. package/dist/providers/cloudflare/value-safety.js +32 -0
  66. package/dist/providers/docs/index.js +4 -4
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.js +4 -4
  69. package/dist/providers/drive/skill.generated.js +2 -2
  70. package/dist/providers/forms/index.js +4 -4
  71. package/dist/providers/forms/skill.generated.js +2 -2
  72. package/dist/providers/gmail/index.js +11 -9
  73. package/dist/providers/gmail/skill.generated.js +2 -2
  74. package/dist/providers/mixpanel/index.js +1 -1
  75. package/dist/providers/mixpanel/skill.generated.js +3 -3
  76. package/dist/providers/notion/index.d.ts +7 -0
  77. package/dist/providers/notion/index.js +100 -11
  78. package/dist/providers/notion/rest.js +45 -5
  79. package/dist/providers/notion/skill.generated.js +2 -2
  80. package/dist/providers/notion/value-safety.js +30 -5
  81. package/dist/providers/overflow/index.js +2 -2
  82. package/dist/providers/overflow/skill.generated.js +2 -2
  83. package/dist/providers/planning-center/index.js +2 -2
  84. package/dist/providers/planning-center/skill.generated.js +4 -4
  85. package/dist/providers/revenuecat/skill.generated.js +2 -2
  86. package/dist/providers/sheets/index.d.ts +3 -0
  87. package/dist/providers/sheets/index.js +7 -4
  88. package/dist/providers/sheets/skill.generated.js +2 -2
  89. package/dist/providers/slides/index.js +4 -4
  90. package/dist/providers/slides/skill.generated.js +2 -2
  91. package/dist/providers/stripe/rest.js +9 -2
  92. package/dist/providers/stripe/skill.generated.js +1 -1
  93. package/dist/providers/stripe/value-safety.js +22 -0
  94. package/dist/providers/tithely/index.js +1 -1
  95. package/dist/providers/vercel/index.js +1 -1
  96. package/dist/providers/vercel/rest.js +20 -2
  97. package/dist/providers/vercel/skill.generated.js +2 -2
  98. package/dist/providers/vercel/value-safety.js +22 -0
  99. package/dist/registry.d.ts +2 -3
  100. package/dist/registry.js +23 -9
  101. package/dist/routes/access-tokens.js +82 -75
  102. package/dist/routes/activity-disclosure.js +17 -18
  103. package/dist/routes/activity.js +34 -54
  104. package/dist/routes/actor-labels.js +33 -4
  105. package/dist/routes/agent-path.js +43 -0
  106. package/dist/routes/connect.js +16 -12
  107. package/dist/routes/credentials.js +16 -40
  108. package/dist/routes/mcp.js +161 -104
  109. package/dist/routes/oauth-management.js +11 -5
  110. package/dist/routes/oauth.js +5 -11
  111. package/dist/routes/operator.js +24 -23
  112. package/dist/routes/shared.d.ts +60 -20
  113. package/dist/routes/shared.js +187 -96
  114. package/dist/routes/ui-config.js +437 -97
  115. package/dist/routes/ui-data.js +2 -2
  116. package/dist/routes/ui.js +9 -16
  117. package/dist/runtime/call-admission.js +9 -0
  118. package/dist/server.js +5 -0
  119. package/dist/skills.js +10 -0
  120. package/dist/storage/keys.js +9 -1
  121. package/dist/storage/sql.js +5 -1
  122. package/dist/types.d.ts +29 -7
  123. package/dist/ui.d.ts +1 -1
  124. package/dist/ui.js +23 -16
  125. package/dist/usage-guide.js +1 -1
  126. package/dist/version.d.ts +1 -1
  127. package/dist/version.js +1 -1
  128. package/dist/workspace-address.js +9 -0
  129. package/dist/workspace-subject.js +23 -0
  130. package/documentation/README.md +47 -23
  131. package/documentation/architecture.md +371 -61
  132. package/documentation/auth.md +897 -249
  133. package/documentation/code-mode.md +37 -16
  134. package/documentation/deploying.md +126 -25
  135. package/documentation/integrating.md +97 -23
  136. package/documentation/meta-tools.md +50 -31
  137. package/documentation/migration-0.33.md +352 -0
  138. package/documentation/migration-0.34.md +67 -0
  139. package/documentation/operating.md +3 -1
  140. package/documentation/operator-tests.md +147 -34
  141. package/documentation/operator-ui.md +519 -64
  142. package/documentation/provider-auth-migration.md +37 -23
  143. package/documentation/provider-migration-0.29.md +2 -2
  144. package/examples/worker/AGENTS.md +37 -9
  145. package/examples/worker/README.md +127 -61
  146. package/examples/worker/src/connecta.config.ts +40 -23
  147. package/examples/worker/wrangler.jsonc +4 -1
  148. package/package.json +19 -18
  149. package/templates/node/.env.example +3 -0
  150. package/templates/node/AGENTS.md +33 -6
  151. package/templates/node/Dockerfile +2 -2
  152. package/templates/node/README.md +80 -52
  153. package/templates/node/docker-compose.yml +3 -2
  154. package/templates/node/package.json +4 -3
  155. package/templates/node/src/adopt-token.ts +23 -0
  156. package/templates/node/src/connecta.config.ts +28 -12
  157. package/templates/node/src/rotate-token.ts +17 -0
  158. package/templates/node/src/provision-token.ts +0 -17
@@ -0,0 +1,375 @@
1
+ import { endpointRows, hueOf, initialsOf } from "./endpoints.js";
2
+ import { connectorVerdict } from "./view.js";
3
+ /* Layout. One coordinate system, scaled by the SVG's viewBox. */
4
+ const MAP_WIDTH = 1000;
5
+ const COLUMN_X = [16, 280, 544];
6
+ const COLUMN_WIDTH = [196, 196, 196];
7
+ /** Each connector's read and write tools sit in a fourth column, joined by a straight link. */
8
+ const TOOLS_X = 808;
9
+ const TOOLS_WIDTH = 176;
10
+ const NODE_HEIGHT = 54;
11
+ const NODE_GAP = 12;
12
+ const HEAD = 40;
13
+ const FOOT = 12;
14
+ /** Characters a node's name keeps before it is cut with an ellipsis. */
15
+ const LABEL_LIMIT = [18, 17, 17];
16
+ /** A read-only endpoint's name shares its row with the read-only chip. */
17
+ const READ_ONLY_LABEL_LIMIT = 12;
18
+ /** "a", "a and b", "a, b and c". */
19
+ export function joinList(items) {
20
+ if (items.length <= 1)
21
+ return items[0] ?? "";
22
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
23
+ }
24
+ function plural(count, one, many = `${one}s`) {
25
+ return `${count} ${count === 1 ? one : many}`;
26
+ }
27
+ /** Cut a name to `limit` characters, ending in an ellipsis when it was longer. */
28
+ export function truncate(text, limit) {
29
+ return text.length <= limit ? text : `${text.slice(0, limit - 1).trimEnd()}…`;
30
+ }
31
+ /** Where an endpoint's row opens on the Endpoints page. */
32
+ export function endpointHref(path) {
33
+ return `/endpoints#${path.replace(/^\//, "")}`;
34
+ }
35
+ /** The endpoint path an Endpoints page hash names, or null. */
36
+ export function endpointFromHash(hash) {
37
+ const value = hash.replace(/^#/, "");
38
+ return value.startsWith("mcp") ? `/${value}` : null;
39
+ }
40
+ function connectorHref(id, healthy) {
41
+ return `/connectors/${encodeURIComponent(id)}#${healthy ? "tools" : "connection"}`;
42
+ }
43
+ function tokenWorks(agent) {
44
+ return agent?.token.state === "issued";
45
+ }
46
+ /**
47
+ * People in the order the contract first lists them (plain /mcp's
48
+ * maintainers, then each pool's members), then every declared agent, then any
49
+ * agent a pool lists that is not declared. Plain /mcp admits no agents.
50
+ */
51
+ function callersOf(contract, rows) {
52
+ const people = new Map();
53
+ const agents = new Map();
54
+ for (const agent of contract.agents) {
55
+ agents.set(agent.name, { id: `agent:${agent.name}`, kind: "agent", name: agent.name, agent, endpoints: [] });
56
+ }
57
+ const memberLists = rows.map((row) => [
58
+ row,
59
+ row.pool === null
60
+ ? (contract.root?.maintainers ?? [])
61
+ : (contract.pools.find((pool) => pool.name === row.pool)?.members ?? []),
62
+ ]);
63
+ for (const [row, members] of memberLists) {
64
+ for (const member of members) {
65
+ if (member.kind === "agent") {
66
+ if (row.pool === null)
67
+ continue;
68
+ let caller = agents.get(member.name);
69
+ if (!caller) {
70
+ caller = { id: `agent:${member.name}`, kind: "agent", name: member.name, agent: null, endpoints: [] };
71
+ agents.set(member.name, caller);
72
+ }
73
+ if (!caller.endpoints.includes(row))
74
+ caller.endpoints.push(row);
75
+ continue;
76
+ }
77
+ const id = `person:${member.namespace}:${member.id}`;
78
+ let caller = people.get(id);
79
+ if (!caller) {
80
+ caller = { id, kind: "person", name: member.label ?? member.id, agent: null, endpoints: [] };
81
+ people.set(id, caller);
82
+ }
83
+ if (!caller.endpoints.includes(row))
84
+ caller.endpoints.push(row);
85
+ }
86
+ }
87
+ return [...people.values(), ...agents.values()];
88
+ }
89
+ /** Unique live tools a set of endpoints serves, by address. */
90
+ function servedTools(rows) {
91
+ const connectors = new Set();
92
+ const tools = new Map();
93
+ for (const row of rows) {
94
+ for (const group of row.groups) {
95
+ connectors.add(group.config.id);
96
+ for (const tool of group.tools)
97
+ tools.set(tool.address, tool.classification);
98
+ }
99
+ }
100
+ const write = [...tools.values()].filter((value) => value === "write").length;
101
+ return { connectors, tools: tools.size, write };
102
+ }
103
+ export function toolsPhrase(tools, write) {
104
+ if (!tools)
105
+ return "no tools";
106
+ return write ? `${plural(tools, "tool")}, ${write} write` : `${plural(tools, "tool")}, all read`;
107
+ }
108
+ function callerSummary(caller) {
109
+ const name = caller.name;
110
+ if (!caller.endpoints.length) {
111
+ return caller.kind === "agent" ? `${name} is in no endpoint, so it reaches nothing.` : `${name} reaches nothing.`;
112
+ }
113
+ const served = servedTools(caller.endpoints);
114
+ // An agent connects only at a pool's agent URL, never the people's one.
115
+ const through = joinList(caller.endpoints.map((row) => (caller.kind === "agent" ? (row.agentPath ?? row.path) : row.path)));
116
+ const reach = served.connectors.size
117
+ ? `${name} reaches ${plural(served.connectors.size, "connector")} through ${through}: ${toolsPhrase(served.tools, served.write)}.`
118
+ : `${name} uses ${through}, which serves no connectors.`;
119
+ if (caller.kind !== "agent" || tokenWorks(caller.agent))
120
+ return reach;
121
+ const state = caller.agent?.token.state;
122
+ return `${reach} ${state === "revoked" ? "Its token is revoked" : "It has no token"}, so it can't connect.`;
123
+ }
124
+ function callerDetail(caller, maintainers) {
125
+ if (caller.kind === "person")
126
+ return maintainers.has(caller.id) ? "maintainer" : "member";
127
+ const state = caller.agent?.token.state;
128
+ return state === "issued"
129
+ ? "agent · token issued"
130
+ : state === "revoked"
131
+ ? "agent · token revoked"
132
+ : "agent · no token";
133
+ }
134
+ function endpointSummary(row, callers) {
135
+ const listed = callers.filter((caller) => caller.endpoints.includes(row));
136
+ const people = listed.filter((caller) => caller.kind === "person").length;
137
+ const agents = listed.length - people;
138
+ const who = row.pool === null ? plural(people, "maintainer") : plural(people, "person", "people");
139
+ const users = agents ? `${who} and ${plural(agents, "agent")}` : who;
140
+ const served = servedTools([row]);
141
+ const mode = row.readOnly ? `${row.path} is read-only and serves ${users}` : `${row.path} serves ${users}`;
142
+ if (!served.connectors.size)
143
+ return `${mode}. It has no connectors.`;
144
+ return `${mode} with ${plural(served.connectors.size, "connector")}: ${toolsPhrase(served.tools, served.write)}.`;
145
+ }
146
+ const STATUS_SENTENCE = {
147
+ ok: "",
148
+ neutral: "",
149
+ warn: " It needs attention.",
150
+ danger: " It is failing.",
151
+ };
152
+ function connectorSummary(title, id, health, rows, callers) {
153
+ const on = rows.filter((row) => row.groups.some((group) => group.config.id === id));
154
+ const status = STATUS_SENTENCE[health.tone];
155
+ if (!on.length)
156
+ return `No endpoint serves ${title}, so nobody reaches it.${status}`;
157
+ const reaching = callers.filter((caller) => caller.endpoints.some((row) => on.includes(row)));
158
+ const people = reaching.filter((caller) => caller.kind === "person").length;
159
+ const agents = reaching.length - people;
160
+ const who = agents
161
+ ? `${plural(people, "person", "people")} and ${plural(agents, "agent")}`
162
+ : plural(people, "person", "people");
163
+ const verb = reaching.length === 1 ? "reaches" : "reach";
164
+ return `${who} ${verb} ${title} through ${joinList(on.map((row) => row.path))}.${status}`;
165
+ }
166
+ function edgePath(from, to) {
167
+ const x1 = from.x + from.width;
168
+ const y1 = from.y + from.height / 2;
169
+ const x2 = to.x;
170
+ const y2 = to.y + to.height / 2;
171
+ const dx = (x2 - x1) / 2;
172
+ return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
173
+ }
174
+ /** Each column's nodes stacked from the top, the shorter columns centered on the tallest. */
175
+ function place(nodes) {
176
+ const columns = [0, 1, 2].map((column) => nodes.filter((node) => node.column === column));
177
+ const tallest = Math.max(1, ...columns.map((column) => column.length));
178
+ const body = tallest * (NODE_HEIGHT + NODE_GAP) - NODE_GAP;
179
+ columns.forEach((column, index) => {
180
+ const height = column.length * (NODE_HEIGHT + NODE_GAP) - NODE_GAP;
181
+ const top = HEAD + Math.max(0, (body - height) / 2);
182
+ column.forEach((node, position) => {
183
+ node.x = COLUMN_X[index];
184
+ node.width = COLUMN_WIDTH[index];
185
+ node.height = NODE_HEIGHT;
186
+ node.y = top + position * (NODE_HEIGHT + NODE_GAP);
187
+ if (node.kind === "connector")
188
+ node.toolsBox = { x: TOOLS_X, y: node.y, width: TOOLS_WIDTH, height: NODE_HEIGHT };
189
+ });
190
+ });
191
+ return HEAD + body + FOOT;
192
+ }
193
+ function node(fields, limit) {
194
+ return { hue: 0, ...fields, label: truncate(fields.name, limit), toolsBox: null, x: 0, y: 0, width: 0, height: 0 };
195
+ }
196
+ /** A connector's tile: two words' initials, a camel-case title's two capitals, or one letter. */
197
+ export function monogram(title) {
198
+ const words = title.split(/[\s._-]+/).filter(Boolean);
199
+ if (words.length > 1)
200
+ return (words[0][0] + words[1][0]).toUpperCase();
201
+ const capitals = title.match(/[A-Z]/g) ?? [];
202
+ if (capitals.length >= 2)
203
+ return capitals[0] + capitals[1];
204
+ return (words[0] ?? title).charAt(0).toUpperCase();
205
+ }
206
+ /** The whole map for a maintainer's contract; an empty map for anyone else. */
207
+ export function connectionMap(contract, options = {}) {
208
+ const rows = contract.role === "maintainer" ? endpointRows(contract) : [];
209
+ const callers = callersOf(contract, rows);
210
+ const maintainers = new Set((contract.root?.maintainers ?? []).flatMap((member) => member.kind === "human" ? [`person:${member.namespace}:${member.id}`] : []));
211
+ const connectors = (contract.role === "maintainer" ? (contract.config?.connectors ?? []) : []).flatMap((config) => {
212
+ const live = contract.live.connectors.find((c) => c.id === config.id);
213
+ return live ? [{ config, live, health: connectorVerdict(live, options.signals?.[config.id]) }] : [];
214
+ });
215
+ const health = new Map(connectors.map(({ config, health }) => [config.id, health]));
216
+ const nodes = [];
217
+ for (const caller of callers) {
218
+ const works = caller.kind === "person" || tokenWorks(caller.agent);
219
+ nodes.push(node({
220
+ id: caller.id,
221
+ kind: caller.kind,
222
+ column: 0,
223
+ name: caller.name,
224
+ detail: callerDetail(caller, maintainers),
225
+ href: caller.kind === "agent"
226
+ ? options.tokensPage
227
+ ? "/tokens"
228
+ : null
229
+ : caller.endpoints[0]
230
+ ? endpointHref(caller.endpoints[0].path)
231
+ : null,
232
+ tone: works ? "neutral" : "warn",
233
+ readOnly: false,
234
+ tools: null,
235
+ initials: caller.kind === "person" ? initialsOf(caller.name) : "",
236
+ hue: caller.kind === "person" ? hueOf(caller.id.slice("person:".length)) : 0,
237
+ summary: callerSummary(caller),
238
+ }, LABEL_LIMIT[0]));
239
+ }
240
+ for (const row of rows) {
241
+ const listed = callers.filter((caller) => caller.endpoints.includes(row)).length;
242
+ nodes.push(node({
243
+ id: `endpoint:${row.path}`,
244
+ kind: "endpoint",
245
+ column: 1,
246
+ name: row.path,
247
+ detail: `${row.pool === null ? "maintainers" : plural(listed, "caller")} · ${plural(row.groups.length, "connector")}`,
248
+ href: endpointHref(row.path),
249
+ tone: "neutral",
250
+ readOnly: row.readOnly,
251
+ tools: null,
252
+ initials: "",
253
+ summary: endpointSummary(row, callers),
254
+ }, row.readOnly ? READ_ONLY_LABEL_LIMIT : LABEL_LIMIT[1]));
255
+ }
256
+ for (const { config, live, health: verdict } of connectors) {
257
+ const title = config.title ?? config.id;
258
+ const read = live.tools.filter((tool) => tool.classification === "read").length;
259
+ const scope = config.authScope === "personal" ? "personal" : "shared";
260
+ // A failed or unobserved catalog has no tools to count, which is not zero tools.
261
+ const known = live.tools.length > 0 || (live.status === "ok" && !live.problem);
262
+ nodes.push(node({
263
+ id: `connector:${config.id}`,
264
+ kind: "connector",
265
+ column: 2,
266
+ name: title,
267
+ detail: verdict.tone === "ok" ? `${scope} · ${plural(live.tools.length, "tool")}` : verdict.label,
268
+ href: connectorHref(config.id, verdict.tone === "ok"),
269
+ tone: verdict.tone,
270
+ readOnly: false,
271
+ tools: known ? { read, write: live.tools.length - read } : null,
272
+ initials: monogram(title),
273
+ summary: connectorSummary(title, config.id, verdict, rows, callers),
274
+ }, LABEL_LIMIT[2]));
275
+ }
276
+ const height = place(nodes);
277
+ const byId = new Map(nodes.map((n) => [n.id, n]));
278
+ const edges = [];
279
+ for (const caller of callers) {
280
+ for (const row of caller.endpoints) {
281
+ const from = byId.get(caller.id);
282
+ const to = byId.get(`endpoint:${row.path}`);
283
+ edges.push({
284
+ id: `${from.id}>${to.id}`,
285
+ from: from.id,
286
+ to: to.id,
287
+ kind: "access",
288
+ tone: "neutral",
289
+ readOnly: false,
290
+ noToken: caller.kind === "agent" && !tokenWorks(caller.agent),
291
+ served: null,
292
+ path: edgePath(from, to),
293
+ });
294
+ }
295
+ }
296
+ for (const row of rows) {
297
+ for (const group of row.groups) {
298
+ const from = byId.get(`endpoint:${row.path}`);
299
+ const to = byId.get(`connector:${group.config.id}`);
300
+ if (!to)
301
+ continue;
302
+ edges.push({
303
+ id: `${from.id}>${to.id}`,
304
+ from: from.id,
305
+ to: to.id,
306
+ kind: "grant",
307
+ tone: health.get(group.config.id)?.tone ?? "neutral",
308
+ readOnly: row.readOnly,
309
+ noToken: false,
310
+ served: {
311
+ tools: group.tools.length,
312
+ write: group.tools.filter((tool) => tool.classification === "write").length,
313
+ },
314
+ path: edgePath(from, to),
315
+ });
316
+ }
317
+ }
318
+ return {
319
+ nodes,
320
+ edges,
321
+ width: MAP_WIDTH,
322
+ height,
323
+ columns: [
324
+ { label: "Callers", count: callers.length, x: COLUMN_X[0] },
325
+ { label: "Endpoints", count: rows.length, x: COLUMN_X[1] },
326
+ { label: "Connectors", count: connectors.length, x: COLUMN_X[2] },
327
+ { label: "Tools", count: connectors.reduce((n, { live }) => n + live.tools.length, 0), x: TOOLS_X },
328
+ ],
329
+ };
330
+ }
331
+ /**
332
+ * What hovering a node lights up: every node and link on a path through it.
333
+ * Links are walked forward from the node and backward to it, never both ways
334
+ * from a neighbor, so a caller lights only what it reaches and a connector
335
+ * lights only who reaches it.
336
+ */
337
+ export function mapHighlight(map, id) {
338
+ const nodes = new Set([id]);
339
+ const edges = new Set();
340
+ const walk = (forward) => {
341
+ const queue = [id];
342
+ const seen = new Set([id]);
343
+ while (queue.length) {
344
+ const current = queue.shift();
345
+ for (const edge of map.edges) {
346
+ if ((forward ? edge.from : edge.to) !== current)
347
+ continue;
348
+ const target = forward ? edge.to : edge.from;
349
+ edges.add(edge.id);
350
+ nodes.add(target);
351
+ if (!seen.has(target)) {
352
+ seen.add(target);
353
+ queue.push(target);
354
+ }
355
+ }
356
+ }
357
+ };
358
+ walk(true);
359
+ walk(false);
360
+ return { nodes, edges };
361
+ }
362
+ export function mapList(map) {
363
+ const byId = new Map(map.nodes.map((n) => [n.id, n]));
364
+ return map.nodes
365
+ .filter((n) => n.kind === "endpoint")
366
+ .map((endpoint) => ({
367
+ endpoint,
368
+ callers: map.edges
369
+ .filter((edge) => edge.to === endpoint.id)
370
+ .map((edge) => ({ node: byId.get(edge.from), noToken: edge.noToken })),
371
+ connectors: map.edges
372
+ .filter((edge) => edge.from === endpoint.id)
373
+ .map((edge) => ({ node: byId.get(edge.to), served: edge.served ?? { tools: 0, write: 0 } })),
374
+ }));
375
+ }
@@ -25,7 +25,7 @@ export interface OperatorConnectorOverlay {
25
25
  /** Latest visible call in the bounded activity window, or null when unknown. */
26
26
  lastCall: OperatorLastCall | null;
27
27
  }
28
- /** Whole connector access, or exact tool grants. Guarded grants still require a live read verdict. */
28
+ /** Whole connector access, or exact tool grants. `requireReadOnly` is always false since guarded grants were removed (#837). */
29
29
  export interface OperatorGrant {
30
30
  connectorId: string;
31
31
  tools: "all" | Array<{
@@ -33,41 +33,162 @@ export interface OperatorGrant {
33
33
  requireReadOnly: boolean;
34
34
  }>;
35
35
  }
36
- export interface OperatorPool {
36
+ export type OperatorRole = "maintainer" | "member";
37
+ export type OperatorMember = {
38
+ kind: "human";
39
+ namespace: string;
40
+ id: string;
41
+ label: string | null;
42
+ } | {
43
+ kind: "agent";
37
44
  name: string;
38
- path: string;
39
- /** Whether this pool's endpoint refuses every write. */
45
+ };
46
+ export interface OperatorPoolV2 {
47
+ name: string;
48
+ /** `/mcp/<name>`. */
49
+ humanPath: string;
50
+ /** `/agent/<name>`. */
51
+ agentPath: string;
40
52
  readOnly: boolean;
41
- /** Pool access intersected with the caller's grants. */
42
- grants: OperatorGrant[];
53
+ /** Workspace address agents act as; null in the member view. */
54
+ owner: string | null;
55
+ /** The pool's declared tools, unfiltered. */
56
+ tools: OperatorGrant[];
57
+ /** Maintainer view only; [] for members. */
58
+ members: OperatorMember[];
43
59
  }
44
- export interface OperatorViewer {
45
- interactive: boolean;
46
- grants: OperatorGrant[];
47
- /** Whether the root /mcp endpoint refuses every write. */
60
+ /** Plain /mcp. Maintainer view only; null for members. */
61
+ export interface OperatorRootEndpoint {
62
+ /** `/mcp`. */
63
+ path: string;
48
64
  readOnly: boolean;
49
- pools: OperatorPool[];
50
- permissions: {
51
- activity: boolean;
52
- accessTokenManagement: boolean;
53
- connectors: Array<{
54
- id: string;
55
- use: boolean;
56
- manageSharedAuth: boolean;
57
- connectPersonal: boolean;
58
- }>;
65
+ /** Humans only. */
66
+ maintainers: OperatorMember[];
67
+ }
68
+ /** What a member may see about one connector in their pools. */
69
+ export interface OperatorMemberConnector {
70
+ id: string;
71
+ title: string;
72
+ pools: string[];
73
+ authScope: "shared" | "personal";
74
+ /** Tools their pools grant. */
75
+ tools: Array<{
76
+ address: string;
77
+ classification: "read" | "write";
78
+ }>;
79
+ /** Own connection state for personal connectors; null for shared ones. */
80
+ connection: {
81
+ state: ConnectorStatus["state"];
82
+ problem?: UiProblem;
83
+ } | null;
84
+ /** What the member may do. Always "none" for shared connectors. */
85
+ manage: OperatorMemberManage;
86
+ }
87
+ export type OperatorMemberManage = {
88
+ kind: "none";
89
+ } | {
90
+ kind: "oauth";
91
+ actions: Array<"connect" | "restart" | "disconnect">;
92
+ } | {
93
+ /** /ui/credentials/<id>, the member's own partition. */
94
+ kind: "credential";
95
+ label: string;
96
+ description?: string;
97
+ /** Labels and names, never values. */
98
+ fields?: Array<{
99
+ name: string;
100
+ label: string;
101
+ description?: string;
102
+ }>;
103
+ actions: Array<"save" | "test" | "clear">;
104
+ };
105
+ export interface OperatorAgent {
106
+ name: string;
107
+ pools: string[];
108
+ /**
109
+ * The agent's token slot. `revision` is the same opaque value GET
110
+ * /ui/access-tokens lists for the slot, on every state including "none":
111
+ * it changes on every commit to the slot, and a token mutation sends it as
112
+ * `expect`, so one decided from a stale view refuses with 409 `slot_changed`.
113
+ */
114
+ token: {
115
+ state: "none";
116
+ revision: string;
117
+ } | {
118
+ state: "issued";
119
+ issuedAt: string;
120
+ revision: string;
121
+ } | {
122
+ state: "revoked";
123
+ revokedAt: string;
124
+ revision: string;
59
125
  };
126
+ /** Latest activity row for this agent, null when activity is off or none is visible. */
127
+ lastSeenAt: string | null;
60
128
  }
61
- export interface OperatorUiContract {
62
- schemaVersion: 1;
63
- /** describeConfig's allowlisted snapshot, restricted to this caller's view. */
64
- config: ConnectaConfigDescription;
65
- /** Leaf provenance, built from the same allowlisted snapshot and scoped with it. */
66
- configSources?: Record<string, "default" | "config">;
129
+ /**
130
+ * Where a shared connector's credential comes from:
131
+ * - "vault": a declared operator credential slot that holds a saved value;
132
+ * - "missing": a declared slot, or a shared OAuth connector, with nothing saved yet;
133
+ * - "oauth": a shared OAuth grant, even when the grant is vault-sealed;
134
+ * - "config": supplied by deployment code or environment (literal headers, env values,
135
+ * request-token callbacks, `api()` handlers that hold their own credentials), never shown;
136
+ * - "unauthenticated": the connector declares no auth at all.
137
+ * A token minted per request follows its underlying credential (a GitHub App key from a
138
+ * slot is "vault", from code is "config"). Absence of a slot alone never means
139
+ * "unauthenticated": that needs a connector that declares no auth.
140
+ */
141
+ export type OperatorSharedSource = "vault" | "missing" | "oauth" | "config" | "unauthenticated";
142
+ export type OperatorRunsAs = {
143
+ kind: "shared";
144
+ source: OperatorSharedSource;
145
+ savedAt: string | null;
146
+ } | {
147
+ kind: "personal";
148
+ connectedMembers: number;
149
+ members: number;
150
+ } | {
151
+ kind: "workspace";
152
+ humans: "self";
153
+ agents: string | null;
154
+ } | {
155
+ kind: "workspaceFixed";
156
+ subject: string;
157
+ };
158
+ export interface OperatorConnectorPoolCell {
159
+ connectorId: string;
160
+ /** Null is plain /mcp. */
161
+ pool: string | null;
162
+ /** Connector-relative tool names, as in `OperatorGrant`, not addresses. */
163
+ tools: "all" | string[];
164
+ runsAs: OperatorRunsAs;
165
+ }
166
+ export interface OperatorUiContractV2 {
167
+ schemaVersion: 2;
168
+ role: OperatorRole;
169
+ /** Maintainer only; null for members. */
170
+ config: ConnectaConfigDescription | null;
171
+ /** Leaf provenance for Settings, as in v1. Maintainer only; null for members. */
172
+ configSources: Record<string, "default" | "config"> | null;
67
173
  live: {
174
+ /** Maintainer only; [] for members. */
68
175
  connectors: OperatorConnectorOverlay[];
69
- /** Last-call lookup scans at most 1,000 recent rows. No payload or actor is returned. */
176
+ /** "forbidden" for members. */
70
177
  activity: "available" | "unconfigured" | "forbidden" | "unavailable";
71
178
  };
72
- you: OperatorViewer;
179
+ /** Maintainer only. */
180
+ root: OperatorRootEndpoint | null;
181
+ /** Maintainer: all; member: their pools, owner null, members []. */
182
+ pools: OperatorPoolV2[];
183
+ /** Maintainer only; [] for members. */
184
+ agents: OperatorAgent[];
185
+ /** Maintainer only; [] for members. */
186
+ matrix: OperatorConnectorPoolCell[];
187
+ /** Member only; [] for maintainers, who read live.connectors. */
188
+ connectors: OperatorMemberConnector[];
189
+ you: {
190
+ interactive: boolean;
191
+ };
73
192
  }
193
+ /** The operator contract `GET /ui/api/config` serves; schema version 1 was removed (#837). */
194
+ export type OperatorUiContract = OperatorUiContractV2;