@zackbart/connecta 0.30.0 → 0.32.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 (133) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +21 -16
  3. package/bin/connecta.mjs +1 -0
  4. package/bin/version.mjs +1 -1
  5. package/dist/auth-elicitation.js +1 -1
  6. package/dist/call-recovery.js +25 -0
  7. package/dist/catalog-service.js +7 -2
  8. package/dist/config-schema.d.ts +14 -7
  9. package/dist/config-schema.js +29 -10
  10. package/dist/config-value-sources.js +6 -3
  11. package/dist/config.d.ts +26 -8
  12. package/dist/config.js +38 -16
  13. package/dist/connector-access.d.ts +2 -1
  14. package/dist/connectors/guarded-fetch.d.ts +18 -1
  15. package/dist/connectors/guarded-fetch.js +21 -2
  16. package/dist/describe-config.d.ts +6 -3
  17. package/dist/describe-config.js +3 -2
  18. package/dist/errors.d.ts +3 -1
  19. package/dist/errors.js +1 -0
  20. package/dist/execute.js +16 -14
  21. package/dist/index.js +4 -3
  22. package/dist/invocation.js +39 -4
  23. package/dist/meta-tools.js +17 -19
  24. package/dist/operator-record.d.ts +2 -2
  25. package/dist/operator-record.js +2 -0
  26. package/dist/operator-ui/contract.d.ts +5 -4
  27. package/dist/operator-ui/generated.js +2 -2
  28. package/dist/operator-ui/model.d.ts +1 -1
  29. package/dist/operator-ui/view.d.ts +9 -0
  30. package/dist/operator-ui/view.js +12 -0
  31. package/dist/program-writes.js +4 -49
  32. package/dist/provider.d.ts +6 -5
  33. package/dist/provider.js +3 -4
  34. package/dist/providers/_shared/google/workspace.js +1 -1
  35. package/dist/providers/_shared/rest/dispatch.js +41 -0
  36. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  37. package/dist/providers/_shared/rest/operation-index.js +527 -0
  38. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  39. package/dist/providers/_shared/rest/tools.js +569 -0
  40. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  41. package/dist/providers/_shared/rest/value-safety.js +580 -0
  42. package/dist/providers/breeze/skill.generated.js +2 -2
  43. package/dist/providers/cloudflare/graphql.js +385 -0
  44. package/dist/providers/cloudflare/index.d.ts +47 -51
  45. package/dist/providers/cloudflare/index.js +188 -1997
  46. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  47. package/dist/providers/cloudflare/rest.d.ts +37 -0
  48. package/dist/providers/cloudflare/rest.js +1056 -0
  49. package/dist/providers/cloudflare/skill.generated.js +7 -4
  50. package/dist/providers/cloudflare/value-safety.js +1247 -0
  51. package/dist/providers/docs/index.d.ts +1 -1
  52. package/dist/providers/docs/index.js +2 -2
  53. package/dist/providers/drive/index.d.ts +1 -1
  54. package/dist/providers/drive/index.js +1 -1
  55. package/dist/providers/forms/index.d.ts +1 -1
  56. package/dist/providers/forms/index.js +2 -2
  57. package/dist/providers/github/skill.generated.js +2 -2
  58. package/dist/providers/gmail/index.d.ts +1 -1
  59. package/dist/providers/gmail/index.js +274 -23
  60. package/dist/providers/gmail/skill.generated.js +2 -2
  61. package/dist/providers/linear/index.js +1 -1
  62. package/dist/providers/mixpanel/index.d.ts +27 -12
  63. package/dist/providers/mixpanel/index.js +58 -6
  64. package/dist/providers/mixpanel/operations.js +20 -0
  65. package/dist/providers/mixpanel/rest.js +603 -0
  66. package/dist/providers/mixpanel/skill.generated.js +3 -2
  67. package/dist/providers/mixpanel/value-safety.js +16 -0
  68. package/dist/providers/notion/index.d.ts +35 -31
  69. package/dist/providers/notion/index.js +76 -1499
  70. package/dist/providers/notion/openapi.generated.js +75 -0
  71. package/dist/providers/notion/rest.d.ts +24 -0
  72. package/dist/providers/notion/rest.js +1186 -0
  73. package/dist/providers/notion/skill.generated.js +5 -4
  74. package/dist/providers/notion/value-safety.js +31 -0
  75. package/dist/providers/planning-center/index.d.ts +27 -0
  76. package/dist/providers/planning-center/index.js +286 -24
  77. package/dist/providers/planning-center/skill.generated.js +24 -4
  78. package/dist/providers/sheets/index.d.ts +1 -1
  79. package/dist/providers/sheets/index.js +3 -3
  80. package/dist/providers/slides/index.d.ts +1 -1
  81. package/dist/providers/slides/index.js +3 -3
  82. package/dist/providers/stripe/index.d.ts +26 -20
  83. package/dist/providers/stripe/index.js +103 -130
  84. package/dist/providers/stripe/openapi.generated.js +649 -0
  85. package/dist/providers/stripe/rest.d.ts +14 -0
  86. package/dist/providers/stripe/rest.js +395 -0
  87. package/dist/providers/stripe/skill.generated.js +7 -5
  88. package/dist/providers/stripe/value-safety.js +705 -0
  89. package/dist/providers/vercel/index.d.ts +31 -22
  90. package/dist/providers/vercel/index.js +96 -853
  91. package/dist/providers/vercel/openapi.generated.js +456 -0
  92. package/dist/providers/vercel/rest.d.ts +20 -0
  93. package/dist/providers/vercel/rest.js +1002 -0
  94. package/dist/providers/vercel/skill.generated.js +4 -5
  95. package/dist/providers/vercel/value-safety.js +328 -0
  96. package/dist/providers/wispr-flow/index.d.ts +14 -0
  97. package/dist/providers/wispr-flow/index.js +117 -0
  98. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  99. package/dist/routes/mcp.js +30 -10
  100. package/dist/routes/operator.js +14 -7
  101. package/dist/routes/ui-config.js +2 -2
  102. package/dist/server.js +3 -0
  103. package/dist/skills.js +5 -4
  104. package/dist/tool-safety.js +13 -5
  105. package/dist/types.d.ts +1 -1
  106. package/dist/ui.d.ts +1 -1
  107. package/dist/ui.js +1 -1
  108. package/dist/usage-guide.js +3 -3
  109. package/dist/version.d.ts +1 -1
  110. package/dist/version.js +1 -1
  111. package/dist/write-outcome.js +47 -0
  112. package/documentation/README.md +1 -0
  113. package/documentation/architecture.md +155 -28
  114. package/documentation/auth.md +86 -27
  115. package/documentation/code-mode.md +81 -72
  116. package/documentation/deploying.md +10 -5
  117. package/documentation/integrating.md +72 -9
  118. package/documentation/meta-tools.md +52 -38
  119. package/documentation/operating.md +11 -8
  120. package/documentation/operator-tests.md +2 -3
  121. package/documentation/operator-ui.md +9 -3
  122. package/documentation/provider-auth-migration.md +308 -0
  123. package/documentation/provider-migration-0.29.md +18 -6
  124. package/examples/worker/AGENTS.md +5 -0
  125. package/examples/worker/README.md +2 -2
  126. package/examples/worker/src/connecta.config.ts +5 -0
  127. package/package.json +7 -2
  128. package/templates/node/AGENTS.md +6 -4
  129. package/templates/node/README.md +6 -4
  130. package/templates/node/package.json +1 -1
  131. package/templates/node/src/connecta.config.ts +5 -0
  132. package/dist/providers/vercel/mcp-ownership.js +0 -212
  133. package/dist/tool-safety.d.ts +0 -9
@@ -294,7 +294,7 @@ function toolkitRetired(logger, sentSecrets) {
294
294
  },
295
295
  });
296
296
  }
297
- function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, trust = "read-only", pool, principalActor) {
297
+ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor) {
298
298
  const sentSecrets = sentSecretsForRequest(requestScope);
299
299
  // Every McpServer the request builds is fresh and closes with its scope.
300
300
  // The modern handler tears its own down after the exchange; the legacy
@@ -361,7 +361,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
361
361
  client,
362
362
  baseUrl,
363
363
  requestScope,
364
- trust,
364
+ readOnly,
365
365
  canManageAuth,
366
366
  oauthConnectUrl: (id, force) => oauthConnectUrl(opts, baseUrl, id, principalKey, force),
367
367
  oauthConnectUnavailable: oauthConnectUnavailable(opts),
@@ -385,7 +385,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
385
385
  requestSignal,
386
386
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
387
387
  ...executeLimits(opts.config),
388
- trust,
388
+ readOnly,
389
389
  });
390
390
  servers.push(server);
391
391
  return server;
@@ -517,6 +517,8 @@ export function createMcpRoute(opts) {
517
517
  };
518
518
  function routeMcp(context) {
519
519
  const { path, request, baseUrl, runtimeContext } = context;
520
+ // Protected-resource metadata stays public, including when a grant is
521
+ // configured, so a fresh OAuth client can discover how to authenticate.
520
522
  if (path !== "/mcp" && !path.startsWith("/mcp/"))
521
523
  return Effect.succeed(null);
522
524
  // Admission, refusal bodies, SDK serialization and connector work share
@@ -644,7 +646,23 @@ export function createMcpRoute(opts) {
644
646
  // one identical 404 so a credential never enumerates the other pools;
645
647
  // the operator log is where the reason lives.
646
648
  let access = authz;
647
- let trust = opts.config.trust;
649
+ let readOnly = opts.config.readOnly;
650
+ if (poolName === undefined && opts.config.grant) {
651
+ const denialReason = yield* Effect.promise(async () => {
652
+ try {
653
+ return (await opts.config.grant(authz.identity)) === true
654
+ ? undefined
655
+ : "default_grant_denied";
656
+ }
657
+ catch {
658
+ return "default_grant_threw";
659
+ }
660
+ });
661
+ if (denialReason !== undefined) {
662
+ logFailure(opts.config.logger, "MCP default endpoint request denied", failureRecord({ reason: denialReason }));
663
+ return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
664
+ }
665
+ }
648
666
  if (poolName !== undefined) {
649
667
  const pool = opts.pools?.get(poolName);
650
668
  const denialReason = !pool
@@ -662,7 +680,7 @@ export function createMcpRoute(opts) {
662
680
  return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
663
681
  }
664
682
  access = intersectAccess(authz, pool.access);
665
- trust = pool.trust;
683
+ readOnly = pool.readOnly;
666
684
  }
667
685
  let scopedRegistry;
668
686
  try {
@@ -685,21 +703,23 @@ export function createMcpRoute(opts) {
685
703
  if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
686
704
  return false;
687
705
  validateAuthPermissions(current, opts.registry);
706
+ if (poolName === undefined && opts.config.grant && (await opts.config.grant(current.identity)) !== true)
707
+ return false;
688
708
  let currentAccess = current;
689
- let currentTrust = opts.config.trust;
709
+ let currentReadOnly = opts.config.readOnly;
690
710
  if (poolName !== undefined) {
691
711
  const pool = opts.pools.get(poolName);
692
712
  if (!pool || (await pool.grant(current.identity)) !== true)
693
713
  return false;
694
714
  currentAccess = intersectAccess(current, pool.access);
695
- currentTrust = pool.trust;
715
+ currentReadOnly = pool.readOnly;
696
716
  }
697
717
  const view = opts.registry.scoped({
698
718
  ...currentAccess,
699
719
  ...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
700
720
  ...(current.principalKey ? { principalKey: current.principalKey } : {}),
701
721
  });
702
- if (classification === "write" && currentTrust !== "trusted")
722
+ if (classification === "write" && currentReadOnly)
703
723
  return false;
704
724
  if (address === undefined)
705
725
  return true;
@@ -716,7 +736,7 @@ export function createMcpRoute(opts) {
716
736
  return Boolean(tool &&
717
737
  (access === "catalog" ||
718
738
  (classification === "read" && tool.classification === "read") ||
719
- currentTrust === "trusted"));
739
+ !currentReadOnly));
720
740
  }
721
741
  finally {
722
742
  await closeConnectorScope(resolved.connector, view.contextFor(resolved.connector.id, baseUrl, requestScope), runtimeContext?.waitUntil?.bind(runtimeContext));
@@ -740,7 +760,7 @@ export function createMcpRoute(opts) {
740
760
  return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
741
761
  const connector = scopedRegistry.getConnector(id);
742
762
  return Boolean(connector && mayManageConnector(authz, connector));
743
- }, authz.principalKey, runtimeContext, trust, poolName, authz.identity.principal
763
+ }, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
744
764
  ? {
745
765
  kind: authz.actor.kind,
746
766
  id: authz.identity.principal.id,
@@ -62,13 +62,20 @@ export function scopeFor(authz, connectorIds = authz.connectorIds) {
62
62
  * included, rather than quietly granting what remains.
63
63
  */
64
64
  export function visibleRegistry({ opts }, authz) {
65
- return Effect.try({
66
- try: () => {
67
- validateAuthPermissions(authz, opts.registry);
68
- return opts.registry.scoped(scopeFor(authz));
69
- },
70
- catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
71
- });
65
+ // Operator catalogs describe the default endpoint's view, never a pool view.
66
+ // Authentication alone cannot admit a caller that deployment code refuses.
67
+ const admission = opts.config.grant
68
+ ? Effect.tryPromise(async () => (await opts.config.grant(authz.identity)) === true).pipe(Effect.orElseSucceed(() => false))
69
+ : Effect.succeed(true);
70
+ return Effect.flatMap(admission, (allowed) => allowed
71
+ ? Effect.try({
72
+ try: () => {
73
+ validateAuthPermissions(authz, opts.registry);
74
+ return opts.registry.scoped(scopeFor(authz));
75
+ },
76
+ catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
77
+ })
78
+ : refuse("not found", 404));
72
79
  }
73
80
  /**
74
81
  * Run one route program to its Response.
@@ -154,7 +154,7 @@ function configRead(context) {
154
154
  pools.push({
155
155
  name,
156
156
  path: `/mcp/${name}`,
157
- trust: pool.trust,
157
+ readOnly: pool.readOnly,
158
158
  grants: grants(intersectAccess(authz, pool.access), ids),
159
159
  });
160
160
  }
@@ -199,7 +199,7 @@ function configRead(context) {
199
199
  you: {
200
200
  interactive: authz.identity.interactive,
201
201
  grants: grants(authz, ids),
202
- trust: opts.config.trust,
202
+ readOnly: opts.config.readOnly,
203
203
  pools,
204
204
  permissions: {
205
205
  activity: activity === "available" || activity === "unavailable",
package/dist/server.js CHANGED
@@ -88,6 +88,9 @@ export function createFetchHandler(opts) {
88
88
  });
89
89
  }
90
90
  if (path.startsWith("/.well-known/")) {
91
+ const guardedMetadata = yield* routeMcp.handle(context);
92
+ if (guardedMetadata)
93
+ return guardedMetadata;
91
94
  const response = yield* metadata(request, baseUrl);
92
95
  if (response)
93
96
  return response;
package/dist/skills.js CHANGED
@@ -7,11 +7,12 @@ import { sentSecretsForRequest } from "./sent-secrets.js";
7
7
  const ROUTE = "Choose a route before discovery. One known-address read uses call_tool; one known-address write uses call_destructive_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Sample unfamiliar reads.";
8
8
  const RECOVERY = 'When an auth error includes a handoff, give it to the user or operator. Use authorize_connector for a fresh link. Page resultId notices and format:"paged" handles with connecta.result(handleOrId, { page? }). Fetch skills({ name: "usage" }) only for missing guidance or program repair.';
9
9
  /**
10
- * The always-loaded MCP `instructions` string. A program runs reads and the
11
- * writes in trusted pools; a read-only pool routes every write to top-level
12
- * call_destructive_tool, where the host's permission prompt is the approval.
10
+ * The always-loaded MCP `instructions` string. An endpoint is writable or
11
+ * read-only: a writable one lets programs and call_destructive_tool write,
12
+ * and the client owns approval; a read-only one omits write tools from search
13
+ * and describe and refuses every write call. Guides may still name them.
13
14
  */
14
- export const CONNECTA_INSTRUCTIONS = `${ROUTE} Programs call reads in read-only pools and may also write in trusted pools. The execute_code description names this endpoint's trust. In a read-only pool, discover writes with search_tools then use call_destructive_tool. Never repeat a write to recover its output. ${RECOVERY}`;
15
+ export const CONNECTA_INSTRUCTIONS = `${ROUTE} On a writable endpoint, programs and call_destructive_tool may write. On a read-only endpoint, search omits write tools and every write call is refused; guides may still name them. The execute_code description says which this endpoint is. Never repeat a write to recover its output. ${RECOVERY}`;
15
16
  export { USAGE_SKILL } from "./usage-guide.js";
16
17
  /** True when at least one of `connectors` carries a usage guide. */
17
18
  export function hasConnectorGuides(connectors) {
@@ -14,9 +14,17 @@ export function classifyTool(definition, override, review) {
14
14
  ? "read"
15
15
  : "write";
16
16
  }
17
- /** The only permission decision for a connector call. Approval is host-owned. */
18
- export function surfaceAllowsTool(classification, surface, trust = "read-only") {
19
- return (classification === "read" ||
20
- surface === "call_destructive_tool" ||
21
- (surface === "execute_code" && trust === "trusted"));
17
+ /**
18
+ * The only permission decision for a connector call. Approval is client-owned.
19
+ * A read-only endpoint refuses every write on every surface; a writable one
20
+ * sends writes through call_destructive_tool and programs, never call_tool.
21
+ */
22
+ export function writeRefusal(classification, surface, readOnly = false) {
23
+ if (classification === "read")
24
+ return undefined;
25
+ if (readOnly)
26
+ return "read_only_endpoint";
27
+ return surface === "call_destructive_tool" || surface === "execute_code"
28
+ ? undefined
29
+ : "destructive_tool_requires_approval";
22
30
  }
package/dist/types.d.ts CHANGED
@@ -95,7 +95,7 @@ export interface ToolDef {
95
95
  * Standard MCP tool behavior hints plus provider-specific extensions.
96
96
  * The registry classifies these after deployment overrides and provider
97
97
  * review. Only a stored read verdict may use call_tool; program writes
98
- * require a trusted pool. Approval belongs to the host.
98
+ * require a writable endpoint. Approval belongs to the host.
99
99
  */
100
100
  annotations?: ToolAnnotations;
101
101
  }
package/dist/ui.d.ts CHANGED
@@ -9,7 +9,7 @@ export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, i
9
9
  export type OperatorPage = import("./operator-ui/view.js").OperatorPage;
10
10
  export declare function operatorPageForPath(path: string): OperatorPage | undefined;
11
11
  export declare function operatorPageTitle(page: OperatorPage, configuredTitle: string): string;
12
- /** Report the verdict the registry stored, independent of pool trust. */
12
+ /** Report the verdict the registry stored, independent of whether an endpoint is read-only. */
13
13
  export declare function uiToolSafety(definition: ToolDef): UiToolSafety;
14
14
  /**
15
15
  * The fix-prompt key for a connector that is not usable. Chosen from the
package/dist/ui.js CHANGED
@@ -40,7 +40,7 @@ export function operatorPageForPath(path) {
40
40
  export function operatorPageTitle(page, configuredTitle) {
41
41
  return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
42
42
  }
43
- /** Report the verdict the registry stored, independent of pool trust. */
43
+ /** Report the verdict the registry stored, independent of whether an endpoint is read-only. */
44
44
  export function uiToolSafety(definition) {
45
45
  if (definition.classification === "read")
46
46
  return "runs_in_programs";
@@ -16,9 +16,9 @@ description: How to route work between one execute_code program and Connecta's e
16
16
 
17
17
  Read one account with call_tool: \`{ "address": "crm.get_account", "args": { "id": "acct_42" } }\`.
18
18
  Discover and call together when schemas suffice. search_tools covers catalog
19
- inspection and write discovery in read-only pools. Programs may
20
- write only in trusted pools; the tool description states this endpoint's trust.
21
- Unannotated tools fail closed to writes.
19
+ inspection. On a read-only endpoint, as execute_code's description says, search
20
+ omits writes (guides may name them) and each fails read_only_endpoint; tell the
21
+ user. Unannotated tools fail closed to writes.
22
22
 
23
23
  ## The global and the envelopes
24
24
 
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CONNECTA_VERSION = "0.30.0";
1
+ export declare const CONNECTA_VERSION = "0.32.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs.
2
2
  // Workers-safe: no runtime filesystem read or deployment override.
3
- export const CONNECTA_VERSION = "0.30.0";
3
+ export const CONNECTA_VERSION = "0.32.0";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Whether a write landed, as far as anyone can know.
3
+ *
4
+ * `unknown` is the outcome a result must never paper over: the call left
5
+ * connecta and no answer came back, so sending it again could do it twice and
6
+ * not sending it could leave it undone. That is a dispatched call that timed
7
+ * out, was cancelled, found the service unavailable, or failed any way that
8
+ * is not a verdict. A refusal code (`REFUSALS`) or a downstream tool's own
9
+ * `isError` is an answer, so it is `failed`. Response text does not decide
10
+ * whether an outcome is unknown. A call that was never dispatched is
11
+ * `failed`: nothing was sent.
12
+ * `result_processing_failed` means the downstream call completed.
13
+ */
14
+ export function classifyWriteOutcome(outcome) {
15
+ if (outcome.ok)
16
+ return "ok";
17
+ if (!outcome.dispatched)
18
+ return "failed";
19
+ const code = outcome.error?.code;
20
+ if (code === "result_processing_failed")
21
+ return "ok";
22
+ if (code === "timeout" || code === "cancelled" || code === "unavailable") {
23
+ return "unknown";
24
+ }
25
+ if (code !== undefined && REFUSALS.has(code))
26
+ return "failed";
27
+ return outcome.answered === true ? "failed" : "unknown";
28
+ }
29
+ /**
30
+ * Codes that say the other side refused the call rather than acted on it:
31
+ * the credential, the arguments, the resource, the rate, or a base version
32
+ * someone else already moved past (`conflict`). Anything else a
33
+ * connector reports after dispatch — `connector_call_failed` from a response
34
+ * too large to read, a redirect it would not follow, a body it could not
35
+ * parse, a 5xx — may come after the write landed, so only a downstream
36
+ * tool's own `isError` answer makes it a known failure.
37
+ */
38
+ const REFUSALS = new Set([
39
+ "auth_required",
40
+ "downstream_oauth_required",
41
+ "provider_permission_denied",
42
+ "invalid_args",
43
+ "not_found",
44
+ "rate_limited",
45
+ "input_required_unsupported",
46
+ "conflict",
47
+ ]);
@@ -22,6 +22,7 @@ service, or change Connecta itself.
22
22
  | [Operator UI](./operator-ui.md) | Optional UI setup, scoped configuration and live-data contract, activity disclosure, and UI implementation |
23
23
  | [Operator UI tests](./operator-tests.md) | Browser state matrix, real-server flows, and snapshot maintenance |
24
24
  | [Provider migration for 0.29](./provider-migration-0.29.md) | Upgrade actions for Notion, Vercel, and Cloudflare; provider reconciliation tables supply exact name mappings |
25
+ | [Provider auth migration](./provider-auth-migration.md) | Upgrade actions when `auth` selects hosted MCP or Connecta's REST connector, Stripe and Vercel |
25
26
 
26
27
  Each long contract starts with task routes. Follow those section links before
27
28
  reading the whole guide. Source and test pointers identify where to check a
@@ -85,20 +85,20 @@ table, security headers. Route _order_ is the contract — several routes would
85
85
  behave differently if they were reachable in another order — so read the table
86
86
  top to bottom.
87
87
 
88
- | Order | Route | Notes |
89
- | ----- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
90
- | 0 | MCP Origin check | A disallowed `Origin` on `/mcp*` is a fixed 403 before redirects, admission, auth, or preflight — costing no permit and no auth lookup. Originless requests are admitted. |
91
- | 0 | HTTPS upgrade | 308 to an HTTPS `publicUrl`, with path and query _assigned_ onto it rather than resolved against it, so a `//host` pathname cannot replace the origin. `/health` is exempt: a loopback probe must not need public DNS. |
92
- | 0 | Cloudflare Access (Worker, when enabled) | Edge admission ahead of this table; an admitted invocation carries trusted identity in `ctx.access`. |
93
- | 1 | `/oauth/client-metadata/<connectorId>` | Public GET-only CIMD for configured self-hosted OAuth connectors; fixed `publicUrl`, no auth or storage. |
94
- | 1 | Mounted UI routes | Before wildcard OPTIONS, so mutation routes refuse preflight rather than inheriting MCP CORS. No UI module, no routes. |
95
- | 1 | `/connect/<connectorId>`, `/oauth/callback/<connectorId>` | GET-only browser OAuth routes, before wildcard OPTIONS. Both verify the initiating user and management permission, independent of the UI. |
96
- | 2 | MCP preflight | Allowed `OPTIONS` on `/mcp*`: 204 without admission or auth. |
97
- | 2 | Other `OPTIONS` | Auth metadata first, otherwise compatibility CORS preflight. |
98
- | 3 | `/.well-known/*` | Auth metadata, or 404. |
99
- | 4 | `/health` | Open and payload-free: health, executor, admission, and deployment metadata. |
100
- | 6 | `/mcp`, `/mcp/<pool>` | Admission, then auth, then a request-local MCP server. Body-confirmed modern listens skip admission and are refused after auth and SDK validation. An undeclared pool, a refusing grant, and a throwing grant are one identical 404; see [pools](./auth.md#pools). |
101
- | 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
88
+ | Order | Route | Notes |
89
+ | ----- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
90
+ | 0 | MCP Origin check | A disallowed `Origin` on `/mcp*` is a fixed 403 before redirects, admission, auth, or preflight — costing no permit and no auth lookup. Originless requests are admitted. |
91
+ | 0 | HTTPS upgrade | 308 to an HTTPS `publicUrl`, with path and query _assigned_ onto it rather than resolved against it, so a `//host` pathname cannot replace the origin. `/health` is exempt: a loopback probe must not need public DNS. |
92
+ | 0 | Cloudflare Access (Worker, when enabled) | Edge admission ahead of this table; an admitted invocation carries trusted identity in `ctx.access`. |
93
+ | 1 | `/oauth/client-metadata/<connectorId>` | Public GET-only CIMD for configured self-hosted OAuth connectors; fixed `publicUrl`, no auth or storage. |
94
+ | 1 | Mounted UI routes | Before wildcard OPTIONS, so mutation routes refuse preflight rather than inheriting MCP CORS. No UI module, no routes. |
95
+ | 1 | `/connect/<connectorId>`, `/oauth/callback/<connectorId>` | GET-only browser OAuth routes, before wildcard OPTIONS. Both verify the initiating user and management permission, independent of the UI. |
96
+ | 2 | MCP preflight | Allowed `OPTIONS` on `/mcp*`: 204 without admission or auth. |
97
+ | 2 | Other `OPTIONS` | Auth metadata first, otherwise compatibility CORS preflight. |
98
+ | 3 | `/.well-known/*` | Public auth metadata, or 404. Protected-resource discovery does not authenticate callers or evaluate endpoint grants. |
99
+ | 4 | `/health` | Open and payload-free: health, executor, admission, and deployment metadata. |
100
+ | 6 | `/mcp`, `/mcp/<pool>` | Admission, then auth, then a request-local MCP server. Body-confirmed modern listens skip admission and are refused after auth and SDK validation. An undeclared pool, a refusing endpoint grant, and a throwing endpoint grant are one identical 404; see [pools](./auth.md#pools). |
101
+ | 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
102
102
 
103
103
  Every response leaves through `withSecurityHeaders`. HTML uses one common
104
104
  security-header helper, including status and error pages. Operator shells
@@ -131,12 +131,17 @@ An admitted non-preflight `/mcp` request then takes five steps in
131
131
  3. **Narrow to the pool.** On `/mcp/<pool>`, look the name up, run its grant
132
132
  against the identity, then `intersectAccess` the pool with the identity's own
133
133
  access. A pool can never widen a view; anything else is a 404 naming no pool.
134
+ The endpoint's `readOnly` is the pool's own value, else the deployment's;
135
+ plain `/mcp` uses the deployment's. It travels to the meta-tools and the
136
+ program as one boolean that drops writes from `CatalogService` search and
137
+ describe and makes `writeRefusal` (`src/tool-safety.ts`) refuse every write
138
+ call. Guides and skills are not filtered.
134
139
  4. **Derive the registry view.** One `registry.scoped(...)` call with those
135
140
  connector ids, exact `connector.tool` addresses when the identity declares a
136
141
  narrower slice, and the subject and principal keys. Personal connectors use
137
142
  the principal partition and result paging the subject partition with principal,
138
143
  endpoint/pool, origin, connector, and tool bindings. Paging rechecks current
139
- grants, pool membership, and trust; no caller parameter selects a partition (`test/identity-scope.test.ts`).
144
+ grants, pool membership, and the endpoint's `readOnly`; no caller parameter selects a partition (`test/identity-scope.test.ts`).
140
145
  5. **Serve.** Refuse `?toolkit=` with a 404 — the toolkits are gone
141
146
  ([#178](https://github.com/zackbart/connecta/issues/178)) but their URLs were
142
147
  handed out, and retiring a scoping boundary into fail-open is worse than any
@@ -217,13 +222,15 @@ or hands out, and a change usually belongs in exactly one of them:
217
222
  | `src/invocation.ts` | One tool call: argument validation, call admission, one-attempt timeout, provider retry hints, result unwrapping, size capping, and the activity record. |
218
223
  | `src/catalog.ts` | Ranking, description summarizing, and the compact and TypeScript schema renderers discovery shows. |
219
224
  | `src/result-shapes.ts` | Bounded runtime-only inference and merging for output shapes learned from successful read-only calls whose providers declared none. |
220
- | `src/program-writes.ts` | Trusted-pool write accounting: write budgets, dispatch draining, and known or unknown outcomes. |
225
+ | `src/program-writes.ts` | Program write accounting on writable endpoints: write budgets, dispatch draining, and known or unknown outcomes. |
221
226
 
222
227
  `src/meta-tools.ts` and `src/execute.ts` are two front doors onto the same two
223
228
  services, `CatalogService` and `InvocationService`. That is the point: a
224
229
  program's `connecta.call` and a top-level `call_tool` reach
225
230
  `InvocationService.invoke` by different routes and get the same admission, the
226
- same credential resolution, and the same fail-closed read-only check.
231
+ same credential resolution, and the same fail-closed read-only check: one
232
+ `writeRefusal` decision per call, from the stored classification, the calling
233
+ surface, and the endpoint's `readOnly`.
227
234
  `test/execute.test.ts` asserts the parity directly, because a sandbox path that
228
235
  quietly diverges is how generated code would mint a capability.
229
236
 
@@ -646,7 +653,7 @@ only: an array or a class instance there is refused by path, unread. String
646
653
  maps (static `headers`, `api()`'s `authorizationParams` and
647
654
  `tokenRequestHeaders`) are copied the same way and each value must be a
648
655
  string. A discriminated union (`remoteMcp()`'s `auth.type`,
649
- a provider's `surface`) with a missing or unrecognized discriminant throws
656
+ a provider's `auth.type`) with a missing or unrecognized discriminant throws
650
657
  with the valid values. Only plain data is copied. Connectors, modules,
651
658
  executors, storage, loggers, handlers, and schemas are opaque and never
652
659
  entered. An `api()` tool carries its handler, so it is checked in place (own
@@ -712,7 +719,7 @@ call. The [provider guide](https://github.com/zackbart/connecta/blob/main/src/pr
712
719
  value-read conventions.
713
720
 
714
721
  A maintained provider is one `defineProvider()` call (`src/provider.ts`): a
715
- name, title, kind (`"mcp"`, `"api"`, or `"composed"`), a maintained skill, an
722
+ name, title, kind (`"mcp"`, `"api"`, `"composed"`, or `"dual"`), a maintained skill, an
716
723
  optional reviewed classification, a closed `options` shape declared with
717
724
  `optionsOf<O>()`, and a synchronous `create`. The root exports the option-shape
718
725
  combinators and `PROVIDER_COMMON` so provider authors use the same validation
@@ -723,17 +730,137 @@ connection context `create` supplies, the maintained text, then deployment
723
730
  instructions, which append and never replace. `src/provider.ts` imports neither
724
731
  transport, so an `api()` provider gains no MCP client or Effect graph from it
725
732
  (`test/purity.node.test.ts`). The factory carries its `definition`, which build and
726
- check tools read instead of keeping provider lists. All twenty providers
727
- live in their own folders. The eight hosted implementations, including the
733
+ check tools read instead of keeping provider lists. Providers
734
+ live in their own folders. Hosted implementations, including the
728
735
  MCP branches of Notion, Vercel, and Cloudflare, use reviewed presets over
729
- `remoteMcp({ classify })`. The eleven API-only factories retain an internal
736
+ `remoteMcp({ classify })`. The migrated API-only factories retain an internal
730
737
  adapter to the same definition and description-stamping path. GitHub composes hosted tools with a scope-enforced REST complement.
731
- Notion, Vercel and Cloudflare default to hosted MCP and select explicit REST
732
- complements with `surface: "api"`. Configure both under distinct connector ids
733
- with independent credentials when both are needed. Notion preserves its
734
- internal-integration identity under `integration_*` names; Vercel and Cloudflare
735
- retain documented REST gaps rather than duplicate hosted operations. See the
736
- [provider migration guide](./provider-migration-0.29.md).
738
+ A `"dual"` provider selects one implementation at construction, usually through `auth`
739
+ ([decision 0005](https://github.com/zackbart/connecta/blob/main/decisions/0005-auth-selects-implementation.md)):
740
+ `variants(["auth", "type"], …)` closes each case's options, and
741
+ `byAuth()` in `src/providers/_shared/rest/dispatch.ts` sends OAuth to
742
+ `hostedOAuth()` (`remoteMcp()`, OAuth only, the reviewed classification) and a
743
+ key to Connecta's REST connector. Stripe, Notion, Cloudflare, and Vercel require `auth`. Mixpanel is also dual,
744
+ with explicit `via: "mcp" | "api"` and MCP as the unchanged default: service accounts
745
+ reach both implementations, so auth cannot distinguish them. API mode requires
746
+ operator-managed service-account credentials and never falls back to MCP.
747
+ Mixpanel uses the same REST transport, operation matching and value-safety engine
748
+ for a narrow reviewed subset. Its official contracts are YAML and app identity
749
+ has no published response schema, so its guide and drift record carry explicit
750
+ manual review evidence instead of claiming a machine-checkable JSON pin.
751
+
752
+ The generic REST connector (`src/providers/_shared/rest/`) serves
753
+ `<vendor>_api_search`, `_api_details`, `_api_read`, and `_api_write` over an
754
+ `OperationIndex` that `npm run providers:spec` compiles offline from the
755
+ provider's pinned `openapi.source.json` into `openapi.generated.ts`.
756
+ Each call is matched to an operation and checked against its parameters before
757
+ transport; `_api_read` admits GET and the vendor's reviewed read-only POSTs in
758
+ its handler, since classification is per tool name. Vendors configure only a
759
+ transport, failure mapper, scope, framing, cursor extraction, a reviewed
760
+ value-safety table (required; see below), argument-dependent refusals, and an
761
+ idempotency header, plus optional hooks: `path` (fill default ids before
762
+ matching), `admit` (an awaited check in `callRest` every tool passes, such as
763
+ Cloudflare's account and zone pin), `result` (unwrap a vendor envelope;
764
+ redaction runs after it and before cursors and `select`), a reviewed
765
+ request-header allowlist, text bodies, and index `slashParams` that admit an
766
+ encoded `/` in a final key segment (`test/rest-connector.test.ts`).
767
+ Large documents opt into generator options that drop restated operation ids
768
+ and plain path parameters and cap one operation's details (`opBudget`).
769
+ `check:providers-generated` checks each index header against its source record
770
+ offline; `providers:check` reports a pin that differs from the live document.
771
+ No maintained provider selects an implementation with `surface` any more. See
772
+ the
773
+ [provider auth migration](./provider-auth-migration.md). Notion's token
774
+ connector keeps its `integration_*` projections and authoring helpers beside
775
+ the generic REST tools.
776
+
777
+ #### Value safety for REST vendors
778
+
779
+ The bar ([decision 0005](https://github.com/zackbart/connecta/blob/main/decisions/0005-auth-selects-implementation.md)):
780
+ no REST tool response, success or error, returns a credential that grants
781
+ access to the vendor or a third party, or a stored secret value, unless a
782
+ reviewed named tool exists to return it. The threat model is Infisical's
783
+ above: an honest server, with secrets people type into free text out of
784
+ scope. Field-by-field redaction over a whole vendor API does not converge, so
785
+ every vendor ships one reviewed table through one mechanism
786
+ (`src/providers/_shared/rest/value-safety.ts`):
787
+
788
+ 1. **Candidates from the pinned spec.** `scripts/value-safety.mjs` flags every
789
+ operation whose success response names or describes a credential, keyed
790
+ value, header, environment, or binding container, or whose path, id,
791
+ summary, or description names a secret family (tokens, secrets, keys,
792
+ signing, webhooks, drains, transfer, claim, and invite codes). It is the
793
+ union of the detectors Cloudflare and Vercel shipped first, and
794
+ deliberately over-inclusive. `providers:spec` writes
795
+ `value-safety.candidates.json` beside the index when the source record sets
796
+ `options.valueSafety` (vendor `operationWords`; Stripe's `expansions: false`
797
+ skips id-or-object expansions, which are their own operations' candidates),
798
+ stamped with the pinned digest, the detector format, and its options. The
799
+ file is test evidence and never ships.
800
+ 2. **One reviewed verdict per candidate**, in the vendor's `value-safety.ts`:
801
+ `refuse` (the generic tools refuse it before transport, naming the
802
+ operation and the reason), `redact` (reviewed field paths), or `safe`, each
803
+ with a reason. Paths are dot paths into the data a tool returns: a list on
804
+ the way is traversed, `[]` and `{}`/`*` address list items and map values,
805
+ `@keys` replaces secret map keys, `[?env]`, `[?credential]`, and
806
+ `[?header]` filter list items, `url:` sanitizes a URL, and `origin:` (or a
807
+ `#url` suffix) keeps only scheme and host. `keep` names reviewed metadata
808
+ the heuristic leaves (children are still checked). A vendor whose objects
809
+ repeat across operations (Stripe) reviews a field once in `fields`,
810
+ `redact` or `keep`, wherever it appears, and states `resources`: rules
811
+ keyed on the object's type discriminator (Stripe's `object`) that apply to
812
+ every such object wherever a response embeds it (an expansion, a list, an
813
+ event's `data.object` and its `previous_attributes`). A resource rule can
814
+ return a reviewed payer-facing URL verbatim only while a declarative guest
815
+ condition holds (deny by default: a Checkout page only when every field
816
+ that can bind the session to a Customer, an Account, or saved payment
817
+ methods is empty and the session only takes a payment) and withhold it
818
+ otherwise. `providers:spec` derives the binding fields from the pinned
819
+ schema, and the harness fails until the condition lists each one. Argument-dependent refusals (Stripe expansions,
820
+ Vercel `decrypt`) stay in the vendor's `refuse` hook.
821
+ 3. **Redaction on every success body**, in `callRest` after `result` and
822
+ before cursors and `select`, so named tools, HEAD data, and uploads pass
823
+ through it too: the operation's reviewed paths, then the key-name
824
+ heuristic as defense in depth. The heuristic is the union of the former
825
+ vendor engines: a credential word anywhere in a non-metadata name removes
826
+ the value whole, a credential suffix removes the strings below it,
827
+ labelled or typed secret records lose their value, environment containers
828
+ lose their values, header, cookie, and query rules lose theirs, external
829
+ destinations keep their origin, and every URL loses userinfo and
830
+ credential-named query parameters or fragments. Exemptions are never
831
+ shared; each vendor states its own.
832
+ 4. **Errors.** A reviewed operation, or an unreviewed one whose request
833
+ accepts a credential-named field, answers failures with the vendor's codes
834
+ and status but not its text, unless its verdict is wrapped in
835
+ `vendorErrors()` because a review found its errors cannot echo a stored
836
+ secret. Credentials Connecta sent are always removed by the sent-secrets
837
+ matcher.
838
+
839
+ `providers:spec` (run under tsx) also checks the table against the pinned
840
+ spec: every `redact` path and `keep` must name something the operation's
841
+ response schema can return (through `options.valueSafety.dataRoot` for an
842
+ envelope such as Cloudflare's `result`), and every resource rule path
843
+ something the resource's schema can return. It stamps the paths that resolve
844
+ into the candidates file and fails on the rest, unless
845
+ `value-safety.absent.json` acknowledges them (shared verdicts applied to
846
+ operations whose schema lacks a path, or fields a schema leaves undeclared;
847
+ such paths still apply at runtime).
848
+
849
+ `test/fixtures/value-safety.ts` is the shared harness each vendor's
850
+ `value-safety.node.test.ts` runs; `reviewProblems` is pure, and
851
+ `test/value-safety-harness.node.test.ts` proves wrong paths fail it. It fails
852
+ on candidates from another pin, format, or option set; an unreviewed
853
+ candidate; a verdict for an operation the index lacks; a table path the stamp
854
+ lacks (unresolved, or edited since `providers:spec`); a flagged field no path,
855
+ keep, field review, or scope map covers; a flagged field, built in the shape
856
+ the pinned schema gives it, that survives redaction or that no redact path
857
+ reaches; a keep that exempts anything below it or a vendor-wide keep of a
858
+ credential name; a redact path or resource rule that leaves its value at the
859
+ top level, in a list, an expansion, or an event; a refusal that reaches
860
+ transport; a reviewed operation that echoes vendor error text; and a moved
861
+ verdict count.
862
+ `RestVendor.valueSafety` is required, so a new REST vendor cannot ship without
863
+ a table.
737
864
 
738
865
  `remoteMcp({ classify })` is the public way to declare what a downstream's
739
866
  tools do: `{ tools: { name: "read" | "write" | "destructive" | { verdict,