@zackbart/connecta 0.32.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 (137) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +15 -7
  3. package/bin/connecta.mjs +24 -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/call-admission-context.js +15 -0
  10. package/dist/call-admission.js +30 -43
  11. package/dist/call-recovery.js +6 -2
  12. package/dist/config-value-sources.js +11 -9
  13. package/dist/config.d.ts +37 -75
  14. package/dist/config.js +110 -36
  15. package/dist/connector-access.d.ts +4 -34
  16. package/dist/connector-access.js +15 -99
  17. package/dist/connector-caller.d.ts +7 -1
  18. package/dist/connector-caller.js +3 -2
  19. package/dist/connectors/api-connector.d.ts +27 -4
  20. package/dist/connectors/api-connector.js +2 -0
  21. package/dist/connectors/remote-mcp.js +2 -0
  22. package/dist/describe-config.d.ts +29 -7
  23. package/dist/describe-config.js +7 -17
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.js +29 -10
  26. package/dist/invocation.js +5 -3
  27. package/dist/membership.d.ts +51 -0
  28. package/dist/membership.js +121 -0
  29. package/dist/module-contracts.d.ts +23 -6
  30. package/dist/operator-record.d.ts +2 -2
  31. package/dist/operator-record.js +2 -4
  32. package/dist/operator-ui/agents.d.ts +74 -0
  33. package/dist/operator-ui/agents.js +104 -0
  34. package/dist/operator-ui/contract.d.ts +149 -28
  35. package/dist/operator-ui/endpoints.js +192 -0
  36. package/dist/operator-ui/fix-prompts.js +4 -4
  37. package/dist/operator-ui/generated.js +5 -4
  38. package/dist/operator-ui/member-page.js +112 -0
  39. package/dist/operator-ui/model.d.ts +14 -6
  40. package/dist/operator-ui/view.d.ts +84 -19
  41. package/dist/operator-ui/view.js +82 -21
  42. package/dist/page-styles.js +1 -1
  43. package/dist/provider.d.ts +61 -8
  44. package/dist/provider.js +46 -7
  45. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  46. package/dist/providers/_shared/google/workspace.js +30 -8
  47. package/dist/providers/_shared/rest/tools.js +2 -2
  48. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  49. package/dist/providers/_shared/rest/value-safety.js +699 -7
  50. package/dist/providers/ccb/index.js +1 -1
  51. package/dist/providers/cloudflare/index.js +3 -3
  52. package/dist/providers/cloudflare/rest.js +25 -3
  53. package/dist/providers/cloudflare/skill.generated.js +1 -1
  54. package/dist/providers/cloudflare/value-safety.js +32 -0
  55. package/dist/providers/docs/index.js +3 -3
  56. package/dist/providers/docs/skill.generated.js +2 -2
  57. package/dist/providers/drive/index.js +3 -3
  58. package/dist/providers/drive/skill.generated.js +2 -2
  59. package/dist/providers/forms/index.js +3 -3
  60. package/dist/providers/forms/skill.generated.js +2 -2
  61. package/dist/providers/gmail/index.js +10 -8
  62. package/dist/providers/gmail/skill.generated.js +2 -2
  63. package/dist/providers/mixpanel/index.js +1 -1
  64. package/dist/providers/notion/index.d.ts +7 -0
  65. package/dist/providers/notion/index.js +100 -11
  66. package/dist/providers/notion/rest.js +45 -5
  67. package/dist/providers/notion/skill.generated.js +2 -2
  68. package/dist/providers/notion/value-safety.js +30 -5
  69. package/dist/providers/overflow/index.js +1 -1
  70. package/dist/providers/planning-center/index.js +1 -1
  71. package/dist/providers/sheets/index.d.ts +3 -0
  72. package/dist/providers/sheets/index.js +6 -3
  73. package/dist/providers/sheets/skill.generated.js +2 -2
  74. package/dist/providers/slides/index.js +3 -3
  75. package/dist/providers/slides/skill.generated.js +2 -2
  76. package/dist/providers/stripe/rest.js +9 -2
  77. package/dist/providers/stripe/skill.generated.js +1 -1
  78. package/dist/providers/stripe/value-safety.js +22 -0
  79. package/dist/providers/vercel/rest.js +20 -2
  80. package/dist/providers/vercel/skill.generated.js +1 -1
  81. package/dist/providers/vercel/value-safety.js +22 -0
  82. package/dist/registry.d.ts +2 -3
  83. package/dist/registry.js +8 -9
  84. package/dist/routes/access-tokens.js +82 -75
  85. package/dist/routes/activity-disclosure.js +17 -18
  86. package/dist/routes/activity.js +34 -54
  87. package/dist/routes/actor-labels.js +33 -4
  88. package/dist/routes/agent-path.js +43 -0
  89. package/dist/routes/connect.js +16 -12
  90. package/dist/routes/credentials.js +16 -40
  91. package/dist/routes/mcp.js +145 -95
  92. package/dist/routes/oauth-management.js +11 -5
  93. package/dist/routes/oauth.js +3 -9
  94. package/dist/routes/operator.js +24 -23
  95. package/dist/routes/shared.d.ts +60 -20
  96. package/dist/routes/shared.js +187 -96
  97. package/dist/routes/ui-config.js +437 -97
  98. package/dist/routes/ui-data.js +2 -2
  99. package/dist/routes/ui.js +9 -16
  100. package/dist/runtime/call-admission.js +9 -0
  101. package/dist/server.js +5 -0
  102. package/dist/storage/keys.js +9 -1
  103. package/dist/types.d.ts +11 -0
  104. package/dist/ui.d.ts +1 -1
  105. package/dist/ui.js +16 -14
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/dist/workspace-address.js +9 -0
  109. package/dist/workspace-subject.js +23 -0
  110. package/documentation/README.md +46 -22
  111. package/documentation/architecture.md +355 -50
  112. package/documentation/auth.md +775 -243
  113. package/documentation/code-mode.md +14 -11
  114. package/documentation/deploying.md +121 -24
  115. package/documentation/integrating.md +92 -21
  116. package/documentation/meta-tools.md +14 -9
  117. package/documentation/migration-0.33.md +344 -0
  118. package/documentation/operating.md +3 -1
  119. package/documentation/operator-tests.md +115 -28
  120. package/documentation/operator-ui.md +326 -61
  121. package/documentation/provider-auth-migration.md +37 -23
  122. package/documentation/provider-migration-0.29.md +2 -2
  123. package/examples/worker/AGENTS.md +30 -8
  124. package/examples/worker/README.md +92 -53
  125. package/examples/worker/src/connecta.config.ts +36 -20
  126. package/examples/worker/wrangler.jsonc +3 -0
  127. package/package.json +3 -2
  128. package/templates/node/.env.example +3 -0
  129. package/templates/node/AGENTS.md +27 -6
  130. package/templates/node/Dockerfile +2 -2
  131. package/templates/node/README.md +67 -42
  132. package/templates/node/docker-compose.yml +3 -2
  133. package/templates/node/package.json +4 -3
  134. package/templates/node/src/adopt-token.ts +23 -0
  135. package/templates/node/src/connecta.config.ts +28 -12
  136. package/templates/node/src/rotate-token.ts +17 -0
  137. package/templates/node/src/provision-token.ts +0 -17
package/dist/config.js CHANGED
@@ -12,10 +12,12 @@
12
12
  // reads, with defaults applied once, so no later layer re-derives a value or
13
13
  // re-plumbs a field.
14
14
  import { CONFIG_DEFAULTS as D } from "./config-defaults.js";
15
- import { array, ConfigError, fn, object, opaque, record, required, seconds, readPlain, text, whole, } from "./config-schema.js";
15
+ import { array, ConfigError, object, opaque, record, required, seconds, readPlain, text, whole, } from "./config-schema.js";
16
+ import { assertAgentNames } from "./agent-identity.js";
16
17
  import { assertExecutor } from "./executor-contract.js";
17
18
  import { memoryStorage } from "./storage/memory.js";
18
19
  import { CONNECTA_VERSION } from "./version.js";
20
+ import { isWorkspaceAddress } from "./workspace-address.js";
19
21
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
20
22
  const STORAGE_METHODS = ["get", "set", "delete", "list", "compareAndSet"];
21
23
  const hasMethods = (value, methods) => isObject(value) && methods.every((method) => typeof value[method] === "function");
@@ -175,26 +177,6 @@ const admission = {
175
177
  */
176
178
  code: object(codeAdmission),
177
179
  };
178
- const identity = {
179
- /**
180
- * What this admitted identity may discover and call: `"all"`, or a list
181
- * whose entries are connector ids (the whole connector) and `connector.tool`
182
- * addresses (that tool only), or `{ tool: "connector.tool",
183
- * requireReadOnly: true }` for an exact tool only while its loaded catalog
184
- * explicitly classifies it read-only. Grants are additive: an unrestricted
185
- * connector or address grant wins over the guarded form. An address naming
186
- * a tool the catalog lacks is unreachable and warned once, never widened.
187
- */
188
- connectorAccess: fn(),
189
- /** Global payload-free activity reads. Defaults to interactive humans. */
190
- activityAccess: fn(),
191
- /** Shared credential and OAuth administration. Defaults to none. */
192
- credentialAdministration: fn(),
193
- /** Client-token lifecycle management by interactive humans. Defaults to false. */
194
- accessTokenManagement: fn(),
195
- /** Connecting or changing the caller's personal account. Defaults to none. */
196
- personalConnection: fn(),
197
- };
198
180
  /** The choice every deployment makes explicitly while `trust` migrates (#820). */
199
181
  const READ_ONLY_REQUIRED = (path) => `${path} is required. Choose readOnly: true to refuse every write on /mcp, or readOnly: false to let ` +
200
182
  "call_destructive_tool and programs write with MCP client approval. Pools inherit it unless they set their own.";
@@ -202,6 +184,12 @@ function assertFlag(value, path) {
202
184
  if (typeof value !== "boolean")
203
185
  throw new ConfigError(`${path} must be true or false.`);
204
186
  }
187
+ /** Shape only; each reference resolves against auth and agents in createConnecta. */
188
+ function assertMemberList(value, path) {
189
+ if (!Array.isArray(value)) {
190
+ throw new ConfigError(`${path} must be an array of principal ids, { namespace, id }, or "agent:<name>".`);
191
+ }
192
+ }
205
193
  const pool = {
206
194
  /**
207
195
  * Whether this pool's endpoint refuses every write: write tools are absent
@@ -219,11 +207,26 @@ const pool = {
219
207
  },
220
208
  })),
221
209
  /**
222
- * Whether this admitted identity may open the pool. Denied by default:
223
- * a pool with no grant serves nobody. A false return, a throw, and an
224
- * undeclared pool name are the same 404.
210
+ * Who may open `/mcp/<name>`: principal ids in the namespace of the one
211
+ * interactive auth provider, `{ namespace, id }`, or `"agent:<name>"` for a
212
+ * declared agent. Required and non-empty; the pool serves exactly its
213
+ * `tools` to these members and the same 404 to everyone else.
214
+ */
215
+ members: required(opaque({ check: assertMemberList }), (path) => `${path} is required: list who may open this pool, as principal ids, { namespace, id }, or "agent:<name>".`),
216
+ /**
217
+ * The Google Workspace address agents in this pool act as, on a Workspace
218
+ * connector whose `subject` is a mapping function. Humans always act as
219
+ * themselves through that mapping, and a fixed `subject` address applies to
220
+ * everyone. Without an owner, an agent's Workspace calls fail
221
+ * `auth_required`.
225
222
  */
226
- grant: fn(),
223
+ owner: opaque({
224
+ check: (value, path) => {
225
+ if (typeof value !== "string" || !isWorkspaceAddress(value.trim())) {
226
+ throw new ConfigError(`${path} must be a Google Workspace email address.`);
227
+ }
228
+ },
229
+ }),
227
230
  };
228
231
  const icon = {
229
232
  src: required(text()),
@@ -276,15 +279,26 @@ const connectaConfig = {
276
279
  },
277
280
  resolve: (value) => normalizeAuth(value === undefined ? [] : Array.isArray(value) ? value : [value]),
278
281
  }),
279
- /** Code-derived connection visibility and independent management permissions. */
280
- identity: object(identity),
281
282
  /**
282
- * Whether this admitted identity may open plain `/mcp`. Omitted admits
283
- * everyone; only literal true admits when configured. Other returns and
284
- * throws receive the same 404 as a refused or undeclared pool.
283
+ * Agents, declared by name: lowercase letters, digits, `_` and `-`, each
284
+ * unique. Each has one `cta_` token slot that operators issue, rotate, and
285
+ * revoke; nothing outside code adds, renames, or rebinds an agent. Removing
286
+ * a name stops its token on the next request. Requires `accessTokens`.
287
+ * An admitted agent carries `identity.agent` and has no principal, so
288
+ * personal connectors are absent for it. Defaults to none.
289
+ */
290
+ agents: opaque({
291
+ check: (value, path) => assertAgentNames(value, path),
292
+ resolve: (value) => Object.freeze([...(value ?? [])]),
293
+ }),
294
+ /**
295
+ * The people who may use plain `/mcp`, which serves the whole catalog with
296
+ * the deployment's `readOnly`, and who administer the deployment. Same
297
+ * reference forms as pool `members`, people only. Required with inbound
298
+ * auth; a maintainer opens no pool unless listed among its members.
285
299
  */
286
- grant: fn(),
287
- /** Named tool pools, each served at `/mcp/<name>` to identities its grant admits. */
300
+ maintainers: opaque({ check: assertMemberList }),
301
+ /** Named tool pools, each served at `/mcp/<name>` to its members only. */
288
302
  pools: record(object(pool)),
289
303
  /** Exact connector id -> tool name -> verdict. Unknown names fail at catalog publication. */
290
304
  classification: record(record(opaque({
@@ -364,11 +378,11 @@ const connectaConfig = {
364
378
  }
365
379
  },
366
380
  }),
367
- /** Optional managed client tokens, created by accessTokens() from /auth/access-tokens. */
381
+ /** Optional agent tokens for the declared `agents`, created by accessTokens() from /auth/access-tokens. */
368
382
  accessTokens: opaque({
369
383
  check: (value) => {
370
384
  const auth = isObject(value) ? value.auth : undefined;
371
- if (!hasMethods(value, ["handle"]) ||
385
+ if (!hasMethods(value, ["handle", "slots", "forAgents"]) ||
372
386
  !isObject(auth) ||
373
387
  typeof auth.authorize !== "function" ||
374
388
  auth.interactiveOperator) {
@@ -436,8 +450,46 @@ const connectaConfig = {
436
450
  const CONFIG = object(connectaConfig);
437
451
  /** Pause-only options #672 removed with `resume_execution`. */
438
452
  const RETIRED_PAUSE_OPTIONS = ["ConnectaConfig.execute.resumableWrites", "ConnectaConfig.execute.pausedRunTtlSeconds"];
453
+ /** Access options pools replaced (#837, decisions/0006). */
454
+ const isRetiredPoolGrant = (path) => /^ConnectaConfig\.pools\.[^.]+\.grant$/.test(path);
455
+ /** Management resolvers maintainers and pool membership replaced (#837, decisions/0006). */
456
+ const RETIRED_IDENTITY = [
457
+ ["credentialAdministration", "maintainers administer shared credentials and OAuth grants. List them in maintainers."],
458
+ [
459
+ "personalConnection",
460
+ "pool members connect and disconnect their own personal connectors in their pools, and maintainers their own. Add people to a pool's members.",
461
+ ],
462
+ [
463
+ "accessTokenManagement",
464
+ "maintainers issue, rotate, and revoke agent tokens from an interactive session. List them in maintainers.",
465
+ ],
466
+ ["activityAccess", "maintainers read activity; activity.readGate can still narrow it. List them in maintainers."],
467
+ ];
439
468
  /** `trust`, on the deployment or a pool, was replaced by `readOnly` (#820). */
440
469
  const isRetiredTrust = (path) => path === "ConnectaConfig.trust" || (path.startsWith("ConnectaConfig.pools.") && path.endsWith(".trust"));
470
+ /**
471
+ * `identity` was removed whole (#837), so the walk reports only the section.
472
+ * Name each key it still holds, read by descriptor without running a getter
473
+ * or echoing a value, so a retired resolver's message names its replacement.
474
+ */
475
+ function expandRetiredIdentity(config, paths) {
476
+ if (!paths.includes("ConnectaConfig.identity"))
477
+ return paths;
478
+ let keys = [];
479
+ try {
480
+ const descriptor = Reflect.getOwnPropertyDescriptor(config, "identity");
481
+ const value = descriptor && "value" in descriptor ? descriptor.value : undefined;
482
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
483
+ keys = Reflect.ownKeys(value).filter((key) => typeof key === "string");
484
+ }
485
+ }
486
+ catch {
487
+ keys = [];
488
+ }
489
+ if (keys.length === 0)
490
+ return paths;
491
+ return paths.flatMap((path) => path === "ConnectaConfig.identity" ? keys.map((key) => `ConnectaConfig.identity.${key}`) : [path]);
492
+ }
441
493
  function rejectUnknownOptions(paths) {
442
494
  if (paths.length === 0)
443
495
  return;
@@ -455,6 +507,21 @@ function rejectUnknownOptions(paths) {
455
507
  (paths.some((path) => RETIRED_PAUSE_OPTIONS.includes(path))
456
508
  ? "\nPrograms no longer pause at writes, so there is nothing to configure: writable endpoints let programs and call_destructive_tool write; readOnly endpoints refuse every write (issue #672). Delete the option."
457
509
  : "") +
510
+ (paths.includes("ConnectaConfig.identity.connectorAccess")
511
+ ? "\nidentity.connectorAccess was removed (#837): pools are the access model. List who may use plain /mcp in maintainers, and give each pool its tools and members. A guarded { tool, requireReadOnly: true } grant becomes a pool with readOnly: true."
512
+ : "") +
513
+ RETIRED_IDENTITY.filter(([key]) => paths.includes(`ConnectaConfig.identity.${key}`))
514
+ .map(([key, replacement]) => `\nidentity.${key} was removed (#837): ${replacement}`)
515
+ .join("") +
516
+ (paths.some((path) => path === "ConnectaConfig.identity" || path.startsWith("ConnectaConfig.identity."))
517
+ ? "\nThe identity section was removed (#837): maintainers and pool members decide access and management. Delete it."
518
+ : "") +
519
+ (paths.includes("ConnectaConfig.grant")
520
+ ? "\nThe top-level grant was removed (#837); list the people who may use plain /mcp in maintainers."
521
+ : "") +
522
+ (paths.some(isRetiredPoolGrant)
523
+ ? "\nPool grant was removed (#837); list who may open the pool in its members."
524
+ : "") +
458
525
  (paths.some(isRetiredTrust)
459
526
  ? '\ntrust was removed (#820); set readOnly, which every deployment now chooses explicitly. trust: "read-only" becomes readOnly: true, which now also refuses call_destructive_tool. trust: "trusted" becomes readOnly: false. A pool inherits the deployment readOnly unless it sets its own.'
460
527
  : ""));
@@ -465,6 +532,10 @@ function assertCoherent(config) {
465
532
  config.discovery.catalogMaxTtlSeconds > 86_400) {
466
533
  throw new ConfigError("ConnectaConfig.discovery requires catalogMinTtlSeconds <= catalogMaxTtlSeconds <= 86400.");
467
534
  }
535
+ if (config.agents.length > 0 && !config.accessTokens) {
536
+ throw new ConfigError("ConnectaConfig.agents needs accessTokens: accessTokens(storage) from @zackbart/connecta/auth/access-tokens " +
537
+ "holds each agent's token slot.");
538
+ }
468
539
  }
469
540
  /**
470
541
  * Validate a configuration and apply every default. Unknown keys are refused
@@ -483,7 +554,7 @@ export function resolveConfig(config) {
483
554
  */
484
555
  export function readConfig(config) {
485
556
  const read = readPlain(config, "ConnectaConfig", CONFIG);
486
- rejectUnknownOptions(read.unknown);
557
+ rejectUnknownOptions(expandRetiredIdentity(config, read.unknown));
487
558
  const input = read.value;
488
559
  const parsed = CONFIG.parse(input, "ConnectaConfig");
489
560
  assertCoherent(parsed);
@@ -492,10 +563,13 @@ export function readConfig(config) {
492
563
  name: parsed.serverInfo.name ?? "connecta",
493
564
  version: parsed.serverInfo.version ?? CONNECTA_VERSION,
494
565
  };
566
+ // The token module admits exactly the agents this configuration declares.
567
+ const accessTokens = parsed.accessTokens?.forAgents(parsed.agents);
495
568
  const resolved = Object.freeze({
496
569
  ...parsed,
570
+ accessTokens,
497
571
  // A managed-token module contributes one more provider, ordered with the rest.
498
- auth: parsed.accessTokens ? normalizeAuth([parsed.accessTokens.auth, ...parsed.auth]) : parsed.auth,
572
+ auth: accessTokens ? normalizeAuth([accessTokens.auth, ...parsed.auth]) : parsed.auth,
499
573
  serverInfo: Object.freeze(info),
500
574
  });
501
575
  return { input, resolved };
@@ -1,48 +1,18 @@
1
1
  import type { ToolAccess } from "./registry.js";
2
- import type { AuthenticatedIdentity } from "./types.js";
3
- /** A declared pool after construction-time validation. */
4
- export interface ResolvedPool {
5
- /** Resolved: the pool's own `readOnly`, else the deployment's. */
6
- readOnly: boolean;
7
- access: ConnectorAccess;
8
- grant(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
9
- }
10
2
  export declare const POOL_NAME_RE: RegExp;
11
3
  /**
12
4
  * One derived view: which connectors, and for connectors granted by address
13
5
  * only, which tools. A connector absent from `toolAccess` is visible whole.
14
- * `guardedToolAccess` is a subset of `toolAccess` checked against the loaded
15
- * catalog each time tools are discovered or invoked.
16
6
  */
17
7
  export interface ConnectorAccess {
18
8
  connectorIds: "all" | readonly string[];
19
9
  toolAccess?: ToolAccess;
20
- /** Exact tool grants that also require a current explicit read-only annotation. */
21
- guardedToolAccess?: ToolAccess;
22
- }
23
- interface ReadOnlyToolGrant {
24
- tool: string;
25
- requireReadOnly: true;
26
10
  }
27
- export type ConnectorGrant = string | ReadOnlyToolGrant;
28
11
  /**
29
- * Normalize a grant list. A bare connector id grants every tool on that
12
+ * Normalize a pool's `tools`. A bare connector id grants every tool on that
30
13
  * connector; a `connector.tool` address grants one tool. Grants are additive,
31
14
  * so a bare id beside addresses for the same connector means the whole
32
- * connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
33
- * for a fixed address that also needs a current explicit read-only annotation;
34
- * pool declarations remain strings. Anything else throws, and the caller
35
- * decides whether that is a construction failure or a 403: a grant that
36
- * cannot be parsed must never fail open.
37
- */
38
- export declare function parseConnectorAccess(value: unknown, options?: {
39
- allowReadOnly?: boolean;
40
- }): ConnectorAccess;
41
- /**
42
- * The view a pool endpoint serves: the pool's grants, never wider than the
43
- * identity's own. A connector or tool outside either side is gone; a
44
- * connector whose tool intersection is empty is gone too, so the pool can
45
- * only narrow what the identity resolver already allowed.
15
+ * connector. Anything else throws: a grant that cannot be parsed must never
16
+ * fail open.
46
17
  */
47
- export declare function intersectAccess(ceiling: ConnectorAccess, pool: ConnectorAccess): ConnectorAccess;
48
- export {};
18
+ export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
@@ -2,121 +2,37 @@ import { hasControlCharacters } from "./tool-name.js";
2
2
  export const POOL_NAME_RE = /^[a-z0-9_-]+$/;
3
3
  const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
4
4
  // MCP does not restrict tool names, and remote servers ship spaced and
5
- // non-ASCII ones. Only control characters are refused, so a grant for a
6
- // legitimately named tool cannot 403 the whole identity at request time.
5
+ // non-ASCII ones. Only control characters are refused, so a pool naming a
6
+ // legitimately named tool still constructs.
7
7
  const TOOL_ADDRESS_RE = /^[a-z0-9_-]+\..{1,256}$/su;
8
8
  /**
9
- * Normalize a grant list. A bare connector id grants every tool on that
9
+ * Normalize a pool's `tools`. A bare connector id grants every tool on that
10
10
  * connector; a `connector.tool` address grants one tool. Grants are additive,
11
11
  * so a bare id beside addresses for the same connector means the whole
12
- * connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
13
- * for a fixed address that also needs a current explicit read-only annotation;
14
- * pool declarations remain strings. Anything else throws, and the caller
15
- * decides whether that is a construction failure or a 403: a grant that
16
- * cannot be parsed must never fail open.
12
+ * connector. Anything else throws: a grant that cannot be parsed must never
13
+ * fail open.
17
14
  */
18
- export function parseConnectorAccess(value, options = {}) {
19
- if (value === "all")
20
- return { connectorIds: "all" };
15
+ export function parseConnectorAccess(value) {
21
16
  if (!Array.isArray(value))
22
17
  throw new Error("invalid connector permission");
23
18
  const whole = new Set();
24
- const plain = new Map();
25
- const guarded = new Map();
26
- for (const entry of value) {
27
- if (typeof entry === "string" && CONNECTOR_ID_RE.test(entry)) {
28
- whole.add(entry);
19
+ const partial = new Map();
20
+ for (const address of value) {
21
+ if (typeof address === "string" && CONNECTOR_ID_RE.test(address)) {
22
+ whole.add(address);
29
23
  continue;
30
24
  }
31
- const isGuarded = typeof entry !== "string";
32
- if (isGuarded &&
33
- (!options.allowReadOnly ||
34
- entry === null ||
35
- typeof entry !== "object" ||
36
- Array.isArray(entry) ||
37
- !Object.hasOwn(entry, "tool") ||
38
- !Object.hasOwn(entry, "requireReadOnly") ||
39
- Object.keys(entry).length !== 2 ||
40
- entry.requireReadOnly !== true)) {
41
- throw new Error("invalid connector permission");
42
- }
43
- const address = isGuarded ? entry.tool : entry;
44
25
  if (typeof address !== "string" || !TOOL_ADDRESS_RE.test(address) || hasControlCharacters(address)) {
45
26
  throw new Error("invalid connector permission");
46
27
  }
47
28
  const dot = address.indexOf(".");
48
29
  const connectorId = address.slice(0, dot);
49
- const target = isGuarded ? guarded : plain;
50
- const tools = target.get(connectorId) ?? new Set();
30
+ const tools = partial.get(connectorId) ?? new Set();
51
31
  tools.add(address.slice(dot + 1));
52
- target.set(connectorId, tools);
53
- }
54
- for (const id of whole) {
55
- plain.delete(id);
56
- guarded.delete(id);
57
- }
58
- for (const [id, names] of plain) {
59
- const restricted = guarded.get(id);
60
- if (!restricted)
61
- continue;
62
- for (const name of names)
63
- restricted.delete(name);
64
- if (restricted.size === 0)
65
- guarded.delete(id);
66
- }
67
- const partial = new Map(plain);
68
- for (const [id, names] of guarded) {
69
- const combined = partial.get(id) ?? new Set();
70
- for (const name of names)
71
- combined.add(name);
72
- partial.set(id, combined);
32
+ partial.set(connectorId, tools);
73
33
  }
34
+ for (const id of whole)
35
+ partial.delete(id);
74
36
  const connectorIds = [...new Set([...whole, ...partial.keys()])];
75
- return {
76
- connectorIds,
77
- ...(partial.size > 0 ? { toolAccess: partial } : {}),
78
- ...(guarded.size > 0 ? { guardedToolAccess: guarded } : {}),
79
- };
80
- }
81
- /**
82
- * The view a pool endpoint serves: the pool's grants, never wider than the
83
- * identity's own. A connector or tool outside either side is gone; a
84
- * connector whose tool intersection is empty is gone too, so the pool can
85
- * only narrow what the identity resolver already allowed.
86
- */
87
- export function intersectAccess(ceiling, pool) {
88
- if (pool.connectorIds === "all")
89
- return ceiling;
90
- const allowedIds = ceiling.connectorIds === "all" ? null : new Set(ceiling.connectorIds);
91
- const connectorIds = [];
92
- const toolAccess = new Map();
93
- const guardedToolAccess = new Map();
94
- for (const id of pool.connectorIds) {
95
- if (allowedIds && !allowedIds.has(id))
96
- continue;
97
- const fromPool = pool.toolAccess?.get(id);
98
- const fromCeiling = ceiling.toolAccess?.get(id);
99
- if (fromPool && fromCeiling) {
100
- const both = new Set([...fromPool].filter((name) => fromCeiling.has(name)));
101
- if (both.size === 0)
102
- continue;
103
- toolAccess.set(id, both);
104
- }
105
- else if (fromPool ?? fromCeiling) {
106
- toolAccess.set(id, (fromPool ?? fromCeiling));
107
- }
108
- const guarded = ceiling.guardedToolAccess?.get(id);
109
- if (guarded) {
110
- const allowed = toolAccess.get(id);
111
- const retained = allowed ? new Set([...guarded].filter((name) => allowed.has(name))) : guarded;
112
- if (retained.size > 0)
113
- guardedToolAccess.set(id, retained);
114
- }
115
- connectorIds.push(id);
116
- }
117
- return {
118
- connectorIds,
119
- ...(toolAccess.size > 0 ? { toolAccess } : {}),
120
- ...(guardedToolAccess.size > 0 ? { guardedToolAccess } : {}),
121
- };
37
+ return { connectorIds, ...(partial.size > 0 ? { toolAccess: partial } : {}) };
122
38
  }
@@ -9,8 +9,14 @@ export interface ConnectorCaller {
9
9
  * with or without a subject, a Connecta-issued access token.
10
10
  */
11
11
  authenticated: boolean;
12
- /** The `/mcp/<pool>` the call arrived on, when it was a pool. */
12
+ /** The pool the call arrived on, at `/mcp/<pool>` or `/agent/<pool>`. */
13
13
  pool?: string;
14
+ /**
15
+ * That pool's configured `owner`, the Workspace address an agent acts as
16
+ * (decisions/0006). Core copies it from the admitted pool's config; no
17
+ * request can set it, and humans never act as it.
18
+ */
19
+ poolOwner?: string;
14
20
  }
15
21
  export declare function attachCaller(ctx: ConnectorContext, caller: ConnectorCaller | undefined): ConnectorContext;
16
22
  export declare function callerOf(ctx: ConnectorContext): ConnectorCaller | undefined;
@@ -8,8 +8,9 @@
8
8
  // - maintained providers that act *as* the caller downstream — Google
9
9
  // Workspace through domain-wide delegation (`src/providers/_shared/google/`) — which
10
10
  // hand the identity to a deployment-config function that names the
11
- // downstream subject (PRINCIPLES.md INV-3, #678). The
12
- // provider never decides whose account to open; config does, from this.
11
+ // downstream subject (PRINCIPLES.md INV-3, #678), or, for an agent, use the
12
+ // admitted pool's configured owner (decisions/0006). The provider never
13
+ // decides whose account to open; config does, from this.
13
14
  //
14
15
  // Core sets it from the authorization that admitted the request, on the scoped
15
16
  // registry view that request receives, and `api()` hands handlers that same
@@ -146,10 +146,33 @@ export interface ApiOptions {
146
146
  /** The closed options api() accepts; see `assertKnownOptions`. */
147
147
  export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, unknown, true> & {
148
148
  readonly shape: {
149
- callAdmission: import("../config-schema.js").Field<unknown, unknown, true> & {
150
- readonly shape: {
151
- rules: import("../config-schema.js").Field<unknown[], readonly unknown[] | undefined, true>;
152
- maxPartitions: import("../config-schema.js").Field<unknown, unknown, true>;
149
+ callAdmission: Omit<import("../config-schema.js").Field<unknown, ConnectorCallAdmissionPolicy | undefined, true>, "parse"> & {
150
+ parse(value: unknown, path: string): ConnectorCallAdmissionPolicy;
151
+ readonly shape: import("../config-schema.js").Shape & {
152
+ maxPartitions: import("../config-schema.js").Field<number, number | undefined, true>;
153
+ rules: import("../config-schema.js").Field<{
154
+ maxConcurrency?: number | undefined;
155
+ maxQueueSize?: number | undefined;
156
+ queueTimeoutMs?: number | undefined;
157
+ retryAfterMs?: number | undefined;
158
+ partitionKey?: ((input: Readonly<import("../types.js").ConnectorCallAdmissionInput>) => string) | undefined;
159
+ budget?: {
160
+ kind: "rolling-window";
161
+ maxCalls: number;
162
+ windowMs: number;
163
+ } | undefined;
164
+ }[], readonly {
165
+ readonly maxConcurrency: number | undefined;
166
+ readonly maxQueueSize: number | undefined;
167
+ readonly queueTimeoutMs: number | undefined;
168
+ readonly retryAfterMs: number | undefined;
169
+ readonly partitionKey: ((input: Readonly<import("../types.js").ConnectorCallAdmissionInput>) => string) | undefined;
170
+ readonly budget: {
171
+ readonly kind: "rolling-window";
172
+ readonly maxCalls: number;
173
+ readonly windowMs: number;
174
+ } | undefined;
175
+ }[], false>;
153
176
  };
154
177
  };
155
178
  usageGuide: import("../config-schema.js").Field<unknown, unknown, true> & {
@@ -90,6 +90,8 @@ function describedApiAuth(opts) {
90
90
  */
91
91
  export function apiConnector(id, opts, oauth) {
92
92
  opts = assertKnownOptions(opts, `api(${JSON.stringify(id)})`, API_OPTIONS);
93
+ if (opts.callAdmission !== undefined)
94
+ CALL_ADMISSION.parse(opts.callAdmission, `api(${JSON.stringify(id)}).callAdmission`);
93
95
  assertStaticToolNames(opts.tools, `api(${JSON.stringify(id)}).tools`);
94
96
  if (opts.oauth !== undefined && oauth === undefined) {
95
97
  throw new Error(`api() connector "${id}" declares oauth but was built without its grant; construct it with api().`);
@@ -1024,6 +1024,8 @@ function registrationErrorCode(body) {
1024
1024
  */
1025
1025
  export function remoteMcp(id, opts) {
1026
1026
  opts = assertKnownOptions(opts, `remoteMcp(${JSON.stringify(id)})`, REMOTE_MCP_OPTIONS);
1027
+ if (opts.callAdmission !== undefined)
1028
+ CALL_ADMISSION.parse(opts.callAdmission, `remoteMcp(${JSON.stringify(id)}).callAdmission`);
1027
1029
  const skillsEnabled = opts.skills;
1028
1030
  if (skillsEnabled !== undefined && typeof skillsEnabled !== "boolean") {
1029
1031
  throw new Error(`[connecta] connector "${id}" skills must be a boolean.`);
@@ -1,6 +1,7 @@
1
1
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
2
2
  import type { ConnectaConfig, ResolvedConfig } from "./config.js";
3
- import type { ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, DescribedEndpoint } from "./types.js";
3
+ import type { ResolvedPool } from "./membership.js";
4
+ import type { ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, DescribedEndpoint, IdentityReference } from "./types.js";
4
5
  /** Where a limit's value came from. */
5
6
  export type ConfigValueSource = "default" | "config";
6
7
  /** One limit and where its value came from. `null` means unset (no bound). */
@@ -8,6 +9,15 @@ export interface DescribedLimit<T = number> {
8
9
  value: T | null;
9
10
  source: ConfigValueSource;
10
11
  }
12
+ /** A resolved pool member: a principal, or a declared agent by name. */
13
+ export type DescribedMember = {
14
+ kind: "human";
15
+ namespace: string;
16
+ id: string;
17
+ } | {
18
+ kind: "agent";
19
+ name: string;
20
+ };
11
21
  /** One connector as `describeConfig()` reports it. */
12
22
  export interface DescribedConnector {
13
23
  id: string;
@@ -88,8 +98,11 @@ export interface ConnectaConfigDescription {
88
98
  };
89
99
  /** Whether the default /mcp endpoint refuses every write, and exact classification overrides from deployment code. */
90
100
  readOnly: boolean;
91
- /** Whether deployment code configures a grant for plain `/mcp`. */
92
- hasGrant: boolean;
101
+ /** The principals plain `/mcp` admits, resolved, in declaration order. Empty on an open deployment. */
102
+ maintainers: Array<{
103
+ namespace: string;
104
+ id: string;
105
+ }>;
93
106
  classification: Record<string, Record<string, "read" | "write">>;
94
107
  /** Inbound auth providers in the order they are tried. */
95
108
  auth: Array<{
@@ -97,14 +110,20 @@ export interface ConnectaConfigDescription {
97
110
  interactive: boolean;
98
111
  ui?: string;
99
112
  }>;
100
- identity: Record<"connectorAccess" | "activityAccess" | "credentialAdministration" | "accessTokenManagement" | "personalConnection", "default" | "custom">;
101
- /** `readOnly` is resolved: the pool's own value, else the deployment's. */
113
+ /** Agents declared in code, by name, in declaration order. Never token state. */
114
+ agents: string[];
115
+ /**
116
+ * `readOnly` is resolved: the pool's own value, else the deployment's.
117
+ * `members` are resolved references in declaration order. `owner` is the
118
+ * Workspace address agents act as, or null.
119
+ */
102
120
  pools: Array<{
103
121
  name: string;
104
122
  path: string;
105
123
  tools: string[];
106
- hasGrant: boolean;
124
+ members: DescribedMember[];
107
125
  readOnly: boolean;
126
+ owner: string | null;
108
127
  }>;
109
128
  modules: {
110
129
  ui: {
@@ -125,7 +144,6 @@ export interface ConnectaConfigDescription {
125
144
  };
126
145
  accessTokens: {
127
146
  enabled: boolean;
128
- maxActive?: number;
129
147
  };
130
148
  };
131
149
  /** Every accepted store implements `list` and `compareAndSet`, so only its kind is told. */
@@ -157,6 +175,10 @@ export interface DescribeConfigInput {
157
175
  registry: import("./registry.js").Registry;
158
176
  raw: ConnectaConfig;
159
177
  config: ResolvedConfig;
178
+ /** Resolved `maintainers`. */
179
+ maintainers: readonly IdentityReference[];
180
+ /** Resolved pools; members are read from here, never from raw references. */
181
+ pools: ReadonlyMap<string, ResolvedPool>;
160
182
  executorName: string | undefined;
161
183
  /** True when the executor owns admission, so `admission.code` is unused. */
162
184
  executorAdmits: boolean;