@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
package/dist/registry.js CHANGED
@@ -277,7 +277,7 @@ export class Registry {
277
277
  throw new Error(`Connector "${c.id}" declares both staticTools and a classification; ` + "annotate static tools directly.");
278
278
  }
279
279
  this.connectors.set(c.id, c);
280
- if (c.callAdmission) {
280
+ if (c.callAdmission !== undefined) {
281
281
  this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
282
282
  }
283
283
  }
@@ -343,7 +343,7 @@ export class Registry {
343
343
  const requested = scope.connectorIds === "all" ? new Set(this.connectors.keys()) : new Set(scope.connectorIds);
344
344
  for (const id of requested) {
345
345
  if (!this.connectors.has(id)) {
346
- throw new Error(`Identity access resolver returned unknown connector "${id}"`);
346
+ throw new Error(`Registry scope names unknown connector "${id}"`);
347
347
  }
348
348
  }
349
349
  return new ScopedRegistryView(this, requested, scope);
@@ -368,13 +368,13 @@ export class Registry {
368
368
  this.warnedAbsentGrants.delete(oldest);
369
369
  }
370
370
  if (hasControlCharacters(toolName)) {
371
- logFailure(this.opts.logger, "connectorAccess grant is unreachable", failureRecord({ connector: connectorId }));
371
+ logFailure(this.opts.logger, "pool tool grant is unreachable", failureRecord({ connector: connectorId }));
372
372
  return;
373
373
  }
374
374
  // Grant names are operator data but may carry any non-control character;
375
375
  // quote them so a line terminator a log reader honours cannot forge a line.
376
376
  const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
377
- this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
377
+ this.opts.logger.warn(`a pool grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
378
378
  }
379
379
  async storeOAuthHandoff(connectorId, state, principalKey) {
380
380
  const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
@@ -1005,13 +1005,13 @@ class ScopedRegistryView {
1005
1005
  return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
1006
1006
  }
1007
1007
  canReadConnectorSkills(id) {
1008
- return (this.registryFor(id) !== undefined && !this.scope.toolAccess?.has(id) && !this.scope.guardedToolAccess?.has(id));
1008
+ return this.registryFor(id) !== undefined && !this.scope.toolAccess?.has(id);
1009
1009
  }
1010
1010
  getConnector(id) {
1011
1011
  return this.registryFor(id)?.getConnector(id);
1012
1012
  }
1013
1013
  getResourceConnector(id) {
1014
- if (this.scope.toolAccess?.has(id) || this.scope.guardedToolAccess?.has(id))
1014
+ if (this.scope.toolAccess?.has(id))
1015
1015
  return undefined;
1016
1016
  return this.getConnector(id);
1017
1017
  }
@@ -1028,14 +1028,13 @@ class ScopedRegistryView {
1028
1028
  throw new Error(`Unknown connector "${args[0]}"`);
1029
1029
  }
1030
1030
  const tools = await registry.getTools(args[0], args[1], args[2], args[3], this.scope);
1031
- const guarded = this.scope.guardedToolAccess?.get(args[0]);
1032
- const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
1031
+ const granted = this.scope.toolAccess?.get(args[0]);
1033
1032
  if (!granted)
1034
1033
  return tools;
1035
1034
  // Every consumer — search, describe, call_tool, and a program's
1036
1035
  // connecta.call — resolves through this list, so an ungranted tool is
1037
1036
  // indistinguishable from one the connector never had.
1038
- const visible = tools.filter((tool) => granted.has(tool.name) && (!guarded?.has(tool.name) || tool.classification === "read"));
1037
+ const visible = tools.filter((tool) => granted.has(tool.name));
1039
1038
  if (visible.length < granted.size) {
1040
1039
  const present = new Set(tools.map((tool) => tool.name));
1041
1040
  for (const name of granted) {
@@ -1,62 +1,59 @@
1
- import { authorizeUiIdentity, isSameOrigin, privateJson } from "./shared.js";
2
- async function readName(request) {
3
- if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
4
- return {
5
- ok: false,
6
- response: privateJson({ error: "Content-Type must be application/json" }, { status: 415 }),
7
- };
1
+ import { AccessTokenError, isSlotRevision } from "../access-tokens.js";
2
+ import { authorizeUiIdentity, boundedText, isSameOrigin, managesAgentTokens, privateJson, } from "./shared.js";
3
+ /** The longest body, in characters, a token mutation accepts: `{ "expect": "<revision>" }` with room to spare. */
4
+ const MAX_BODY_CHARS = 1_024;
5
+ /**
6
+ * The slot revision a mutation names. POST takes it only as a JSON body,
7
+ * `{ "expect": "<revision>" }`; DELETE takes that body or, for clients that
8
+ * cannot send a body with DELETE, `?expect=<revision>`, but not both. Read
9
+ * only after the caller is authorized.
10
+ */
11
+ async function expectedRevision(request) {
12
+ const query = new URL(request.url).searchParams.getAll("expect");
13
+ if (query.length > 0 && request.method !== "DELETE") {
14
+ return { status: 400, error: "expect belongs in the JSON body" };
8
15
  }
9
- const reader = request.body?.getReader();
10
- const chunks = [];
11
- let size = 0;
12
- if (reader) {
16
+ const raw = await boundedText(request, MAX_BODY_CHARS);
17
+ if (raw === undefined)
18
+ return { status: 413, error: "request body is too large" };
19
+ let candidate;
20
+ if (raw !== "") {
21
+ if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
22
+ return { status: 415, error: "Content-Type must be application/json" };
23
+ }
24
+ let body;
13
25
  try {
14
- while (true) {
15
- const { done, value } = await reader.read();
16
- if (done)
17
- break;
18
- size += value.byteLength;
19
- if (size > 1_000) {
20
- reader.cancel().catch(() => { });
21
- return { ok: false, response: privateJson({ error: "request body is too large" }, { status: 413 }) };
22
- }
23
- chunks.push(value);
24
- }
26
+ body = JSON.parse(raw);
25
27
  }
26
- finally {
27
- reader.releaseLock();
28
+ catch {
29
+ return { status: 400, error: "invalid JSON body" };
28
30
  }
31
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
32
+ return { status: 400, error: "invalid JSON body" };
33
+ }
34
+ if (query.length > 0)
35
+ return { status: 400, error: "give expect once, in the body or the query" };
36
+ candidate = body.expect;
29
37
  }
30
- const bytes = new Uint8Array(size);
31
- let offset = 0;
32
- for (const chunk of chunks) {
33
- bytes.set(chunk, offset);
34
- offset += chunk.byteLength;
35
- }
36
- const raw = new TextDecoder().decode(bytes);
37
- if (raw.length > 1_000) {
38
- return {
39
- ok: false,
40
- response: privateJson({ error: "request body is too large" }, { status: 413 }),
41
- };
42
- }
43
- try {
44
- const body = JSON.parse(raw);
45
- return { ok: true, name: body.name };
38
+ else if (query.length === 1) {
39
+ candidate = query[0];
46
40
  }
47
- catch {
48
- return {
49
- ok: false,
50
- response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
51
- };
41
+ if (candidate === undefined) {
42
+ return { status: 400, error: "expect is required: the slot revision from GET /ui/access-tokens" };
52
43
  }
44
+ return isSlotRevision(candidate) ? { expect: candidate } : { status: 400, error: "expect is not a slot revision" };
53
45
  }
54
46
  /**
55
- * Interactive-operator lifecycle for deployment access tokens. The token itself
47
+ * Interactive-operator lifecycle for agent tokens: list one slot per declared
48
+ * agent, issue or rotate its token, and revoke it. Each mutation names the
49
+ * slot revision it decided from, so a stale view refuses with 409
50
+ * `slot_changed` instead of issuing over a token or rotating over someone
51
+ * else's fresh rotation. Agents themselves come only
52
+ * from code, so nothing here creates, renames, or rebinds one. The token itself
56
53
  * is deliberately never an administrator credential and cannot reach here.
57
54
  */
58
55
  export async function routeAccessTokens(context, manager) {
59
- const match = /^\/ui\/access-tokens(?:\/([0-9a-f-]{36}))?$/.exec(context.path);
56
+ const match = /^\/ui\/access-tokens(?:\/([a-z0-9_-]{1,64}))?$/.exec(context.path);
60
57
  if (!match)
61
58
  return null;
62
59
  const { request, baseUrl, opts } = context;
@@ -67,41 +64,51 @@ export async function routeAccessTokens(context, manager) {
67
64
  if (mutating && !isSameOrigin(request, baseUrl)) {
68
65
  return privateJson({ error: "same-origin request required" }, { status: 403 });
69
66
  }
70
- const admin = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "access token management", context.runtimeContext, opts.config.identity);
67
+ const admin = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "access token management", context.runtimeContext);
71
68
  if (!admin.ok)
72
69
  return admin.response;
73
- if (!admin.accessTokenManagement || !admin.identity.principal) {
74
- return privateJson({ error: "access token management permission required" }, { status: 403 });
70
+ // Interactive maintainers alone manage agent tokens (#837); anyone else
71
+ // gets the same private 404 every operator route gives them.
72
+ if (!managesAgentTokens(opts, admin) || !admin.identity.principal) {
73
+ return privateJson({ error: "not found" }, { status: 404 });
75
74
  }
76
- const id = match[1];
75
+ const operator = `${admin.identity.principal.namespace}:${admin.identity.principal.id}`;
76
+ const name = match[1];
77
77
  try {
78
- if (!id && request.method === "GET") {
79
- return privateJson({ accessTokens: await manager.list() });
78
+ if (!name && request.method === "GET") {
79
+ return privateJson({ agents: await manager.list() });
80
80
  }
81
- if (!id && request.method === "POST") {
82
- const input = await readName(request);
83
- if (!input.ok)
84
- return input.response;
85
- return privateJson(await manager.create(input.name, admin.identity.principal), { status: 201 });
81
+ if (!name || (request.method !== "POST" && request.method !== "DELETE")) {
82
+ return privateJson({ error: "method not allowed" }, { status: 405 });
86
83
  }
87
- if (id && request.method === "PUT") {
88
- const input = await readName(request);
89
- if (!input.ok)
90
- return input.response;
91
- const accessToken = await manager.rename(id, input.name);
92
- return accessToken
93
- ? privateJson({ accessToken })
94
- : privateJson({ error: "unknown access token" }, { status: 404 });
84
+ const expected = await expectedRevision(request);
85
+ if (!("expect" in expected))
86
+ return privateJson({ error: expected.error }, { status: expected.status });
87
+ // The request's signal bounds each mutation: a caller that leaves before
88
+ // the slot commit publishes nothing, and one that leaves after a rotation
89
+ // commits has the slot revoked, since its secret cannot be delivered.
90
+ const options = { signal: request.signal, expect: expected.expect };
91
+ if (request.method === "POST")
92
+ return privateJson(await manager.rotate(name, operator, options));
93
+ return privateJson({ agent: await manager.revoke(name, options) });
94
+ }
95
+ catch (error) {
96
+ if (error instanceof AccessTokenError && error.code === "unknown_agent") {
97
+ return privateJson({ error: "unknown agent" }, { status: 404 });
95
98
  }
96
- if (id && request.method === "DELETE") {
97
- const accessToken = await manager.revoke(id, `${admin.identity.principal.namespace}:${admin.identity.principal.id}`);
98
- return accessToken
99
- ? privateJson({ accessToken })
100
- : privateJson({ error: "unknown access token" }, { status: 404 });
99
+ if (error instanceof AccessTokenError && error.code === "aborted") {
100
+ return privateJson({ error: "request ended" }, { status: 499 });
101
101
  }
102
- return privateJson({ error: "method not allowed" }, { status: 405 });
103
- }
104
- catch {
105
- return privateJson({ error: "Access token operation failed; check the name, capacity, and storage" }, { status: 400 });
102
+ if (error instanceof AccessTokenError && error.code === "outcome_unknown") {
103
+ // A fixed, operation-specific sentence; it never carries storage text.
104
+ return privateJson({ error: error.message }, { status: 503 });
105
+ }
106
+ if (error instanceof AccessTokenError && error.code === "slot_changed") {
107
+ return privateJson({ error: "The agent's token slot changed; reload and retry", code: "slot_changed" }, { status: 409 });
108
+ }
109
+ if (error instanceof AccessTokenError && error.code === "conflict") {
110
+ return privateJson({ error: "The agent's token changed meanwhile; reload and retry" }, { status: 409 });
111
+ }
112
+ return privateJson({ error: "Access token operation failed; check storage" }, { status: 400 });
106
113
  }
107
114
  }
@@ -1,23 +1,22 @@
1
- import { intersectAccess } from "../connector-access.js";
2
1
  import { recordedToolName } from "../operator-record.js";
3
- /** Historical disclosure uses the verdict at call time; old guarded rows fail closed. */
4
- function activityToolVisible(authz, event) {
5
- const allowed = authz.toolAccess?.get(event.connectorId);
6
- return (recordedToolName({ name: event.toolName }) === event.toolName &&
7
- (!allowed || allowed.has(event.toolName)) &&
8
- (!authz.guardedToolAccess?.get(event.connectorId)?.has(event.toolName) || event.classification === "read"));
2
+ /** A recorded tool is shown only when its name passes the record grammar and its endpoint granted it. */
3
+ function activityToolVisible(access, event) {
4
+ const allowed = access.toolAccess?.get(event.connectorId);
5
+ return recordedToolName({ name: event.toolName }) === event.toolName && (!allowed || allowed.has(event.toolName));
9
6
  }
10
7
  /**
11
- * One disclosure rule for the timeline and the UI's last-call overlay.
12
- * After Activity access and the read gate, operators and activityAccess machine
13
- * callers may see other principals' shared-connector history when connector/tool
14
- * and recorded-pool grants permit, including request IDs, client name/version,
15
- * and actor kind/id/namespace. Interactive reads may add directory actor labels;
16
- * machine reads do not. Personal history requires a matching principal owner.
8
+ * One disclosure rule for the timeline and the UI's last-call overlay. Only
9
+ * maintainers read activity (#837), and only after the optional
10
+ * read gate. They may see other principals' shared-connector history,
11
+ * including request IDs, client name/version, and actor kind/id/namespace, on
12
+ * plain /mcp and in every declared pool, within the tools that pool declares.
13
+ * Interactive reads may add directory actor labels; machine reads do not.
14
+ * Personal history requires a matching principal owner. A row recorded in a
15
+ * pool that is no longer declared is withheld.
17
16
  */
18
- export function activityEventVisible(context, authz, registry, admittedPools, event) {
17
+ export function activityEventVisible(context, authz, registry, event) {
19
18
  const connector = registry.getConnector(event.connectorId);
20
- if (!connector || (event.pool !== undefined && !admittedPools.has(event.pool)))
19
+ if (!connector)
21
20
  return false;
22
21
  if (connector.authScope === "personal" || (event.kind === "catalog_drift" && event.actorBasis === "principal")) {
23
22
  const owner = authz.identity.principal;
@@ -27,10 +26,10 @@ export function activityEventVisible(context, authz, registry, admittedPools, ev
27
26
  event.actor.namespace !== owner.namespace)
28
27
  return false;
29
28
  }
30
- const pool = event.pool ? context.opts.pools.get(event.pool) : undefined;
31
- if (event.pool && !pool)
29
+ const pool = event.pool !== undefined ? context.opts.pools.get(event.pool) : undefined;
30
+ if (event.pool !== undefined && !pool)
32
31
  return false;
33
- const access = pool ? intersectAccess(authz, pool.access) : authz;
32
+ const access = pool ? pool.access : { connectorIds: "all" };
34
33
  if (access.connectorIds !== "all" && !access.connectorIds.includes(event.connectorId))
35
34
  return false;
36
35
  // Connector-wide counts reveal tools outside an address-only grant.
@@ -1,10 +1,10 @@
1
1
  import { activityEventVisible } from "./activity-disclosure.js";
2
- import { Duration, Effect } from "effect";
2
+ import { Effect } from "effect";
3
3
  import { activityBehaviorFacts, activityPackageVersion, activityClientFact, InvalidActivityCursorError, } from "../activity.js";
4
4
  import { failureRecord, logFailure } from "../operator-record.js";
5
- import { ACTOR_LABEL_BUDGET_MS, ACTOR_LABEL_CONCURRENCY, actorKey, cleanActorLabel, labelLookups, } from "./actor-labels.js";
5
+ import { actorKey, resolveActorLabels } from "./actor-labels.js";
6
6
  import { authorized, refuse, serveOperator, visibleRegistry } from "./operator.js";
7
- import { privateJson } from "./shared.js";
7
+ import { privateJson, readsActivity } from "./shared.js";
8
8
  /**
9
9
  * Add display-only actor labels to one authorized activity page. Resolution is
10
10
  * best-effort, bounded, and read-time only: stored events retain stable ids and
@@ -15,50 +15,42 @@ import { privateJson } from "./shared.js";
15
15
  * abandoned, as they are when the reader leaves.
16
16
  */
17
17
  function enrichActivityActorLabels(page, auth) {
18
- return Effect.suspend(() => {
19
- const labels = new Map();
20
- const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
21
- if (label)
22
- labels.set(key, label);
23
- }));
24
- return Effect.forEach(labelLookups(page.events.map((event) => event.actor), auth), resolve, {
25
- concurrency: ACTOR_LABEL_CONCURRENCY,
26
- discard: true,
27
- }).pipe(Effect.timeoutOption(Duration.millis(ACTOR_LABEL_BUDGET_MS)), Effect.map(() => ({
28
- ...page,
29
- events: page.events.map((event) => {
30
- const resolved = event.actor.id ? labels.get(actorKey(event.actor)) : undefined;
31
- // Never trust or echo a `label` supplied by storage. The persisted event
32
- // schema has no label; only this authenticated read path may add one.
33
- const actor = {
34
- kind: event.actor.kind,
35
- ...(event.actor.id ? { id: event.actor.id } : {}),
36
- ...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
37
- };
38
- // Re-check stored client facts, including custom readers and old rows.
39
- const { packageVersion: suppliedPackageVersion, clientName: suppliedName, clientVersion: suppliedVersion, classification: _classification, resultBytes: _resultBytes, kind: _kind, drift: _drift, pool: _pool, actorBasis: _actorBasis, ...record } = event;
40
- const packageVersion = activityPackageVersion(suppliedPackageVersion);
41
- const clientName = activityClientFact(suppliedName, "name");
42
- const clientVersion = activityClientFact(suppliedVersion, "version");
43
- return {
44
- ...record,
45
- ...activityBehaviorFacts(event),
46
- ...(packageVersion !== undefined ? { packageVersion } : {}),
47
- ...(clientName !== undefined ? { clientName } : {}),
48
- ...(clientVersion !== undefined ? { clientVersion } : {}),
49
- actor: resolved ? { ...actor, label: resolved } : actor,
50
- };
51
- }),
52
- })));
53
- });
18
+ return resolveActorLabels(page.events.map((event) => event.actor), auth).pipe(Effect.map((labels) => ({
19
+ ...page,
20
+ events: page.events.map((event) => {
21
+ const resolved = event.actor.id ? labels.get(actorKey(event.actor)) : undefined;
22
+ // Never trust or echo a `label` supplied by storage. The persisted event
23
+ // schema has no label; only this authenticated read path may add one.
24
+ const actor = {
25
+ kind: event.actor.kind,
26
+ ...(event.actor.id ? { id: event.actor.id } : {}),
27
+ ...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
28
+ };
29
+ // Re-check stored client facts, including custom readers and old rows.
30
+ const { packageVersion: suppliedPackageVersion, clientName: suppliedName, clientVersion: suppliedVersion, classification: _classification, resultBytes: _resultBytes, kind: _kind, drift: _drift, pool: _pool, actorBasis: _actorBasis, ...record } = event;
31
+ const packageVersion = activityPackageVersion(suppliedPackageVersion);
32
+ const clientName = activityClientFact(suppliedName, "name");
33
+ const clientVersion = activityClientFact(suppliedVersion, "version");
34
+ return {
35
+ ...record,
36
+ ...activityBehaviorFacts(event),
37
+ ...(packageVersion !== undefined ? { packageVersion } : {}),
38
+ ...(clientName !== undefined ? { clientName } : {}),
39
+ ...(clientVersion !== undefined ? { clientVersion } : {}),
40
+ actor: resolved ? { ...actor, label: resolved } : actor,
41
+ };
42
+ }),
43
+ })));
54
44
  }
55
45
  function activityRead(context) {
56
46
  const { url, opts } = context;
57
47
  return Effect.gen(function* () {
58
48
  const authz = yield* authorized(context);
49
+ // A maintainer principal, interactive or not (#837); members and agents
50
+ // get the private 404 every operator route gives them.
51
+ if (!readsActivity(context.opts, authz))
52
+ return yield* refuse("not found", 404);
59
53
  const registry = yield* visibleRegistry(context, authz);
60
- if (!authz.operator)
61
- return yield* refuse("activity access required", 403);
62
54
  if (opts.config.activity?.readGate &&
63
55
  !(yield* Effect.promise(async () => opts.config.activity?.readGate(authz.actor)))) {
64
56
  return yield* refuse("forbidden", 403);
@@ -66,19 +58,7 @@ function activityRead(context) {
66
58
  const list = opts.config.activity?.store.list?.bind(opts.config.activity?.store);
67
59
  if (!list)
68
60
  return yield* refuse("activity history is not configured", 404);
69
- const admittedPools = new Set();
70
- for (const [name, pool] of opts.pools) {
71
- if (yield* Effect.promise(async () => {
72
- try {
73
- return (await pool.grant(authz.identity)) === true;
74
- }
75
- catch {
76
- return false;
77
- }
78
- }))
79
- admittedPools.add(name);
80
- }
81
- const visible = (event) => activityEventVisible(context, authz, registry, admittedPools, event);
61
+ const visible = (event) => activityEventVisible(context, authz, registry, event);
82
62
  const cursor = url.searchParams.get("cursor") ?? undefined;
83
63
  if (cursor && cursor.length > 500)
84
64
  return yield* refuse("invalid cursor", 400);
@@ -2,11 +2,12 @@
2
2
  // read time from the one auth provider that owns the actor's directory, and
3
3
  // only on an authorized read path. Nothing here is persisted, and a label a
4
4
  // store happens to carry is never trusted or echoed.
5
+ import { Duration, Effect } from "effect";
5
6
  import { activityActorNamespace } from "./shared.js";
6
- export const ACTOR_LABEL_CONCURRENCY = 8;
7
- export const ACTOR_LABEL_BUDGET_MS = 1_500;
7
+ const ACTOR_LABEL_CONCURRENCY = 8;
8
+ const ACTOR_LABEL_BUDGET_MS = 1_500;
8
9
  const ACTOR_LABEL_MAX_LENGTH = 160;
9
- export function cleanActorLabel(value) {
10
+ function cleanActorLabel(value) {
10
11
  if (typeof value !== "string")
11
12
  return undefined;
12
13
  const compact = value.replace(/\s+/gu, " ").trim();
@@ -22,7 +23,7 @@ export function actorKey(actor) {
22
23
  * the actor's directory. An actor no provider can claim unambiguously gets no
23
24
  * lookup at all.
24
25
  */
25
- export function labelLookups(actors, auth) {
26
+ function labelLookups(actors, auth) {
26
27
  const identities = new Map();
27
28
  for (const actor of actors) {
28
29
  if (!actor.id)
@@ -65,3 +66,31 @@ export function labelLookups(actors, auth) {
65
66
  }
66
67
  return lookups;
67
68
  }
69
+ /**
70
+ * Resolve labels for these actors, keyed by `actorKey`. Best-effort and
71
+ * bounded: at most eight lookups run at once, and whatever has resolved
72
+ * within 1.5 seconds in all is used; the rest are abandoned, as they are when
73
+ * the reader leaves. A lookup that fails or answers nothing usable is absent.
74
+ */
75
+ export function resolveActorLabels(actors, auth) {
76
+ return Effect.suspend(() => {
77
+ const labels = new Map();
78
+ const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
79
+ if (label)
80
+ labels.set(key, label);
81
+ }));
82
+ return Effect.forEach(labelLookups(actors, auth), resolve, {
83
+ concurrency: ACTOR_LABEL_CONCURRENCY,
84
+ discard: true,
85
+ }).pipe(Effect.timeoutOption(Duration.millis(ACTOR_LABEL_BUDGET_MS)), Effect.map(() => labels));
86
+ });
87
+ }
88
+ /**
89
+ * The activity actor a listed person would be recorded as: the principal in
90
+ * the namespace of the interactive provider that owns that directory. People
91
+ * no interactive provider owns have no actor, and so no label.
92
+ */
93
+ export function principalActor(principal, auth) {
94
+ const provider = auth.find((candidate) => candidate.interactiveOperator && activityActorNamespace(candidate) === principal.namespace);
95
+ return provider ? { kind: provider.kind, id: principal.id, namespace: principal.namespace } : undefined;
96
+ }
@@ -0,0 +1,43 @@
1
+ // Agent endpoint paths (#837, decisions/0006). Cloudflare Access bypasses
2
+ // `/agent/*` at the edge after resolving dot segments, folding case, decoding
3
+ // unreserved escapes, and collapsing a leading `//`, while the Worker sees
4
+ // the raw pathname. So connecta claims every path Access might treat as
5
+ // `/agent/*`, and admits only the one exact, raw spelling of a pool.
6
+ /** Pool names, as `createConnecta` validates them. */
7
+ const POOL_NAME_RE = /^[a-z0-9_-]+$/;
8
+ /** Escapes decoded before giving up and treating the path as an agent path. */
9
+ const MAX_DECODE_ROUNDS = 8;
10
+ /**
11
+ * The pool an agent endpoint names, or undefined. The raw pathname must be
12
+ * byte-equal to `/agent/` plus a well-formed pool name: no decoding, case
13
+ * folding, slash collapsing, trailing slash, extra segment, or `%`.
14
+ */
15
+ export function agentEndpointPool(path) {
16
+ if (!path.startsWith("/agent/"))
17
+ return undefined;
18
+ const pool = path.slice("/agent/".length);
19
+ return POOL_NAME_RE.test(pool) ? pool : undefined;
20
+ }
21
+ /**
22
+ * Whether a pathname belongs to the agent namespace: anything that starts
23
+ * with `/agent` once every escape is decoded, case is folded, and slashes are
24
+ * collapsed. This is deliberately wider than Access's own matching, so no
25
+ * path the edge bypasses can fall through to a human route.
26
+ */
27
+ export function isAgentPath(path) {
28
+ let value = path;
29
+ for (let round = 0; value.includes("%"); round++) {
30
+ // Still encoded after the bound: refuse it with the agent 404.
31
+ if (round === MAX_DECODE_ROUNDS)
32
+ return true;
33
+ const decoded = value.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
34
+ if (decoded === value)
35
+ break;
36
+ value = decoded;
37
+ }
38
+ return value
39
+ .replace(/\\/g, "/")
40
+ .replace(/\/{2,}/g, "/")
41
+ .toLowerCase()
42
+ .startsWith("/agent");
43
+ }
@@ -8,7 +8,7 @@ import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
8
8
  import { finishOAuthConnectStart, consumeOAuthConnectLink, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
9
9
  import { failureRecord, logFailure } from "../operator-record.js";
10
10
  import { runEdge, withDeadlineEffect } from "../runtime/run.js";
11
- import { authorizeUiIdentity, mayManageConnector, privateJson, validateAuthPermissions, withSessionCookies, } from "./shared.js";
11
+ import { admitsMaintainer, authorizeUiIdentity, mayManageConnector, privateJson, withSessionCookies, } from "./shared.js";
12
12
  const START_TIMEOUT_MS = 30_000;
13
13
  /** Clerk establishes a browser session on this origin before retrying the link. */
14
14
  function clerkSignIn(context) {
@@ -84,33 +84,37 @@ async function connect(context) {
84
84
  headers: { Location: target.href, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" },
85
85
  });
86
86
  };
87
- const authz = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "OAuth connection", runtimeContext, opts.config.identity);
87
+ const authz = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "OAuth connection", runtimeContext);
88
88
  if (!authz.ok) {
89
89
  // Access sign-in is enforced at the edge. Clerk needs its own sign-in page.
90
+ // The sign-in page returns to this link, which then sends a maintainer on
91
+ // to the operator UI. Pool members cannot use that UI yet (#837), so
92
+ // signing in there would strand them.
90
93
  if (authz.response.status === 401 &&
91
94
  authorizationCredential(request).kind === "absent" &&
92
95
  !runtimeContext?.access) {
93
- if (opts.config.ui)
94
- return authPage();
95
96
  const signIn = clerkSignIn(context);
96
97
  if (signIn)
97
98
  return signIn;
99
+ if (opts.config.ui)
100
+ return authPage();
98
101
  }
99
102
  return authz.response;
100
103
  }
101
- try {
102
- validateAuthPermissions(authz, opts.registry);
103
- }
104
- catch {
105
- return refuse("forbidden");
106
- }
107
104
  const connector = opts.registry.getConnector(id);
108
- if (authz.principalKey !== handoff.principal || !mayManageConnector(authz, connector)) {
105
+ // Visibility is the caller's pools, or everything for a maintainer, so a
106
+ // member may connect a personal connector in a pool they belong to.
107
+ if (authz.principalKey !== handoff.principal || !mayManageConnector(opts, authz, connector)) {
109
108
  return refuse("Sign in as the user who requested this connection and has permission to manage it.");
110
109
  }
110
+ // The operator UI serves maintainers only until member views ship (#837).
111
+ // A member's OAuth link starts here directly, as it does without a UI.
112
+ const viaUi = Boolean(opts.config.ui) && admitsMaintainer(opts, authz);
111
113
  if (!connector.startAuth) {
112
114
  if (!connector.credential || !opts.config.ui || !opts.config.vault)
113
115
  return refuse("unknown OAuth connector", 404);
116
+ if (!viaUi)
117
+ return refuse("Credential entry is in the operator UI, which only maintainers can use for now.");
114
118
  let target;
115
119
  try {
116
120
  target = new URL(opts.config.ui.credentialHandoffUrl(baseUrl), baseUrl);
@@ -133,7 +137,7 @@ async function connect(context) {
133
137
  },
134
138
  }), authz.sessionCookies);
135
139
  }
136
- if (opts.config.ui && context.url.searchParams.get("start") !== "1")
140
+ if (viaUi && context.url.searchParams.get("start") !== "1")
137
141
  return withSessionCookies(authPage(), authz.sessionCookies);
138
142
  const registry = opts.registry.scoped({
139
143
  connectorIds: [id],