@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
@@ -183,13 +183,6 @@ function describeConnector(connector, deploymentCap, registry) {
183
183
  ...(connector.staticTools ? { tools: describedTools(registry.describeStaticTools(connector.id) ?? []) } : {}),
184
184
  };
185
185
  }
186
- const IDENTITY_KEYS = [
187
- "connectorAccess",
188
- "activityAccess",
189
- "credentialAdministration",
190
- "accessTokenManagement",
191
- "personalConnection",
192
- ];
193
186
  const STORE_KINDS = new Set(["memory", "d1", "sqlite"]);
194
187
  const storeKind = (described) => typeof described?.kind === "string" && STORE_KINDS.has(described.kind)
195
188
  ? described.kind
@@ -198,7 +191,6 @@ const storeKind = (described) => typeof described?.kind === "string" && STORE_KI
198
191
  export function describeConfig(input) {
199
192
  const { raw, config } = input;
200
193
  const brand = resolveBranding(config.ui?.branding);
201
- const accessTokens = config.accessTokens?.describe?.();
202
194
  const storage = config.storage;
203
195
  const activityStore = config.activity?.store.describe?.();
204
196
  const D = CONFIG_DEFAULTS;
@@ -241,20 +233,23 @@ export function describeConfig(input) {
241
233
  requests: limitGroup(config.admission.requests, ownValue(ownValue(raw, "admission"), "requests"), Object.keys(D.admission.requests)),
242
234
  },
243
235
  readOnly: config.readOnly,
244
- hasGrant: typeof config.grant === "function",
236
+ maintainers: input.maintainers.map(({ namespace, id }) => ({ namespace: String(namespace), id: String(id) })),
245
237
  classification: structuredClone(config.classification ?? {}),
246
238
  auth: config.auth.map((provider) => ({
247
239
  kind: String(provider.kind),
248
240
  interactive: provider.interactiveOperator === true,
249
241
  ...(str(provider.uiAuth?.kind) !== undefined ? { ui: provider.uiAuth.kind } : {}),
250
242
  })),
251
- identity: Object.fromEntries(IDENTITY_KEYS.map((key) => [key, typeof config.identity[key] === "function" ? "custom" : "default"])),
243
+ agents: [...config.agents],
252
244
  pools: Object.entries(config.pools ?? {}).map(([name, pool]) => ({
253
245
  readOnly: pool.readOnly ?? config.readOnly,
254
246
  name,
255
247
  path: `/mcp/${name}`,
256
248
  tools: pool.tools.map(String),
257
- hasGrant: typeof pool.grant === "function",
249
+ members: (input.pools.get(name)?.members ?? []).map((member) => member.kind === "agent"
250
+ ? { kind: "agent", name: member.name }
251
+ : { kind: "human", namespace: member.namespace, id: member.id }),
252
+ owner: input.pools.get(name)?.owner ?? null,
258
253
  })),
259
254
  modules: {
260
255
  ui: { enabled: config.ui !== undefined },
@@ -277,12 +272,7 @@ export function describeConfig(input) {
277
272
  },
278
273
  }
279
274
  : { enabled: false },
280
- accessTokens: config.accessTokens
281
- ? {
282
- enabled: true,
283
- ...(num(accessTokens?.maxActive) !== undefined ? { maxActive: accessTokens.maxActive } : {}),
284
- }
285
- : { enabled: false },
275
+ accessTokens: { enabled: config.accessTokens !== undefined },
286
276
  },
287
277
  storage: {
288
278
  configured: raw.storage !== undefined,
package/dist/index.d.ts CHANGED
@@ -5,8 +5,9 @@ export { customExecutor, type CustomExecutorOptions } from "./executor-contract.
5
5
  export type { AccessTokensModule, ActivityModule, OperatorSurface } from "./module-contracts.js";
6
6
  export type { CredentialVault, CredentialMetadata } from "./credential-contract.js";
7
7
  export { defineConfig } from "./config.js";
8
- export type { AdmissionPoolConfig, ConnectaAdmissionConfig, ConnectaCallsConfig, ConnectaConfig, ConnectaDiscoveryConfig, ConnectaExecuteConfig, ConnectaIdentityConfig, ConnectaPoolConfig, ConnectaResultsConfig, ConnectorPermission, RequestAdmissionConfig, } from "./config.js";
9
- export type { ConfigValueSource, ConnectaConfigDescription, DescribedConnector, DescribedLimit, } from "./describe-config.js";
8
+ export type { AdmissionPoolConfig, ConnectaAdmissionConfig, ConnectaCallsConfig, ConnectaConfig, ConnectaDiscoveryConfig, ConnectaExecuteConfig, ConnectaPoolConfig, ConnectaResultsConfig, RequestAdmissionConfig, } from "./config.js";
9
+ export type { MemberRef } from "./membership.js";
10
+ export type { ConfigValueSource, ConnectaConfigDescription, DescribedConnector, DescribedMember, DescribedLimit, } from "./describe-config.js";
10
11
  export interface Connecta {
11
12
  /** Web-standard fetch handler. Usable as `export default { fetch: connecta.fetch }`. */
12
13
  fetch: (request: Request, env?: unknown, ctx?: unknown) => Promise<Response>;
@@ -14,7 +15,7 @@ export interface Connecta {
14
15
  /**
15
16
  * A secret-free snapshot of the configuration this deployment runs with,
16
17
  * built once at construction: limits and where each came from, auth
17
- * providers, identity rules, pools, modules, and every connector's source,
18
+ * providers, maintainers, agents, pools, modules, and every connector's source,
18
19
  * endpoint, auth mode, and static tools. Header values, keys, client
19
20
  * secrets, credentials, and functions never appear. The same object is
20
21
  * returned on every call.
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@ import { describeConfigSources } from "./config-value-sources.js";
2
2
  import { credentialTestRule, describeCredentialTestMismatch } from "./credential-rules.js";
3
3
  import { Registry } from "./registry.js";
4
4
  import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
5
+ import { assertsAgents } from "./agent-identity.js";
6
+ import { ConfigError } from "./config-schema.js";
7
+ import { memberContext, resolveMaintainers, resolveMembers, } from "./membership.js";
5
8
  import { createFetchHandler } from "./server.js";
6
9
  import { droppedBrandingUrls, droppedThemeTokens, droppedUiAuthUrls } from "./branding.js";
7
10
  import { readConfig } from "./config.js";
@@ -10,19 +13,21 @@ export { customExecutor } from "./executor-contract.js";
10
13
  import { AdmissionController, executorName, isAdmittingExecutor, withExecutorAdmission } from "./executor-admission.js";
11
14
  export { defineConfig } from "./config.js";
12
15
  /**
13
- * Validate declared pools against the connector set. Everything checkable at
14
- * construction throws here: a malformed name, an unparseable grant, an
15
- * unknown connector id, a tool address on an `api()` connector whose static
16
- * catalog lacks it. Remote catalogs load lazily, so their addresses are
17
- * checked at catalog load instead and stay unreachable until they match.
16
+ * Validate declared pools against the connector set and the members against
17
+ * auth and agents. Everything checkable at construction throws here: a
18
+ * malformed name, an unparseable grant, an unknown connector id, a tool
19
+ * address on an `api()` connector whose static catalog lacks it, and an empty,
20
+ * ambiguous, unknown, or repeated member. Remote catalogs load lazily, so
21
+ * their addresses are checked at catalog load instead and stay unreachable
22
+ * until they match.
18
23
  */
19
- function resolvePools(pools, readOnly, registry) {
24
+ function resolvePools(pools, readOnly, registry, members) {
20
25
  const resolved = new Map();
21
26
  if (!pools)
22
27
  return resolved;
23
28
  // The schema has already refused a pool that is not an object, a missing or
24
- // non-array `tools`, a non-function `grant`, and a misspelled key — a
25
- // misspelled `grant` would otherwise boot as a deny-all pool.
29
+ // non-array `tools` or `members`, and a misspelled key; the retired `grant`
30
+ // is refused there with its replacement named.
26
31
  for (const [name, pool] of Object.entries(pools)) {
27
32
  if (!POOL_NAME_RE.test(name)) {
28
33
  throw new Error(`ConnectaConfig.pools: pool name "${name}" must match [a-z0-9_-]+`);
@@ -52,8 +57,17 @@ function resolvePools(pools, readOnly, registry) {
52
57
  }
53
58
  }
54
59
  }
60
+ const path = `ConnectaConfig.pools.${name}.members`;
61
+ if (pool.members.length === 0) {
62
+ throw new ConfigError(`${path} is empty; a pool serves only its members, so list at least one.`);
63
+ }
55
64
  // A pool inherits the deployment value unless it sets its own, either way.
56
- resolved.set(name, { access, readOnly: pool.readOnly ?? readOnly, grant: pool.grant ?? (() => false) });
65
+ resolved.set(name, Object.freeze({
66
+ access,
67
+ readOnly: pool.readOnly ?? readOnly,
68
+ members: Object.freeze(resolveMembers(pool.members, path, members)),
69
+ ...(pool.owner !== undefined ? { owner: pool.owner.trim() } : {}),
70
+ }));
57
71
  }
58
72
  return resolved;
59
73
  }
@@ -191,7 +205,9 @@ export function createConnecta(config) {
191
205
  results: resolved.results,
192
206
  classification: resolved.classification,
193
207
  });
194
- const pools = resolvePools(resolved.pools, resolved.readOnly, registry);
208
+ const members = memberContext(resolved.auth, resolved.agents);
209
+ const maintainers = resolveMaintainers(input.maintainers, resolved.auth.some((provider) => !assertsAgents(provider)), members);
210
+ const pools = resolvePools(resolved.pools, resolved.readOnly, registry, members);
195
211
  warnInsecureConfig(resolved);
196
212
  const requestAdmission = new AdmissionController(resolved.admission.requests);
197
213
  let codeAdmission;
@@ -214,6 +230,8 @@ export function createConnecta(config) {
214
230
  registry,
215
231
  raw: input,
216
232
  config: resolved,
233
+ maintainers,
234
+ pools,
217
235
  executorName: configuredExecutorName,
218
236
  executorAdmits,
219
237
  });
@@ -222,6 +240,7 @@ export function createConnecta(config) {
222
240
  configDescription: description,
223
241
  configValueSources: describeConfigSources(description, input),
224
242
  registry,
243
+ maintainers,
225
244
  pools,
226
245
  executor,
227
246
  executorName: configuredExecutorName,
@@ -3,6 +3,7 @@ import { hasControlCharacters } from "./tool-name.js";
3
3
  import { writeRefusal } from "./tool-safety.js";
4
4
  import { Cause, Effect, Exit } from "effect";
5
5
  import { isCallAdmissionError } from "./call-admission.js";
6
+ import { attachCallAdmission } from "./call-admission-context.js";
6
7
  import { boundedEchoText, classificationCode, classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
7
8
  import { downstreamValue } from "./mcp-result.js";
8
9
  import { carryFailureFacts, classifiedFailure, failureRecord, logFailure, recordedToolName, } from "./operator-record.js";
@@ -494,8 +495,9 @@ export class InvocationService {
494
495
  if (callSignal?.aborted)
495
496
  return yield* Effect.fail(callSignal.reason);
496
497
  attempts = 1;
497
- const call = async () => {
498
+ const call = async (permit) => {
498
499
  const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs, defer: this.catalog.defer }));
500
+ attachCallAdmission(connectorContext, permit);
499
501
  trackCredentialReads(connectorContext);
500
502
  sentSecrets.include(sentSecretsFor(connectorContext));
501
503
  dispatchContext = connectorContext;
@@ -537,14 +539,14 @@ export class InvocationService {
537
539
  // cannot hold its permit past the deadline.
538
540
  const attempt = yield* Effect.exit(Effect.scoped(Effect.gen({ self: this }, function* () {
539
541
  stage = "admission";
540
- yield* timed((elapsed) => {
542
+ const permit = yield* timed((elapsed) => {
541
543
  admissionMs += elapsed;
542
544
  }, admitted(this.registry, target, args, admissionSignal));
543
545
  stage = "downstream";
544
546
  const reply = yield* timed((elapsed) => {
545
547
  connectorMs += elapsed;
546
548
  }, Effect.tryPromise({
547
- try: () => Promise.resolve(call()),
549
+ try: () => Promise.resolve(call(permit)),
548
550
  catch: (error) => error,
549
551
  }));
550
552
  if (target.connector.kind === "mcp" && isDownstreamInputResult(reply)) {
@@ -0,0 +1,51 @@
1
+ import type { ConnectorAccess } from "./connector-access.js";
2
+ import type { AuthenticatedIdentity, IdentityReference, InboundAuth } from "./types.js";
3
+ /**
4
+ * A person or an agent, as deployment code lists it: a bare principal id in
5
+ * the namespace of the deployment's one interactive auth provider,
6
+ * `{ namespace, id }` when that is ambiguous or the id contains `:`, or
7
+ * `"agent:<name>"` for an agent declared in `agents`.
8
+ */
9
+ export type MemberRef = string | Readonly<IdentityReference>;
10
+ /** A resolved member reference. Humans match by principal, agents by name. */
11
+ export type Member = {
12
+ kind: "human";
13
+ namespace: string;
14
+ id: string;
15
+ } | {
16
+ kind: "agent";
17
+ name: string;
18
+ };
19
+ /** A declared pool after construction-time validation. */
20
+ export interface ResolvedPool {
21
+ /** Resolved: the pool's own `readOnly`, else the deployment's. */
22
+ readOnly: boolean;
23
+ /** Exactly the pool's `tools`; nothing intersects or widens it. */
24
+ access: ConnectorAccess;
25
+ members: readonly Member[];
26
+ /** Trimmed `owner`: the Workspace address agents in this pool act as. */
27
+ owner?: string;
28
+ }
29
+ /** What resolving a reference needs to know about the deployment. */
30
+ export interface MemberContext {
31
+ /** `activityActorNamespace` of each interactive provider, `undefined` where it has none. */
32
+ interactiveNamespaces: ReadonlyArray<string | undefined>;
33
+ agents: readonly string[];
34
+ }
35
+ /** The namespaces bare ids may resolve in, read from the resolved providers. */
36
+ export declare function memberContext(auth: readonly InboundAuth[], agents: readonly string[]): MemberContext;
37
+ /**
38
+ * Resolve one list of references, throwing on any that is malformed,
39
+ * ambiguous, unknown, or repeated (INV-11).
40
+ */
41
+ export declare function resolveMembers(value: unknown, path: string, context: MemberContext): Member[];
42
+ /**
43
+ * `maintainers`: people only. Required whenever an inbound provider can admit
44
+ * a person, which is any provider but the built-in agent-token adapter. With
45
+ * agents alone nobody can be a maintainer, so plain `/mcp` admits nobody.
46
+ */
47
+ export declare function resolveMaintainers(value: unknown, admitsPeople: boolean, context: MemberContext): readonly IdentityReference[];
48
+ /** Whether an admitted identity is this member. An agent never matches a person. */
49
+ export declare function isMember(member: Member, identity: Readonly<AuthenticatedIdentity>): boolean;
50
+ /** Whether an admitted identity is a listed maintainer. */
51
+ export declare function isMaintainer(maintainers: readonly IdentityReference[], identity: Readonly<AuthenticatedIdentity>): boolean;
@@ -0,0 +1,121 @@
1
+ // Who may open which endpoint (#837, decisions/0006): `maintainers` open plain
2
+ // `/mcp`, and each pool's static `members` open `/mcp/<pool>`. References are
3
+ // resolved once at construction; a request is matched against them and never
4
+ // names its own scope (INV-4). Pure, so core reads it without any auth module.
5
+ import { isAgentName } from "./agent-identity.js";
6
+ import { ConfigError } from "./config-schema.js";
7
+ import { validIdentityReference } from "./identity.js";
8
+ const AGENT_PREFIX = "agent:";
9
+ /** The namespaces bare ids may resolve in, read from the resolved providers. */
10
+ export function memberContext(auth, agents) {
11
+ return {
12
+ interactiveNamespaces: auth
13
+ .filter((provider) => provider.interactiveOperator === true)
14
+ .map((provider) => typeof provider.activityActorNamespace === "string" &&
15
+ validIdentityReference({ namespace: provider.activityActorNamespace, id: "x" })
16
+ ? provider.activityActorNamespace
17
+ : undefined),
18
+ agents,
19
+ };
20
+ }
21
+ function resolveRef(value, path, context) {
22
+ if (typeof value === "string") {
23
+ if (value.startsWith(AGENT_PREFIX)) {
24
+ const name = value.slice(AGENT_PREFIX.length);
25
+ if (!isAgentName(name) || !context.agents.includes(name)) {
26
+ throw new ConfigError(`${path} names agent "${name}", which ConnectaConfig.agents does not declare.`);
27
+ }
28
+ return { kind: "agent", name };
29
+ }
30
+ if (value.includes(":")) {
31
+ throw new ConfigError(`${path} contains ":". Write a principal whose id contains ":" as { namespace, id }; ` +
32
+ 'agents are "agent:<name>".');
33
+ }
34
+ if (!validIdentityReference({ namespace: "x", id: value })) {
35
+ throw new ConfigError(`${path} must be a principal id of 1 to 256 printable, non-space ASCII characters.`);
36
+ }
37
+ const namespaces = context.interactiveNamespaces;
38
+ if (namespaces.length !== 1) {
39
+ throw new ConfigError(`${path} is a bare principal id, which resolves only when exactly one interactive auth provider is ` +
40
+ `configured (found ${namespaces.length}). Write it as { namespace, id }.`);
41
+ }
42
+ const namespace = namespaces[0];
43
+ if (namespace === undefined) {
44
+ throw new ConfigError(`${path} is a bare principal id, but the interactive auth provider declares no activityActorNamespace. ` +
45
+ "Write it as { namespace, id }.");
46
+ }
47
+ return { kind: "human", namespace, id: value };
48
+ }
49
+ if (typeof value !== "object" ||
50
+ value === null ||
51
+ Array.isArray(value) ||
52
+ Object.keys(value).length !== 2 ||
53
+ !Object.hasOwn(value, "namespace") ||
54
+ !Object.hasOwn(value, "id")) {
55
+ throw new ConfigError(`${path} must be a principal id string, { namespace, id }, or "agent:<name>".`);
56
+ }
57
+ const { namespace, id } = value;
58
+ if (typeof namespace !== "string" || typeof id !== "string" || !validIdentityReference({ namespace, id })) {
59
+ throw new ConfigError(`${path} must have a namespace and id of 1 to 256 printable, non-space ASCII characters each.`);
60
+ }
61
+ return { kind: "human", namespace, id };
62
+ }
63
+ const memberKey = (member) => member.kind === "agent" ? `agent\0${member.name}` : `human\0${member.namespace}\0${member.id}`;
64
+ /**
65
+ * Resolve one list of references, throwing on any that is malformed,
66
+ * ambiguous, unknown, or repeated (INV-11).
67
+ */
68
+ export function resolveMembers(value, path, context) {
69
+ if (!Array.isArray(value)) {
70
+ throw new ConfigError(`${path} must be an array of principal ids, { namespace, id }, or "agent:<name>".`);
71
+ }
72
+ const seen = new Set();
73
+ return value.map((entry, index) => {
74
+ const member = resolveRef(entry, `${path}[${index}]`, context);
75
+ const key = memberKey(member);
76
+ if (seen.has(key))
77
+ throw new ConfigError(`${path}[${index}] repeats an earlier member.`);
78
+ seen.add(key);
79
+ return Object.freeze(member);
80
+ });
81
+ }
82
+ /**
83
+ * `maintainers`: people only. Required whenever an inbound provider can admit
84
+ * a person, which is any provider but the built-in agent-token adapter. With
85
+ * agents alone nobody can be a maintainer, so plain `/mcp` admits nobody.
86
+ */
87
+ export function resolveMaintainers(value, admitsPeople, context) {
88
+ const path = "ConnectaConfig.maintainers";
89
+ if (value === undefined || (Array.isArray(value) && value.length === 0)) {
90
+ if (admitsPeople) {
91
+ throw new ConfigError(`${path} is required with inbound auth: list the people who may use plain /mcp, administer shared ` +
92
+ "credentials, manage agent tokens, and read activity.");
93
+ }
94
+ return Object.freeze([]);
95
+ }
96
+ if (!admitsPeople) {
97
+ throw new ConfigError(`${path} needs an inbound auth provider that admits people, such as clerkAuth or cloudflareAccessAuth. ` +
98
+ "Without one no maintainer can be admitted; agents are never maintainers.");
99
+ }
100
+ const members = resolveMembers(value, path, context);
101
+ return Object.freeze(members.map((member, index) => {
102
+ if (member.kind === "agent") {
103
+ throw new ConfigError(`${path}[${index}] is an agent. Agents are never maintainers; add them to a pool's members.`);
104
+ }
105
+ return Object.freeze({ namespace: member.namespace, id: member.id });
106
+ }));
107
+ }
108
+ /** Whether an admitted identity is this member. An agent never matches a person. */
109
+ export function isMember(member, identity) {
110
+ if (member.kind === "agent")
111
+ return identity.agent !== undefined && identity.agent === member.name;
112
+ const principal = identity.principal;
113
+ return (identity.agent === undefined &&
114
+ principal !== undefined &&
115
+ principal.namespace === member.namespace &&
116
+ principal.id === member.id);
117
+ }
118
+ /** Whether an admitted identity is a listed maintainer. */
119
+ export function isMaintainer(maintainers, identity) {
120
+ return maintainers.some((maintainer) => isMember({ kind: "human", ...maintainer }, identity));
121
+ }
@@ -17,13 +17,30 @@ export interface ActivityModule {
17
17
  readonly recordTool: typeof recordToolActivity;
18
18
  readonly recordChange: typeof recordCatalogChangeActivity;
19
19
  }
20
- /** Optional client-token authentication and operator lifecycle. */
20
+ /** Optional agent-token authentication and operator lifecycle. */
21
21
  export interface AccessTokensModule {
22
22
  readonly auth: import("./types.js").InboundAuth;
23
23
  handle(context: RouteContext): Promise<Response | null>;
24
- /** Token policy for `describeConfig()`; never token material. */
25
- describe?(): {
26
- maxActive: number;
27
- optionSources?: Readonly<Record<string, "default" | "config">>;
28
- };
24
+ /**
25
+ * Every declared agent's token slot, in declaration order: the list GET
26
+ * /ui/access-tokens serves, which the maintainer's operator contract
27
+ * repeats field by field. Never secret material.
28
+ */
29
+ slots(): Promise<Array<{
30
+ name: string;
31
+ state: "none";
32
+ revision: string;
33
+ } | {
34
+ name: string;
35
+ state: "issued";
36
+ issuedAt: string;
37
+ revision: string;
38
+ } | {
39
+ name: string;
40
+ state: "revoked";
41
+ revokedAt: string;
42
+ revision: string;
43
+ }>>;
44
+ /** The same module bound to the deployment's declared `agents`; createConnecta calls it once. */
45
+ forAgents(agents: readonly string[]): AccessTokensModule;
29
46
  }
@@ -8,7 +8,7 @@ type FailureStep = "MCP handshake" | "tools/list" | "tools/call" | "resources/re
8
8
  */
9
9
  export declare const OAUTH_ERROR_CODES: ReadonlySet<string>;
10
10
  /** Inbound denial facts, never identity text or provider diagnostics. */
11
- export type AuthDenialReason = "authentication_failed" | "authorization_header_invalid" | "token_type_mismatch" | "oauth_verification_failed" | "oauth_client_not_allowed" | "oauth_binding_mismatch" | "session_origin_mismatch" | "user_missing" | "email_lookup_failed" | "verified_email_invalid" | "email_domain_denied" | "gate_denied" | "gate_failed" | "default_grant_denied" | "default_grant_threw" | "pool_not_declared" | "pool_grant_denied" | "pool_grant_threw";
11
+ export type AuthDenialReason = "authentication_failed" | "authorization_header_invalid" | "token_type_mismatch" | "oauth_verification_failed" | "oauth_client_not_allowed" | "oauth_binding_mismatch" | "session_origin_mismatch" | "user_missing" | "email_lookup_failed" | "verified_email_invalid" | "email_domain_denied" | "gate_denied" | "gate_failed" | "not_maintainer" | "pool_not_declared" | "not_pool_member";
12
12
  /**
13
13
  * Name `ctor`'s instances `label` in records and fixed text. Call once, at
14
14
  * module scope, beside the class. An error whose prototype chain reaches no
@@ -104,7 +104,7 @@ export declare function failureRecord(subject: FailureSubject, failure?: unknown
104
104
  * The events an operator log reports failures as. Closed: a new one is a
105
105
  * reviewed line here, never a string assembled at the call site.
106
106
  */
107
- export type FailureEvent = "negotiation cache read failed" | "negotiation cache write failed" | "connectorAccess grant is unreachable" | "call failed" | "input schema unusable; arguments are not validated" | "catalog read failed" | "catalog persistence failed" | "catalog invalidation failed" | "catalog refresh observation failed" | "catalog refresh failed; serving stale catalog" | "deferred catalog refresh failed" | "deferred catalog refresh could not attach to the runtime" | "catalog drift check failed" | "schema digest check failed; serving digested reviews as writes" | "credential shape read failed" | "credential test failed" | "credential test threw" | "session termination refused or failed; the downstream session may remain until its provider timeout" | "activity record failed" | "catalog drift record failed" | "activity read failed" | "OAuth start failed" | "OAuth disconnect failed" | "OAuth callback verifyState threw; no authorization code was exchanged" | "OAuth callback handoff could not be consumed; no authorization code was exchanged" | "MCP default endpoint request denied" | "MCP pool request denied" | "OAuth error callback consent could not be consumed; no authorization code was exchanged" | "MCP handler error" | "operator status" | "result paging unavailable" | "request failed" | "Clerk request denied" | "Clerk authentication failed";
107
+ export type FailureEvent = "negotiation cache read failed" | "negotiation cache write failed" | "pool tool grant is unreachable" | "call failed" | "input schema unusable; arguments are not validated" | "catalog read failed" | "catalog persistence failed" | "catalog invalidation failed" | "catalog refresh observation failed" | "catalog refresh failed; serving stale catalog" | "deferred catalog refresh failed" | "deferred catalog refresh could not attach to the runtime" | "catalog drift check failed" | "schema digest check failed; serving digested reviews as writes" | "credential shape read failed" | "credential test failed" | "credential test threw" | "session termination refused or failed; the downstream session may remain until its provider timeout" | "activity record failed" | "catalog drift record failed" | "activity read failed" | "OAuth start failed" | "OAuth disconnect failed" | "OAuth callback verifyState threw; no authorization code was exchanged" | "OAuth callback handoff could not be consumed; no authorization code was exchanged" | "MCP default endpoint request denied" | "MCP pool request denied" | "MCP agent endpoint request denied" | "OAuth error callback consent could not be consumed; no authorization code was exchanged" | "MCP handler error" | "operator status" | "operator agent slots read failed" | "result paging unavailable" | "request failed" | "Clerk request denied" | "Clerk authentication failed";
108
108
  /**
109
109
  * Log one failure as `[connecta] <event>` and its record. A record
110
110
  * `failureRecord` did not build is replaced, whatever its shape, by a fixed
@@ -112,11 +112,9 @@ const AUTH_DENIAL_REASONS = new Set([
112
112
  "email_domain_denied",
113
113
  "gate_denied",
114
114
  "gate_failed",
115
- "default_grant_denied",
116
- "default_grant_threw",
115
+ "not_maintainer",
117
116
  "pool_not_declared",
118
- "pool_grant_denied",
119
- "pool_grant_threw",
117
+ "not_pool_member",
120
118
  ]);
121
119
  /** The registry's connector id grammar (src/registry.ts). */
122
120
  const CONNECTOR_ID_RE = /^[a-z0-9_-]{1,64}$/;
@@ -0,0 +1,74 @@
1
+ import type { OperatorUiContractV2 } from "./contract.js";
2
+ import { type Notice, type RequestFailureFacts, type Tone } from "./view.js";
3
+ /**
4
+ * The Tokens page's rows, computed from the slot list and contract v2 alone.
5
+ * Agents are declared in code: a row's only controls are its token's issue,
6
+ * rotate and revoke, so nothing here creates, renames or rebinds one.
7
+ */
8
+ /** One declared agent's token slot, as `GET /ui/access-tokens` returns it. */
9
+ export type AgentSlot = ({
10
+ name: string;
11
+ state: "none";
12
+ } | {
13
+ name: string;
14
+ state: "issued";
15
+ issuedAt: string;
16
+ } | {
17
+ name: string;
18
+ state: "revoked";
19
+ revokedAt: string;
20
+ }) & {
21
+ /**
22
+ * The route's opaque slot revision; it changes on every slot commit (P3b,
23
+ * #837). A slot read without one is unresolved and never acted on.
24
+ */
25
+ revision?: string;
26
+ };
27
+ /** Issue and rotate are the same request; the slot's state names it. */
28
+ export type SlotAction = "issue" | "rotate" | "revoke";
29
+ export interface AgentRow {
30
+ name: string;
31
+ /** Pools it belongs to, from `agents[].pools`; empty when the contract does not list it. */
32
+ pools: string[];
33
+ slot: AgentSlot;
34
+ /**
35
+ * Whether `slot` comes from the live slot list. Only a live slot can be
36
+ * acted on: one known only from the contract may be stale, so its controls
37
+ * are disabled until a refresh lists it.
38
+ */
39
+ live: boolean;
40
+ /** Why its controls are off, or null when they work. */
41
+ blocked: string | null;
42
+ /** Its latest activity row; null is "never" (or history that is off). */
43
+ lastSeenAt: string | null;
44
+ actions: SlotAction[];
45
+ }
46
+ export declare function slotActions(slot: Pick<AgentSlot, "state">): SlotAction[];
47
+ /**
48
+ * One agent's slot, resolved once for both what the row shows and what an
49
+ * action may do. The live slot list is authoritative; the contract's copy is
50
+ * shown only for an agent the list does not have, and is never acted on.
51
+ */
52
+ export declare function resolveSlot(contract: OperatorUiContractV2 | null, slots: AgentSlot[], name: string): {
53
+ slot: AgentSlot;
54
+ live: boolean;
55
+ } | null;
56
+ /**
57
+ * One row per declared agent, in the slot list's order, then any agent only
58
+ * the contract lists. The contract adds pools and last seen.
59
+ */
60
+ export declare function agentRows(contract: OperatorUiContractV2 | null, slots: AgentSlot[], unresolved?: readonly string[]): AgentRow[];
61
+ export declare function slotBadge(slot: Pick<AgentSlot, "state">): {
62
+ label: string;
63
+ tone: Tone;
64
+ };
65
+ /** When the slot last changed, as "Issued <date>" or "Revoked <date>"; a dash when it never has. */
66
+ export declare function slotSince(slot: AgentSlot, format: (value: string) => string): string;
67
+ export declare function lastSeenLabel(lastSeenAt: string | null, format: (value: string) => string): string;
68
+ /**
69
+ * Why a token operation did not land, in fixed sentences keyed by status.
70
+ * `outcome_unknown` (503) and `aborted` (499) are failures like any other and
71
+ * are never retried for the operator: an unknown rotation returned no secret,
72
+ * and only a new, deliberate rotation produces one.
73
+ */
74
+ export declare function tokenFailureNotice(action: SlotAction, name: string, facts: RequestFailureFacts, productName: string): Notice;
@@ -0,0 +1,104 @@
1
+ import { failure } from "./view.js";
2
+ /** Why a row's controls are disabled when its slot is not in the live list. */
3
+ const SLOT_UNAVAILABLE = "Token state unavailable, refresh";
4
+ /** Why a row's controls are disabled until a fresh list read resolves its slot. */
5
+ const SLOT_UNRESOLVED = "Refresh to continue";
6
+ export function slotActions(slot) {
7
+ return slot.state === "issued" ? ["rotate", "revoke"] : ["issue"];
8
+ }
9
+ function contractSlot(agent) {
10
+ return { name: agent.name, ...agent.token };
11
+ }
12
+ function maintainerAgents(contract) {
13
+ return contract?.role === "maintainer" ? contract.agents : [];
14
+ }
15
+ /**
16
+ * One agent's slot, resolved once for both what the row shows and what an
17
+ * action may do. The live slot list is authoritative; the contract's copy is
18
+ * shown only for an agent the list does not have, and is never acted on.
19
+ */
20
+ export function resolveSlot(contract, slots, name) {
21
+ const listed = slots.find((slot) => slot.name === name);
22
+ if (listed)
23
+ return { slot: listed, live: true };
24
+ const agent = maintainerAgents(contract).find((candidate) => candidate.name === name);
25
+ return agent ? { slot: contractSlot(agent), live: false } : null;
26
+ }
27
+ /**
28
+ * One row per declared agent, in the slot list's order, then any agent only
29
+ * the contract lists. The contract adds pools and last seen.
30
+ */
31
+ export function agentRows(contract, slots, unresolved = []) {
32
+ const agents = maintainerAgents(contract);
33
+ const declared = new Map(agents.map((agent) => [agent.name, agent]));
34
+ const names = [...slots.map((slot) => slot.name), ...agents.map((agent) => agent.name)];
35
+ return [...new Set(names)].map((name) => {
36
+ const { slot, live } = resolveSlot(contract, slots, name);
37
+ const agent = declared.get(name);
38
+ return {
39
+ name,
40
+ pools: agent?.pools ?? [],
41
+ slot,
42
+ live,
43
+ blocked: !live ? SLOT_UNAVAILABLE : unresolved.includes(name) || !slot.revision ? SLOT_UNRESOLVED : null,
44
+ lastSeenAt: agent?.lastSeenAt ?? null,
45
+ actions: slotActions(slot),
46
+ };
47
+ });
48
+ }
49
+ export function slotBadge(slot) {
50
+ if (slot.state === "issued")
51
+ return { label: "Issued", tone: "ok" };
52
+ if (slot.state === "revoked")
53
+ return { label: "Revoked", tone: "warn" };
54
+ return { label: "No token", tone: "neutral" };
55
+ }
56
+ /** When the slot last changed, as "Issued <date>" or "Revoked <date>"; a dash when it never has. */
57
+ export function slotSince(slot, format) {
58
+ if (slot.state === "issued")
59
+ return `Issued ${format(slot.issuedAt)}`;
60
+ if (slot.state === "revoked")
61
+ return `Revoked ${format(slot.revokedAt)}`;
62
+ return "—";
63
+ }
64
+ export function lastSeenLabel(lastSeenAt, format) {
65
+ return lastSeenAt ? format(lastSeenAt) : "never";
66
+ }
67
+ /**
68
+ * Why a token operation did not land, in fixed sentences keyed by status.
69
+ * `outcome_unknown` (503) and `aborted` (499) are failures like any other and
70
+ * are never retried for the operator: an unknown rotation returned no secret,
71
+ * and only a new, deliberate rotation produces one.
72
+ */
73
+ export function tokenFailureNotice(action, name, facts, productName) {
74
+ if (facts.kind === "session")
75
+ return failure("Your session has ended. Sign in again, then retry.");
76
+ if (facts.kind === "forbidden")
77
+ return failure("You don't have permission to manage agent tokens.");
78
+ if (facts.kind === "stale") {
79
+ return failure(`${name}'s token changed before the request was sent, so nothing was sent. Check it and try again.`);
80
+ }
81
+ if (facts.kind === "network") {
82
+ return failure(`Couldn't reach ${productName}. Refresh to check ${name}'s token before trying again.`);
83
+ }
84
+ if (facts.status === 503) {
85
+ return failure(action === "revoke"
86
+ ? `Revocation outcome unknown for ${name}: revoke again to be sure its token is stopped.`
87
+ : `${action === "issue" ? "Issue" : "Rotation"} outcome unknown for ${name}: no secret was returned, so rotate again for a replacement.`);
88
+ }
89
+ if (facts.status === 499) {
90
+ return failure(action === "revoke"
91
+ ? `The request ended before ${name}'s revocation finished. Refresh to check its token before trying again.`
92
+ : `The request ended before ${name}'s token was delivered, so no secret was returned. Refresh to check its token before trying again.`);
93
+ }
94
+ // The route's compare-and-swap refused it: the slot moved after the list was read.
95
+ if (facts.status === 409 && facts.code === "slot_changed")
96
+ return failure("Changed since you looked, refresh.");
97
+ if (facts.status === 409)
98
+ return failure(`${name}'s token changed meanwhile. Reload and try again.`);
99
+ if (facts.status === 404)
100
+ return failure(`${name} is no longer declared in code. Reload the page.`);
101
+ return action === "revoke"
102
+ ? failure(`${name}'s token could not be revoked. Reload and try again.`)
103
+ : failure(`A token could not be issued for ${name}. Reload and try again.`);
104
+ }