@zackbart/connecta 0.37.0 → 0.38.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 (183) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +2 -2
  3. package/bin/connecta.mjs +1 -39
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +2 -4
  6. package/dist/agent-identity.js +1 -1
  7. package/dist/auth/clerk-sdk/generated.js +2 -1
  8. package/dist/auth/cloudflare-access.d.ts +3 -1
  9. package/dist/auth/cloudflare-access.js +22 -11
  10. package/dist/auth/downstream-oauth.js +195 -270
  11. package/dist/auth/downstream-revocation.js +1 -1
  12. package/dist/auth/oauth-grant.js +11 -0
  13. package/dist/auth/oauth-token-response.js +1 -1
  14. package/dist/auth/vercel-agent.js +2 -1
  15. package/dist/auth-elicitation.js +2 -2
  16. package/dist/branding.d.ts +1 -15
  17. package/dist/branding.js +2 -2
  18. package/dist/bytes.js +42 -0
  19. package/dist/call-admission-context.js +2 -5
  20. package/dist/call-admission.js +5 -0
  21. package/dist/call-recovery.js +0 -3
  22. package/dist/catalog-cache.js +26 -7
  23. package/dist/catalog-fingerprint.js +2 -18
  24. package/dist/catalog-revision.js +9 -0
  25. package/dist/catalog-service.js +51 -20
  26. package/dist/config-defaults.d.ts +1 -0
  27. package/dist/config-defaults.js +1 -0
  28. package/dist/config-schema.d.ts +1 -1
  29. package/dist/config-schema.js +6 -2
  30. package/dist/config.d.ts +41 -14
  31. package/dist/config.js +21 -71
  32. package/dist/connector-caller.js +1 -1
  33. package/dist/connectors/api-connector.js +4 -3
  34. package/dist/connectors/guarded-fetch.d.ts +0 -6
  35. package/dist/connectors/guarded-fetch.js +1 -11
  36. package/dist/connectors/remote-mcp.js +93 -69
  37. package/dist/connectors/resource-uri.js +0 -4
  38. package/dist/credentials.js +3 -0
  39. package/dist/d1.d.ts +0 -89
  40. package/dist/d1.js +0 -34
  41. package/dist/downstream-input.js +2 -2
  42. package/dist/execute.js +161 -116
  43. package/dist/executor-result.js +2 -1
  44. package/dist/executors/quickjs-runtime.js +2 -25
  45. package/dist/executors/quickjs.js +1 -1
  46. package/dist/guest-runtime.js +5 -3
  47. package/dist/guest-types.d.ts +5 -5
  48. package/dist/http/client.js +1 -1
  49. package/dist/http/errors.js +2 -2
  50. package/dist/http/json-schema.js +1 -1
  51. package/dist/http/node-transport.js +0 -1
  52. package/dist/http/oauth.js +1 -1
  53. package/dist/http/paging.js +6 -9
  54. package/dist/http/provider.js +1 -1
  55. package/dist/http/safety.js +1 -1
  56. package/dist/http/services.js +1 -8
  57. package/dist/http/tools.js +1 -7
  58. package/dist/http/vendor.js +7 -7
  59. package/dist/identity.js +2 -6
  60. package/dist/index.js +1 -2
  61. package/dist/invocation-auth.js +2 -1
  62. package/dist/invocation.js +8 -2
  63. package/dist/meta-tools.js +27 -27
  64. package/dist/native-tools.js +2 -1
  65. package/dist/oauth-handoff.js +2 -2
  66. package/dist/operation-vocabulary.js +1 -1
  67. package/dist/operator-record.d.ts +8 -2
  68. package/dist/operator-record.js +18 -50
  69. package/dist/operator-ui/contract.d.ts +4 -2
  70. package/dist/operator-ui/generated.js +3 -3
  71. package/dist/operator-ui/model.d.ts +7 -2
  72. package/dist/program-writes.js +3 -8
  73. package/dist/provider-call.js +0 -7
  74. package/dist/provider.d.ts +1 -1
  75. package/dist/providers/_shared/google/kit.js +1 -6
  76. package/dist/providers/_shared/google/service-account.js +1 -3
  77. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  78. package/dist/providers/_shared/guide.js +3 -0
  79. package/dist/providers/_shared/json.js +12 -0
  80. package/dist/providers/_shared/rest/compact-index.js +1 -1
  81. package/dist/providers/_shared/rest/kit.js +1 -3
  82. package/dist/providers/_shared/rest/operation-index.js +1 -3
  83. package/dist/providers/_shared/rest/value-safety.js +26 -28
  84. package/dist/providers/breeze/api.js +2 -7
  85. package/dist/providers/breeze/index.js +4 -2
  86. package/dist/providers/breeze/tools.js +3 -4
  87. package/dist/providers/ccb/api.js +1 -6
  88. package/dist/providers/ccb/index.js +2 -1
  89. package/dist/providers/ccb/tools.js +1 -6
  90. package/dist/providers/cloudflare/api.js +2 -2
  91. package/dist/providers/cloudflare/index.js +2 -1
  92. package/dist/providers/cloudflare/safety.js +3 -3
  93. package/dist/providers/docs/tools.js +2 -10
  94. package/dist/providers/drive/tools.js +12 -25
  95. package/dist/providers/forms/tools.js +1 -9
  96. package/dist/providers/gmail/tools.js +5 -21
  97. package/dist/providers/overflow/api.js +1 -3
  98. package/dist/providers/overflow/index.js +2 -1
  99. package/dist/providers/overflow/tools.js +3 -7
  100. package/dist/providers/planning-center/api.js +2 -1
  101. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  102. package/dist/providers/planning-center/lib/json-api.js +1 -9
  103. package/dist/providers/planning-center/tools.js +2 -1
  104. package/dist/providers/sheets/tools.js +1 -9
  105. package/dist/providers/slides/tools.js +4 -13
  106. package/dist/providers/stripe/index.js +2 -1
  107. package/dist/providers/stripe/safety.js +2 -2
  108. package/dist/providers/tithely/api.js +2 -7
  109. package/dist/providers/tithely/index.js +3 -2
  110. package/dist/providers/tithely/tools.js +7 -5
  111. package/dist/providers/vercel/api.js +1 -6
  112. package/dist/providers/vercel/lib/pin.js +1 -1
  113. package/dist/providers/vercel/safety.js +1 -1
  114. package/dist/providers/vercel/tools.js +2 -1
  115. package/dist/redaction-marker.js +2 -2
  116. package/dist/registry.d.ts +4 -1
  117. package/dist/registry.js +35 -22
  118. package/dist/request-principal.js +15 -2
  119. package/dist/request-state.js +2 -2
  120. package/dist/routes/access-tokens.js +1 -1
  121. package/dist/routes/activity-disclosure.js +1 -1
  122. package/dist/routes/connect.js +2 -2
  123. package/dist/routes/credentials.js +1 -1
  124. package/dist/routes/dispatch.js +10 -0
  125. package/dist/routes/mcp.js +23 -21
  126. package/dist/routes/oauth-management.js +1 -1
  127. package/dist/routes/operator.js +53 -7
  128. package/dist/routes/shared.d.ts +8 -8
  129. package/dist/routes/shared.js +22 -15
  130. package/dist/routes/ui-config.js +6 -6
  131. package/dist/routes/ui-data.js +2 -2
  132. package/dist/routes/ui.js +9 -4
  133. package/dist/runtime/call-admission.js +1 -1
  134. package/dist/runtime/call.js +1 -1
  135. package/dist/runtime/settlement.js +1 -1
  136. package/dist/runtime/shared-read.js +2 -15
  137. package/dist/sent-secrets.d.ts +14 -6
  138. package/dist/sent-secrets.js +231 -85
  139. package/dist/server.js +4 -4
  140. package/dist/skills.js +8 -35
  141. package/dist/sqlite.d.ts +0 -19
  142. package/dist/sqlite.js +2 -83
  143. package/dist/storage/keys.js +29 -124
  144. package/dist/storage/sql-native.js +14 -111
  145. package/dist/storage/sql.d.ts +1 -24
  146. package/dist/storage/sql.js +1 -12
  147. package/dist/tool-error.js +4 -0
  148. package/dist/types.d.ts +4 -3
  149. package/dist/ui.js +3 -0
  150. package/dist/url-safety.js +0 -4
  151. package/dist/usage-guide.js +2 -2
  152. package/dist/version.d.ts +1 -1
  153. package/dist/version.js +1 -1
  154. package/dist/worker.js +1 -1
  155. package/documentation/README.md +6 -42
  156. package/documentation/architecture.md +190 -507
  157. package/documentation/auth.md +172 -58
  158. package/documentation/code-mode.md +98 -66
  159. package/documentation/deploying.md +19 -40
  160. package/documentation/integrating.md +8 -19
  161. package/documentation/meta-tools.md +23 -6
  162. package/documentation/migration-0.33.md +5 -5
  163. package/documentation/migration-0.34.md +2 -4
  164. package/documentation/migration-retired-layouts.md +74 -0
  165. package/documentation/operator-tests.md +19 -119
  166. package/documentation/operator-ui.md +46 -8
  167. package/documentation/provider-auth-migration.md +4 -10
  168. package/documentation/provider-migration-0.29.md +4 -15
  169. package/examples/worker/AGENTS.md +4 -6
  170. package/examples/worker/README.md +20 -115
  171. package/guides/connecting-agents.md +7 -30
  172. package/guides/how-it-works.md +2 -2
  173. package/guides/safety-and-trust.md +4 -1
  174. package/package.json +19 -19
  175. package/templates/node/README.md +9 -26
  176. package/templates/node/package.json +1 -1
  177. package/dist/auth/oauth-v2-migration.js +0 -135
  178. package/dist/provider-call-facts.d.ts +0 -115
  179. package/dist/provider-call.d.ts +0 -112
  180. package/dist/storage/d1-copy.js +0 -255
  181. package/dist/storage/sql-schema.d.ts +0 -6
  182. package/examples/worker/kv-to-d1.wrangler.jsonc +0 -26
  183. package/examples/worker/scripts/copy-kv-to-d1.mjs +0 -162
package/dist/registry.js CHANGED
@@ -1,12 +1,16 @@
1
+ import { wrapViewCredentials } from "./runtime/call.js";
2
+ import { sha256Hex } from "./bytes.js";
3
+ import { catalogRevision } from "./catalog-revision.js";
1
4
  import { authCall, authInvoke } from "./auth/native.js";
2
5
  import { Clock, Duration, Effect, Random } from "effect";
3
6
  import { activityRequest } from "./activity-request.js";
4
7
  import { aggregateCallAdmissionSnapshots, ConnectorCallAdmissionController, } from "./call-admission.js";
5
- import { attachCatalogCache, catalogExpiry, catalogFetchedAt, customCatalogFallback, invalidateCatalogCache, observeUncachedCatalogRefresh, storeCustomCatalogFallback, } from "./catalog-cache.js";
8
+ import { attachCatalogCache, catalogExpiry, catalogFetchedAt, customCatalogFallback, invalidateCatalogCache, observeUncachedCatalogRefresh, retainedCatalogIdentity, storeCustomCatalogFallback, } from "./catalog-cache.js";
6
9
  import { carryCatalogFreshness, markCatalogFreshness } from "./catalog-freshness.js";
7
10
  import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "./catalog-limits.js";
8
11
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
9
12
  import { usesCallerIdentity } from "./connector-caller.js";
13
+ import { forwardsPrincipal } from "./request-principal.js";
10
14
  import { canonicalJson } from "./canonical-json.js";
11
15
  import { storedCredentialShape } from "./credential-rules.js";
12
16
  import { ConnectorCallError } from "./errors.js";
@@ -21,7 +25,7 @@ import { causeError, runEdge } from "./runtime/run.js";
21
25
  import { Storage } from "./runtime/services.js";
22
26
  import { SharedRead } from "./runtime/shared-read.js";
23
27
  import { runOnPartition, storageCompareAndSet, storageDelete, storageGet, storageSet, storageList, } from "./runtime/storage.js";
24
- import { redactCatalog, sentSecretsFor, sentSecretsForRequest, trackCredentialReads } from "./sent-secrets.js";
28
+ import { redactCatalog, sentSecretsFor, sentSecretsForRequest } from "./sent-secrets.js";
25
29
  import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary } from "./skills.js";
26
30
  import { jsonCodec, OAUTH_HANDOFF_TTL_SECONDS, oauthHandoffKeys, resultKeys, scopes, stashExpiryKeys, stashLedgerKeys, } from "./storage/keys.js";
27
31
  import { assertStaticToolNames, hasControlCharacters } from "./tool-name.js";
@@ -132,6 +136,8 @@ const STASH_LEDGER_BACKOFF_CAP_MS = 250;
132
136
  const STASH_WRITE_TIMEOUT_MS = 30_000;
133
137
  /** Total cleanup/release or settlement budget, including ledger retries. */
134
138
  const STASH_COMPLETION_TIMEOUT_MS = 15_000;
139
+ /** Reservation, chunk writes and completion share one finite outer budget. */
140
+ const STASH_TIMEOUT_MS = STASH_WRITE_TIMEOUT_MS + STASH_COMPLETION_TIMEOUT_MS;
135
141
  /** Stop waiting without starting background storage work after the deadline. */
136
142
  function completeStash(effect) {
137
143
  return effect.pipe(Effect.asVoid, Effect.timeoutOrElse({ duration: Duration.millis(STASH_COMPLETION_TIMEOUT_MS), orElse: () => Effect.void }),
@@ -228,10 +234,6 @@ function swapStashLedger(plan) {
228
234
  }).pipe(Effect.catch(() => Effect.succeed({ status: "exhausted" })));
229
235
  });
230
236
  }
231
- async function sha256Hex(value) {
232
- const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
233
- return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
234
- }
235
237
  /**
236
238
  * Holds the connector set, resolves addresses, and coalesces tool listings
237
239
  * within one request. Remote MCP clients own persisted response caching. Connector failures are isolated: a broken
@@ -449,6 +451,9 @@ export class Registry {
449
451
  }
450
452
  contextFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
451
453
  const connector = this.connectors.get(id);
454
+ if (!scope?.caller && scope?.operatorCaller && connector && forwardsPrincipal(connector)) {
455
+ scope = { ...scope, caller: scope.operatorCaller };
456
+ }
452
457
  const credentialConfig = connector?.credential;
453
458
  let credentialAccess;
454
459
  let credentialProgram;
@@ -494,6 +499,7 @@ export class Registry {
494
499
  attachCatalogCache(context, {
495
500
  storage: this.opts.catalogStorage ?? this.opts.storage,
496
501
  partition: this.catalogPartition(scope),
502
+ credentialRevision: () => catalogRevision(this.opts.credentialVault),
497
503
  sharedPartition: JSON.stringify([baseUrl, this.opts.publicUrl ?? null, scope?.caller?.pool ?? null]),
498
504
  defaultTtlMs: (this.opts.toolCacheTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds) * 1000,
499
505
  minTtlMs: (this.opts.catalogMinTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMinTtlSeconds) * 1000,
@@ -510,7 +516,7 @@ export class Registry {
510
516
  : {}),
511
517
  });
512
518
  sentSecretsFor(context);
513
- trackCredentialReads(context);
519
+ wrapViewCredentials(context);
514
520
  attachOAuthPartition(context, this.oauthPartition);
515
521
  // Downstream OAuth state is sealed under the vault key, bound to this
516
522
  // connector and owner. The sealer rides beside the context, not on it.
@@ -539,12 +545,13 @@ export class Registry {
539
545
  }
540
546
  /** The admitted identity fields used by both catalog caching and reporting. */
541
547
  catalogPartition(scope) {
548
+ const caller = scope?.caller ?? scope?.operatorCaller;
542
549
  return JSON.stringify([
543
550
  scope?.principalKey ?? this.opts.credentialOwner ?? null,
544
551
  scope?.subjectKey ?? null,
545
- scope?.caller?.identity ?? null,
546
- scope?.caller?.authenticated ?? false,
547
- scope?.caller?.pool ?? null,
552
+ caller?.identity ?? null,
553
+ caller?.authenticated ?? false,
554
+ caller?.pool ?? null,
548
555
  ]);
549
556
  }
550
557
  /** Bounded, in-memory tokens; no principal or catalog data is persisted. */
@@ -572,7 +579,7 @@ export class Registry {
572
579
  *
573
580
  * `chunks[n]` lands on `resultKeys.chunk(id, n)` inside `partition` — the
574
581
  * layout connecta.result reads back, so a page fetches only the chunks it covers
575
- * instead of the whole stored result (issue #540). Chunking is a read-cost
582
+ * instead of the whole stored result. Chunking is a read-cost
576
583
  * decision, not a capacity one: however many keys an envelope occupies, it
577
584
  * is one stash entry charged its total ASCII length.
578
585
  *
@@ -580,6 +587,7 @@ export class Registry {
580
587
  * row in one ledger record in storage, booked by compare-and-set before any
581
588
  * chunk is written, so every isolate and process sharing the store sees the
582
589
  * same entries and bytes. A pending write keeps its finite charge reserved.
590
+ * The whole operation, including reservation, stops after 45 seconds.
583
591
  * Writes stop after 30 seconds; absolute chunk expiries cannot pass the
584
592
  * booked deadline even on late commit. Rejected responses leave writes
585
593
  * unconfirmed and retain their charge. Cleanup/release and settlement each
@@ -669,13 +677,17 @@ export class Registry {
669
677
  yield* authCall(observe, [ctx, id, facts]).pipe(Effect.catch((error) => Effect.sync(() => logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error)))));
670
678
  }
671
679
  }
672
- this.catalogObservedAt.set(id, catalogFetchedAt(ctx) ?? Date.now());
673
680
  const provenance = catalogExpiry(ctx);
674
681
  const freshUntil = provenance && !provenance.staleFallback ? provenance.expiresAt : 0;
675
- this.catalogAccess.set(id, {
676
- state: freshUntil > Date.now() ? "fresh" : "stale",
677
- observedAt: new Date().toISOString(),
678
- });
682
+ // Principal-forwarded listings belong to one caller, never to the shared
683
+ // operator observation store. The actual catalog cache is identity-bound.
684
+ if (!forwardsPrincipal(connector)) {
685
+ this.catalogObservedAt.set(id, catalogFetchedAt(ctx) ?? Date.now());
686
+ this.catalogAccess.set(id, {
687
+ state: freshUntil > Date.now() ? "fresh" : "stale",
688
+ observedAt: new Date().toISOString(),
689
+ });
690
+ }
679
691
  return markCatalogFreshness(accepted, freshUntil);
680
692
  });
681
693
  }
@@ -863,7 +875,7 @@ export class Registry {
863
875
  else
864
876
  yield* completeStash(settle);
865
877
  return written;
866
- }).pipe(Effect.provideService(Storage, self.opts.storage)),
878
+ }).pipe(Effect.timeoutOrElse({ duration: Duration.millis(STASH_TIMEOUT_MS), orElse: () => Effect.succeed(false) }), Effect.provideService(Storage, self.opts.storage)),
867
879
  recheckResultAccess: () => Effect.succeed(true),
868
880
  storeOAuthHandoff: (self, connectorId, state, principalKey) => Effect.gen(function* () {
869
881
  const key = oauthHandoffKeys.handoff(connectorId, yield* Effect.tryPromise({ try: () => sha256Hex(state), catch: (error) => error }));
@@ -907,16 +919,17 @@ export class Registry {
907
919
  if (!self.connectors.has(id))
908
920
  return Effect.fail(new Error(`Unknown connector "${id}"`));
909
921
  const owner = self.contextFor(id, baseUrl, requestScope, callOptions, scope);
910
- return duringViewEffect(owner, (signal) => {
922
+ return duringViewEffect(owner, (signal) => Effect.gen(function* () {
911
923
  if (!requestScope)
912
- return self.loadTools(id, baseUrl, owner.requestScope, { ...callOptions, signal }, scope);
924
+ return yield* self.loadTools(id, baseUrl, owner.requestScope, { ...callOptions, signal }, scope);
925
+ const identity = self.connectors.get(id)?.staticTools ? "static" : retainedCatalogIdentity(owner);
913
926
  let loads = self.requestCatalogLoads.get(requestScope);
914
927
  if (!loads) {
915
928
  loads = new Map();
916
929
  self.requestCatalogLoads.set(requestScope, loads);
917
930
  }
918
931
  const requestLoads = loads;
919
- const key = JSON.stringify([id, scope?.principalKey, scope?.subjectKey, scope?.caller]);
932
+ const key = JSON.stringify([id, scope?.principalKey, scope?.subjectKey, scope?.caller, identity]);
920
933
  let load = requestLoads.get(key);
921
934
  if (!load) {
922
935
  const started = new SharedRead((signal) => self.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, scope), () => {
@@ -928,8 +941,8 @@ export class Registry {
928
941
  requestLoads.set(key, started);
929
942
  load = started;
930
943
  }
931
- return load.join(signal);
932
- });
944
+ return yield* load.join(signal);
945
+ }));
933
946
  }),
934
947
  credentialDriftFor: (self, id) => Effect.gen(function* () {
935
948
  const credential = self.connectors.get(id)?.credential;
@@ -1,4 +1,4 @@
1
- // The admitted principal a request-auth resolver may receive (#1103).
1
+ // The admitted principal a request-auth resolver may receive.
2
2
  //
3
3
  // Only the caller core attached from the admitting authorization counts
4
4
  // (`callerOf`, see connector-caller.ts). No argument, program or header can
@@ -9,13 +9,26 @@
9
9
  import { isAgentName } from "./agent-identity.js";
10
10
  import { callerOf } from "./connector-caller.js";
11
11
  import { validIdentityReference } from "./identity.js";
12
+ const forwardingConnectors = new WeakSet();
13
+ /** Internal construction tag; never inferred from request data or descriptions. */
14
+ export function tagPrincipalForwarding(connector) {
15
+ forwardingConnectors.add(connector);
16
+ return connector;
17
+ }
18
+ export function forwardsPrincipal(connector) {
19
+ for (let current = connector; current; current = Object.getPrototypeOf(current)) {
20
+ if (forwardingConnectors.has(current))
21
+ return true;
22
+ }
23
+ return false;
24
+ }
12
25
  /**
13
26
  * The principal this operation's admitted caller is. Anything else is refused
14
27
  * through `refuse`, the connector's own `auth_required` error.
15
28
  */
16
29
  export function requestPrincipalFor(policy, ctx, refuse) {
17
30
  const caller = callerOf(ctx);
18
- // An operator check, a context no request admitted, or an open deployment's anonymous caller.
31
+ // A context no request admitted, or an open deployment's anonymous caller.
19
32
  if (!caller?.authenticated)
20
33
  throw refuse("acts as the admitted caller, and this operation has none");
21
34
  const { identity } = caller;
@@ -1,3 +1,4 @@
1
+ import { sha256Hex } from "./bytes.js";
1
2
  import { ProtocolError, INVALID_PARAMS } from "@modelcontextprotocol/server";
2
3
  export const REQUEST_STATE_TTL_MS = 10 * 60_000;
3
4
  export const MAX_INPUT_ROUNDS = 3;
@@ -16,6 +17,5 @@ export function canonicalState(value) {
16
17
  : item);
17
18
  }
18
19
  export async function requestDigest(tool, args) {
19
- const bytes = new TextEncoder().encode(canonicalState({ tool, args }));
20
- return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
20
+ return sha256Hex(canonicalState({ tool, args }));
21
21
  }
@@ -67,7 +67,7 @@ export async function routeAccessTokens(context, manager) {
67
67
  const admin = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "access token management", context.runtimeContext, opts.config.logger);
68
68
  if (!admin.ok)
69
69
  return admin.response;
70
- // Interactive maintainers alone manage agent tokens (#837); anyone else
70
+ // Interactive maintainers alone manage agent tokens; anyone else
71
71
  // gets the same private 404 every operator route gives them.
72
72
  if (!managesAgentTokens(opts, admin) || !admin.identity.principal) {
73
73
  return privateJson({ error: "not found" }, { status: 404 });
@@ -6,7 +6,7 @@ function activityToolVisible(access, event) {
6
6
  }
7
7
  /**
8
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
9
+ * maintainers read activity, and only after the optional
10
10
  * read gate. They may see other principals' shared-connector history,
11
11
  * including request IDs, client name/version, and actor kind/id/namespace, on
12
12
  * plain /mcp and in every declared pool, within the tools that pool declares.
@@ -88,7 +88,7 @@ async function connect(context) {
88
88
  if (!authz.ok) {
89
89
  // Access sign-in is enforced at the edge. Clerk needs its own sign-in page.
90
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
91
+ // to the operator UI. Pool members cannot use that UI yet, so
92
92
  // signing in there would strand them.
93
93
  if (authz.response.status === 401 &&
94
94
  authorizationCredential(request).kind === "absent" &&
@@ -107,7 +107,7 @@ async function connect(context) {
107
107
  if (authz.principalKey !== handoff.principal || !mayManageConnector(opts, authz, connector)) {
108
108
  return refuse("Sign in as the user who requested this connection and has permission to manage it.");
109
109
  }
110
- // The operator UI serves maintainers only until member views ship (#837).
110
+ // The operator UI serves maintainers only until member views ship.
111
111
  // A member's OAuth link starts here directly, as it does without a UI.
112
112
  const viaUi = Boolean(opts.config.ui) && admitsMaintainer(opts, authz);
113
113
  if (!connector.startAuth) {
@@ -158,7 +158,7 @@ function credentialRequest(context, connectorId, action) {
158
158
  if (request.method === "DELETE") {
159
159
  // Clearing nothing is refused, as testing nothing is, so the route
160
160
  // agrees with the operator contract, which offers `clear` only once a
161
- // value is stored in the caller's partition (#837). A value that cannot
161
+ // value is stored in the caller's partition. A value that cannot
162
162
  // be read may still be cleared: removing it is how one recovers.
163
163
  const stored = yield* Effect.tryPromise({
164
164
  try: () => vault.metadata(connectorId, owner),
@@ -4,6 +4,8 @@ import * as HttpServerError from "effect/http/HttpServerError";
4
4
  import * as HttpServerRequest from "effect/http/HttpServerRequest";
5
5
  import * as HttpServerResponse from "effect/http/HttpServerResponse";
6
6
  import { notFoundResponse } from "../branding.js";
7
+ import { authorizationCredential } from "../inbound-credential.js";
8
+ import { failureRecord, logFailure } from "../operator-record.js";
7
9
  import { isAgentPath } from "./agent-path.js";
8
10
  import { MCP_CORS_HEADERS } from "./mcp.js";
9
11
  import { routeConnect } from "./connect.js";
@@ -27,6 +29,14 @@ export function createRouteDispatcher(mcp, metadata, health) {
27
29
  const afterUi = (program) => (context) => Effect.gen(function* () {
28
30
  const ui = context.opts.config.ui;
29
31
  const response = ui ? yield* Effect.promise(() => ui.handle(context)) : null;
32
+ if (response?.status === 401) {
33
+ const authorizationHeader = authorizationCredential(context.request).kind;
34
+ logFailure(context.opts.config.logger, "operator_refused", failureRecord({
35
+ reason: authorizationHeader === "invalid" ? "authorization_header_invalid" : "authentication_failed",
36
+ accessContext: Boolean(context.runtimeContext?.access),
37
+ authorizationHeader,
38
+ }, response));
39
+ }
30
40
  return response ?? (yield* program(context));
31
41
  });
32
42
  const optional = (program) => (context) => Effect.flatMap(program(context), (response) => (response ? Effect.succeed(response) : fallback(context)));
@@ -32,7 +32,7 @@ const deadlineResponses = new WeakSet();
32
32
  /**
33
33
  * The most a `subscriptions/listen` classification reads. A listen is a small
34
34
  * JSON-RPC request; this read happens before request admission, so it is
35
- * bounded by this fixed cap rather than by the host's body limit (#857).
35
+ * bounded by this fixed cap rather than by the host's body limit.
36
36
  */
37
37
  export const MAX_LISTEN_CLASSIFICATION_BYTES = 64 * 1024;
38
38
  /**
@@ -286,7 +286,7 @@ function admitted(controller, signal) {
286
286
  }), (lease) => Effect.sync(() => lease.release()), { interruptible: true });
287
287
  }
288
288
  /**
289
- * 404 for any `?toolkit=` value, kept after the feature's retirement (#178).
289
+ * 404 for any `?toolkit=` value, kept after the feature's retirement.
290
290
  *
291
291
  * Toolkits are gone, but the URLs that named them are not: clients were handed
292
292
  * MCP endpoint URLs with `?toolkit=` baked in, and nothing about upgrading the
@@ -409,6 +409,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
409
409
  credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
410
410
  ...(activity ? { activity } : {}),
411
411
  defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
412
+ maxProgramResultChars: opts.config.execute.maxResultChars,
412
413
  probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
413
414
  discoveryConcurrency: opts.config.discovery.concurrency,
414
415
  requestSignal,
@@ -504,20 +505,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
504
505
  if (!response.body)
505
506
  return response;
506
507
  const body = yield* upwardStep(authInvoke(response, "text", []));
507
- let text;
508
- if (response.headers.get("Content-Type")?.includes("application/json")) {
509
- // Structured redaction also joins text blocks before a secret split
510
- // across them can cross the serialization boundary.
511
- try {
512
- text = JSON.stringify(redactAgentOutput(sentSecrets, JSON.parse(body)));
513
- }
514
- catch {
515
- /* Non-JSON diagnostics still pass through the text boundary. */
516
- }
517
- }
518
- // Parsed JSON already passes every string through the boundary. A second
519
- // serialized pass would mistake a blob's encoding for credential bytes.
520
- text ??= redactAgentOutput(sentSecrets, body);
508
+ const text = sentSecrets.serializedAgentOutput(body, response.headers.get("Content-Type")?.includes("application/json") ?? false);
521
509
  const headers = new Headers(response.headers);
522
510
  headers.delete("Content-Length");
523
511
  return new Response(text, { status: response.status, statusText: response.statusText, headers });
@@ -651,7 +639,7 @@ export function createMcpRoute(opts) {
651
639
  }));
652
640
  // A request naming `subscriptions/listen` defers its permit: nothing of
653
641
  // its body is read until auth and endpoint admission pass, and then at
654
- // most MAX_LISTEN_CLASSIFICATION_BYTES (#857). Only a body-confirmed
642
+ // most MAX_LISTEN_CLASSIFICATION_BYTES. Only a body-confirmed
655
643
  // modern listen goes without a permit; trusting the header alone would
656
644
  // let a legacy tools/call bypass the pool, so anything else takes one
657
645
  // after classification. Every other request takes its permit here,
@@ -662,15 +650,21 @@ export function createMcpRoute(opts) {
662
650
  const refuseAdmission = (error) => {
663
651
  if (!(error instanceof ExecutorAdmissionError))
664
652
  return Effect.fail(error);
665
- if (error.code === "executor_cancelled")
653
+ if (error.code === "executor_cancelled") {
654
+ // The lifetime timer can withdraw a fallback waiter before the
655
+ // enclosing timeout settles. Keep its public deadline response.
656
+ if (localAbort.signal.aborted && !request.signal.aborted) {
657
+ return Effect.succeed(cors(mcpRefusal(sentSecrets, 504, -33003, "MCP request lifetime exceeded.")));
658
+ }
666
659
  return Effect.fail(request.signal.reason ?? error);
660
+ }
667
661
  if (error.code === "executor_overloaded")
668
662
  warnAdmissionRejected(error);
669
663
  return Effect.succeed(cors(requestAdmissionFailure(sentSecrets, error)));
670
664
  };
671
665
  const admission = listenHeader
672
666
  ? Result.succeed(undefined)
673
- : yield* Effect.result(admitted(opts.requestAdmission, request.signal));
667
+ : yield* Effect.result(admitted(opts.requestAdmission, localAbort.signal));
674
668
  if (Result.isFailure(admission))
675
669
  return yield* refuseAdmission(admission.failure);
676
670
  const remainingMs = admission.success
@@ -687,7 +681,15 @@ export function createMcpRoute(opts) {
687
681
  localAbort.abort(new Error("MCP request lifetime exceeded."));
688
682
  }, remainingMs);
689
683
  }
690
- const localRequest = new Request(request, { signal: localAbort.signal });
684
+ // Pass the body explicitly: inheriting it makes Node add an identity
685
+ // TransformStream whose pull-ahead can exceed the inspection bound.
686
+ const localRequest = new Request(request, {
687
+ signal: localAbort.signal,
688
+ // This is an inbound SDK view, not an outgoing keepalive or no-cors
689
+ // fetch; Node rejects an explicit stream with either.
690
+ ...(request.body ? { body: request.body, duplex: "half", keepalive: false } : {}),
691
+ ...(request.body && request.mode === "no-cors" ? { mode: "cors" } : {}),
692
+ });
691
693
  if (admission.success && admission.success.waitMs > 0) {
692
694
  opts.config.logger.debug("[connecta] MCP request admitted after queue wait", {
693
695
  waitMs: admission.success.waitMs,
@@ -803,7 +805,7 @@ export function createMcpRoute(opts) {
803
805
  return cors(mcpRefusal(sentSecrets, 413, -33006, "MCP request body too large."));
804
806
  }
805
807
  if (listenHeader && verdict === "other") {
806
- const late = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
808
+ const late = yield* Effect.result(admitted(opts.requestAdmission, localAbort.signal));
807
809
  if (Result.isFailure(late))
808
810
  return yield* refuseAdmission(late.failure);
809
811
  }
@@ -23,7 +23,7 @@ function oauthManagementRequest(context, connectorId) {
23
23
  const registry = yield* managementRegistry(context, authz);
24
24
  const connector = registry.getConnector(connectorId);
25
25
  const disconnectAuth = connector?.disconnectAuth?.bind(connector);
26
- // Each verb needs only its own hooks (#837): connecting needs the whole
26
+ // Each verb needs only its own hooks: connecting needs the whole
27
27
  // flow, through the callback's state check; disconnecting needs only
28
28
  // disconnectAuth, so a grant provisioned out of band can still be dropped.
29
29
  const connects = Boolean(connector?.startAuth && connector.finishAuth && connector.verifyState);
@@ -1,3 +1,4 @@
1
+ import { bytesToHex } from "../bytes.js";
1
2
  // The Effect plumbing the operator and activity data routes share.
2
3
  //
3
4
  // Each JSON route under /ui/* is one Effect program, run at its Promise edge
@@ -21,7 +22,8 @@
21
22
  //
22
23
  // Imported by the /ui and /activity graphs only; never from the root entry.
23
24
  import { Effect } from "effect";
24
- import { runEdge } from "../runtime/run.js";
25
+ import { operatorViewerKeys } from "../storage/keys.js";
26
+ import { runEdge, withDeadlineEffect } from "../runtime/run.js";
25
27
  import { isMaintainer } from "../membership.js";
26
28
  import { admitsMaintainer, authorize, authorizeUiIdentity, memberConnectors, privateJson, } from "./shared.js";
27
29
  /** A response the route has already decided on. */
@@ -47,36 +49,80 @@ export function authorized({ request, baseUrl, opts, runtimeContext }, partition
47
49
  export function authorizedPerson({ request, baseUrl, opts, runtimeContext }, purpose) {
48
50
  return Effect.suspend(() => admitted(authorizeUiIdentity(request, baseUrl, opts.config.auth, purpose, runtimeContext, opts.config.logger)));
49
51
  }
52
+ // Cache resolved keys only; request promises and identities never outlive a read.
53
+ const viewerSigningKeys = new WeakMap();
54
+ /** Stable across isolates, opaque even for guessable Access email identities. */
55
+ export function operatorViewerKey(context, authz) {
56
+ const attempt = (read) => Effect.tryPromise({ try: read, catch: (error) => error });
57
+ return withDeadlineEffect(() => Effect.gen(function* () {
58
+ const storage = context.opts.config.storage;
59
+ let key = viewerSigningKeys.get(storage);
60
+ if (!key) {
61
+ let raw = yield* attempt(() => storage.get(operatorViewerKeys.signingKey));
62
+ if (raw === null) {
63
+ const candidate = crypto.randomUUID();
64
+ raw = (yield* attempt(() => storage.compareAndSet(operatorViewerKeys.signingKey, null, candidate)))
65
+ ? candidate
66
+ : yield* attempt(() => storage.get(operatorViewerKeys.signingKey));
67
+ }
68
+ if (!raw || !/^[0-9a-f-]{36}$/.test(raw))
69
+ return yield* Effect.fail(new Error("Operator viewer key unavailable"));
70
+ const bytes = new TextEncoder().encode(raw);
71
+ key = yield* attempt(() => crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]));
72
+ viewerSigningKeys.set(storage, key);
73
+ }
74
+ const { principal, subject, actor, interactive, agent } = authz.identity;
75
+ const identity = new TextEncoder().encode(JSON.stringify([
76
+ principal?.namespace,
77
+ principal?.id,
78
+ subject?.namespace,
79
+ subject?.id,
80
+ actor.kind,
81
+ actor.namespace,
82
+ actor.id,
83
+ interactive,
84
+ agent,
85
+ ]));
86
+ const signingKey = key;
87
+ const digest = yield* attempt(() => crypto.subtle.sign("HMAC", signingKey, identity));
88
+ return bytesToHex(new Uint8Array(digest));
89
+ }), {
90
+ timeoutMs: 500,
91
+ signal: context.request.signal,
92
+ timeoutError: new Error("Operator viewer key timed out"),
93
+ }).pipe(Effect.catch(() => refuse("operator identity could not be read", 503)));
94
+ }
50
95
  /** The partition an identity carries into a registry view of the given connectors. */
51
- export function scopeFor(authz, connectorIds = "all") {
96
+ export function scopeFor(authz, authenticated, connectorIds = "all") {
52
97
  return {
53
98
  connectorIds,
54
99
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
55
100
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
101
+ operatorCaller: { identity: authz.identity, authenticated },
56
102
  };
57
103
  }
58
104
  /**
59
- * The whole registry, for a maintainer only (#837). Everyone else gets the
105
+ * The whole registry, for a maintainer only. Everyone else gets the
60
106
  * private 404 plain `/mcp` gives them.
61
107
  */
62
108
  export function visibleRegistry({ opts }, authz) {
63
109
  // Operator catalogs describe the default endpoint's view, never a pool view.
64
110
  if (!admitsMaintainer(opts, authz))
65
111
  return refuse("not found", 404);
66
- return Effect.succeed(opts.registry.scoped(scopeFor(authz)));
112
+ return Effect.succeed(opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0)));
67
113
  }
68
114
  /**
69
- * The connectors a connector-auth route may resolve (#837): every connector
115
+ * The connectors a connector-auth route may resolve: every connector
70
116
  * for a maintainer, the connectors of their pools for a pool member, and the
71
117
  * private 404 for anyone else. mayManageConnector still decides each action.
72
118
  */
73
119
  export function managementRegistry({ opts }, authz) {
74
120
  if (isMaintainer(opts.maintainers, authz.identity))
75
- return Effect.succeed(opts.registry.scoped(scopeFor(authz)));
121
+ return Effect.succeed(opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0)));
76
122
  const ids = memberConnectors(opts, authz.identity);
77
123
  if (ids !== "all" && ids.size === 0)
78
124
  return refuse("not found", 404);
79
- return Effect.succeed(opts.registry.scoped(scopeFor(authz, ids === "all" ? "all" : [...ids])));
125
+ return Effect.succeed(opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0, ids === "all" ? "all" : [...ids])));
80
126
  }
81
127
  /**
82
128
  * Run one route program to its Response.
@@ -78,7 +78,7 @@ export declare function authorize(request: Request, baseUrl: string, auth: reado
78
78
  response: Response;
79
79
  }>;
80
80
  /**
81
- * Admission at an agent endpoint, `/agent/<pool>` (#837). Only the built-in
81
+ * Admission at an agent endpoint, `/agent/<pool>`. Only the built-in
82
82
  * agent credential adapters are consulted: Clerk and Access never run, cookies and Clerk
83
83
  * handshake parameters are stripped, and the runtime context, which carries
84
84
  * Access's `ctx.access` even on a bypassed path, is never passed. Anything but
@@ -88,31 +88,31 @@ export declare function authorizeAgent(request: Request, baseUrl: string, auth:
88
88
  /** Human routes reject machine credentials before any verification or storage I/O. */
89
89
  export declare function authorizeUiIdentity(request: Request, baseUrl: string, auth: readonly InboundAuth[], purpose: string, runtimeContext?: RuntimeExecutionContext, logger?: Logger): Promise<Awaited<ReturnType<typeof authorize>>>;
90
90
  export declare function isSameOrigin(request: Request, baseUrl: string): boolean;
91
- export declare function withSecurityHeaders(response: Response, requestUrl: URL, _path: string): Response;
91
+ export declare function withSecurityHeaders(response: Response, requestUrl: URL): Response;
92
92
  type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
93
93
  ok: true;
94
94
  }>;
95
95
  /**
96
- * Plain `/mcp` and every operator route admit a maintainer (#837). An open
96
+ * Plain `/mcp` and every operator route admit a maintainer. An open
97
97
  * deployment has no inbound auth and so no maintainers: its one anonymous
98
98
  * caller keeps the whole default endpoint, as the startup warning says.
99
99
  */
100
100
  export declare function admitsMaintainer(opts: ServerOptions, authz: AuthorizedIdentity): boolean;
101
101
  /**
102
102
  * The declared pool `/mcp/<name>` serves to this person, or undefined for the
103
- * same 404. An agent is never admitted here, even when listed (#837).
103
+ * same 404. An agent is never admitted here, even when listed.
104
104
  */
105
105
  export declare function memberPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
106
106
  /** The declared pool `/agent/<name>` serves to this agent, or undefined for the same 404. */
107
107
  export declare function agentPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
108
108
  /**
109
- * The connectors of every pool this identity is a member of (#837): what a
109
+ * The connectors of every pool this identity is a member of: what a
110
110
  * pool member may ever manage, and only their personal ones among these.
111
111
  */
112
112
  export declare function memberConnectors(opts: ServerOptions, identity: Readonly<AuthenticatedIdentity>): "all" | ReadonlySet<string>;
113
113
  /**
114
114
  * Whether this identity may start, finish, or remove a connector's auth
115
- * (#837). Only an interactive person ever may; an agent or any other machine
115
+ * Only an interactive person ever may; an agent or any other machine
116
116
  * credential manages nothing. Shared auth is deployment-wide, so it takes a
117
117
  * maintainer. A personal connector takes a stable principal that is a
118
118
  * maintainer, whose plain `/mcp` includes it, or a member of a pool that
@@ -124,10 +124,10 @@ export declare function mayManageConnector(opts: ServerOptions, authz: Authorize
124
124
  id: string;
125
125
  authScope?: "shared" | "personal";
126
126
  }): boolean;
127
- /** Agent token list, issue, rotate, and revoke: an interactive maintainer only (#837). */
127
+ /** Agent token list, issue, rotate, and revoke: an interactive maintainer only. */
128
128
  export declare function managesAgentTokens(opts: ServerOptions, authz: AuthorizedIdentity): boolean;
129
129
  /**
130
- * Activity reads: a maintainer principal, interactive or not (#837). Members
130
+ * Activity reads: a maintainer principal, interactive or not. Members
131
131
  * and agents read none, and an open deployment has no maintainer.
132
132
  */
133
133
  export declare function readsActivity(opts: ServerOptions, authz: AuthorizedIdentity): boolean;