@zackbart/connecta 0.22.3 → 0.24.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 (94) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +19 -11
  4. package/dist/activity-friction.d.ts +3 -0
  5. package/dist/activity-friction.js +19 -0
  6. package/dist/activity.d.ts +11 -2
  7. package/dist/activity.js +15 -19
  8. package/dist/auth/downstream-oauth.d.ts +2 -1
  9. package/dist/auth/downstream-oauth.js +10 -1
  10. package/dist/branding.d.ts +67 -0
  11. package/dist/branding.js +176 -0
  12. package/dist/catalog-service.d.ts +1 -6
  13. package/dist/catalog-service.js +3 -53
  14. package/dist/connectors/remote-mcp.js +3 -5
  15. package/dist/credential-contract.d.ts +24 -0
  16. package/dist/credential-contract.js +1 -0
  17. package/dist/credential-rules.d.ts +85 -0
  18. package/dist/credential-rules.js +107 -0
  19. package/dist/credentials.d.ts +4 -100
  20. package/dist/credentials.js +3 -107
  21. package/dist/errors.d.ts +1 -1
  22. package/dist/execute.d.ts +5 -52
  23. package/dist/execute.js +40 -347
  24. package/dist/executors/quickjs-protocol.d.ts +0 -7
  25. package/dist/executors/quickjs-protocol.js +2 -10
  26. package/dist/executors/quickjs.js +1 -1
  27. package/dist/index.d.ts +23 -56
  28. package/dist/index.js +30 -58
  29. package/dist/invocation.d.ts +0 -33
  30. package/dist/invocation.js +56 -124
  31. package/dist/meta-tools.d.ts +7 -6
  32. package/dist/meta-tools.js +14 -21
  33. package/dist/module-contracts.d.ts +19 -0
  34. package/dist/module-contracts.js +1 -0
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/model.d.ts +6 -3
  37. package/dist/operator-ui/view.d.ts +2 -18
  38. package/dist/operator-ui/view.js +3 -20
  39. package/dist/registry.d.ts +4 -1
  40. package/dist/registry.js +8 -6
  41. package/dist/routes/activity.js +1 -1
  42. package/dist/routes/credentials.js +5 -2
  43. package/dist/routes/mcp.js +7 -51
  44. package/dist/routes/oauth-management.d.ts +2 -0
  45. package/dist/routes/oauth-management.js +108 -0
  46. package/dist/routes/oauth.d.ts +0 -1
  47. package/dist/routes/oauth.js +21 -121
  48. package/dist/routes/shared.d.ts +19 -17
  49. package/dist/routes/shared.js +48 -44
  50. package/dist/routes/ui.js +36 -33
  51. package/dist/server.d.ts +1 -2
  52. package/dist/server.js +7 -45
  53. package/dist/skills.d.ts +1 -1
  54. package/dist/skills.js +55 -19
  55. package/dist/types.d.ts +3 -15
  56. package/dist/ui.d.ts +15 -70
  57. package/dist/ui.js +176 -317
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/documentation/architecture.md +31 -26
  61. package/documentation/auth.md +65 -111
  62. package/documentation/call-admission.md +10 -11
  63. package/documentation/cloudflare.md +1 -1
  64. package/documentation/code-mode.md +65 -260
  65. package/documentation/connectors.md +8 -6
  66. package/documentation/linear.md +1 -1
  67. package/documentation/meta-tools.md +37 -18
  68. package/documentation/mixpanel.md +1 -1
  69. package/documentation/notion.md +2 -2
  70. package/documentation/operations.md +19 -23
  71. package/documentation/operator-ui.md +82 -104
  72. package/documentation/optional-modules-upgrade.md +243 -0
  73. package/documentation/provider-conventions.md +7 -5
  74. package/documentation/revenuecat.md +1 -1
  75. package/documentation/storage-and-credentials.md +59 -40
  76. package/documentation/stripe.md +1 -1
  77. package/documentation/upgrading.md +83 -8
  78. package/ethos.md +36 -43
  79. package/examples/worker/AGENTS.md +3 -1
  80. package/examples/worker/README.md +68 -84
  81. package/examples/worker/src/d1-activity.ts +1 -1
  82. package/examples/worker/src/index.ts +11 -6
  83. package/package.json +18 -2
  84. package/templates/node/AGENTS.md +8 -6
  85. package/templates/node/README.md +56 -67
  86. package/templates/node/package.json +1 -1
  87. package/templates/node/src/file-activity.ts +1 -1
  88. package/templates/node/src/index.ts +11 -12
  89. package/dist/access-tokens.d.ts +0 -31
  90. package/dist/access-tokens.js +0 -236
  91. package/dist/apps-shell.d.ts +0 -37
  92. package/dist/apps-shell.js +0 -174
  93. package/dist/routes/access-tokens.d.ts +0 -6
  94. package/dist/routes/access-tokens.js +0 -83
@@ -1,12 +1,12 @@
1
1
  import type { Implementation } from "@modelcontextprotocol/server";
2
- import type { AccessTokenManager } from "../access-tokens.js";
2
+ import type { ActivityModule, OperatorSurface } from "../module-contracts.js";
3
3
  import type { ActivityActor, ActivityReadGate, ActivityStore } from "../activity.js";
4
- import type { CredentialVault } from "../credentials.js";
4
+ import type { CredentialVault } from "../credential-contract.js";
5
5
  import type { DeferredWork } from "../connector-scope.js";
6
6
  import type { AdmissionController } from "../executor-admission.js";
7
7
  import type { Registry } from "../registry.js";
8
- import type { AuthenticatedIdentity, ConnectaBranding, Executor, IdentityReference, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
9
- import type { ConnectaIdentityConfig } from "../index.js";
8
+ import type { AuthenticatedIdentity, ConnectaBranding, Executor, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
9
+ import type { ConnectorPermission, ConnectaIdentityConfig } from "../index.js";
10
10
  export { msg } from "../errors.js";
11
11
  export interface ServerOptions {
12
12
  registry: Registry;
@@ -37,8 +37,9 @@ export interface ServerOptions {
37
37
  requestAdmission: AdmissionController;
38
38
  /** Encrypted connector-credential storage backing the Credentials page. */
39
39
  credentialVault?: CredentialVault | undefined;
40
- /** Hashed deployment access tokens backing MCP admission and the Tokens page. */
41
- accessTokens?: AccessTokenManager | undefined;
40
+ /** Optional browser routes, with no implementation import in core. */
41
+ ui?: OperatorSurface | undefined;
42
+ activityModule?: ActivityModule | undefined;
42
43
  /** Optional browser UI and OAuth result-page labels. */
43
44
  branding?: ConnectaBranding | undefined;
44
45
  }
@@ -74,22 +75,14 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
74
75
  principalKey?: string;
75
76
  connectorIds: "all" | readonly string[];
76
77
  operator: boolean;
78
+ credentialAdministration: ConnectorPermission;
79
+ personalConnection: ConnectorPermission;
77
80
  /** Backward-compatible name used by operator views. */
78
81
  uiAdminEligible?: boolean;
79
82
  } | {
80
83
  ok: false;
81
84
  response: Response;
82
85
  }>;
83
- export declare function authorizeUiAdmin(request: Request, baseUrl: string, auth: InboundAuth[], purpose?: string, runtimeContext?: RuntimeExecutionContext, identityConfig?: ConnectaIdentityConfig): Promise<{
84
- ok: true;
85
- userId: string;
86
- principal?: IdentityReference;
87
- principalKey?: string;
88
- connectorIds: "all" | readonly string[];
89
- } | {
90
- ok: false;
91
- response: Response;
92
- }>;
93
86
  export declare function authorizeUiIdentity(request: Request, baseUrl: string, auth: InboundAuth[], purpose: string, runtimeContext?: RuntimeExecutionContext, identityConfig?: ConnectaIdentityConfig): Promise<Extract<Awaited<ReturnType<typeof authorize>>, {
94
87
  ok: true;
95
88
  }> | {
@@ -97,4 +90,13 @@ export declare function authorizeUiIdentity(request: Request, baseUrl: string, a
97
90
  response: Response;
98
91
  }>;
99
92
  export declare function isSameOrigin(request: Request, baseUrl: string): boolean;
100
- export declare function withSecurityHeaders(response: Response, requestUrl: URL, path: string): Response;
93
+ export declare function withSecurityHeaders(response: Response, requestUrl: URL, _path: string): Response;
94
+ type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
95
+ ok: true;
96
+ }>;
97
+ /** Unknown configured ids refuse the complete view, including management rights. */
98
+ export declare function validateAuthPermissions(authz: AuthorizedIdentity, registry: Registry): void;
99
+ export declare function mayManageConnector(authz: AuthorizedIdentity, connector: {
100
+ id: string;
101
+ authScope?: "shared" | "personal";
102
+ }): boolean;
@@ -1,5 +1,4 @@
1
1
  import { identityStorageKey, validIdentityReference } from "../identity.js";
2
- import { operatorPageForPath } from "../ui.js";
3
2
  export { msg } from "../errors.js";
4
3
  export function privateJson(body, init = {}) {
5
4
  const headers = new Headers(init.headers);
@@ -36,7 +35,9 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
36
35
  const identity = { actor, interactive: false };
37
36
  let connectorIds = "all";
38
37
  try {
39
- connectorIds = await identityConfig?.connectorAccess?.(identity) ?? "all";
38
+ connectorIds = identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all";
39
+ if (connectorIds !== "all" && (!Array.isArray(connectorIds) || !connectorIds.every(id => typeof id === "string" && /^[a-z0-9_-]+$/.test(id))))
40
+ throw new Error("invalid connector permission");
40
41
  }
41
42
  catch {
42
43
  return {
@@ -44,7 +45,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
44
45
  response: privateJson({ error: "identity access resolution failed" }, { status: 403 }),
45
46
  };
46
47
  }
47
- return { ok: true, actor, identity, connectorIds, operator: false };
48
+ return { ok: true, actor, identity, connectorIds, operator: false, credentialAdministration: "none", personalConnection: "none" };
48
49
  }
49
50
  let lastResponse = null;
50
51
  for (const provider of auth) {
@@ -74,14 +75,26 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
74
75
  interactive,
75
76
  };
76
77
  let operator = interactive;
78
+ let credentialAdministration = "none";
79
+ let personalConnection = "none";
77
80
  let connectorIds = "all";
78
81
  try {
79
- if (identityConfig?.operatorAccess) {
82
+ if (identityConfig?.activityAccess) {
80
83
  operator = interactive && principal
81
- ? await identityConfig.operatorAccess(principal)
84
+ ? await identityConfig.activityAccess(principal)
82
85
  : false;
83
86
  }
84
- connectorIds = await identityConfig?.connectorAccess?.(identity) ?? "all";
87
+ connectorIds = identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all";
88
+ if (interactive) {
89
+ credentialAdministration = identityConfig?.credentialAdministration ? await identityConfig.credentialAdministration(identity) : "none";
90
+ personalConnection = principal && identityConfig?.personalConnection ? await identityConfig.personalConnection(identity) : "none";
91
+ }
92
+ if (typeof operator !== "boolean")
93
+ throw new Error("invalid activity permission");
94
+ for (const permission of [connectorIds, credentialAdministration, personalConnection]) {
95
+ if (permission !== "all" && permission !== "none" && (!Array.isArray(permission) || !permission.every(id => typeof id === "string" && /^[a-z0-9_-]+$/.test(id))))
96
+ throw new Error("invalid identity permission");
97
+ }
85
98
  }
86
99
  catch {
87
100
  return {
@@ -100,6 +113,8 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
100
113
  ? { principalKey: await identityStorageKey(principal) }
101
114
  : {}),
102
115
  connectorIds,
116
+ credentialAdministration,
117
+ personalConnection,
103
118
  operator,
104
119
  ...(operator ? { uiAdminEligible: true } : {}),
105
120
  };
@@ -118,34 +133,6 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
118
133
  }),
119
134
  };
120
135
  }
121
- export async function authorizeUiAdmin(request, baseUrl, auth, purpose = "credential management", runtimeContext, identityConfig) {
122
- // Operator mutation is intentionally narrower than /mcp and /ui/data: only
123
- // an interactive provider may admit it. A static bearer token is useful
124
- // for headless tool calls but must not become a deployment-admin key.
125
- //
126
- // Every interactive provider gets a turn, the way the /mcp gate does.
127
- // Stopping at the first would make admission depend on config order: a failed gate or
128
- // missing user may simply mean a later provider is the one meant to admit.
129
- // The last refusal is returned if none do.
130
- const authz = await authorizeUiIdentity(request, baseUrl, auth, purpose, runtimeContext, identityConfig);
131
- if (!authz.ok)
132
- return authz;
133
- if (!authz.operator || !authz.actor.id) {
134
- return {
135
- ok: false,
136
- response: privateJson({ error: `${purpose} requires operator access` }, { status: 403 }),
137
- };
138
- }
139
- return {
140
- ok: true,
141
- userId: authz.identity.principal?.id ?? authz.actor.id,
142
- ...(authz.identity.principal
143
- ? { principal: authz.identity.principal }
144
- : {}),
145
- ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
146
- connectorIds: authz.connectorIds,
147
- };
148
- }
149
136
  export async function authorizeUiIdentity(request, baseUrl, auth, purpose, runtimeContext, identityConfig) {
150
137
  const providers = auth.filter((candidate) => candidate.interactiveOperator);
151
138
  if (providers.length === 0) {
@@ -181,25 +168,42 @@ export function isSameOrigin(request, baseUrl) {
181
168
  return false;
182
169
  }
183
170
  }
184
- export function withSecurityHeaders(response, requestUrl, path) {
171
+ export function withSecurityHeaders(response, requestUrl, _path) {
185
172
  const headers = new Headers(response.headers);
186
173
  headers.set("X-Content-Type-Options", "nosniff");
187
174
  headers.set("Referrer-Policy", "no-referrer");
188
175
  if (requestUrl.protocol === "https:") {
189
176
  headers.set("Strict-Transport-Security", "max-age=31536000");
190
177
  }
191
- if (operatorPageForPath(path) || path === "/ui") {
192
- // Operator HTML responses ship their own nonce-based script CSP (which
193
- // already includes frame-ancestors 'none'); only fall back to the
194
- // framing-only directive when no CSP is present (for example redirects).
195
- if (!headers.has("Content-Security-Policy")) {
196
- headers.set("Content-Security-Policy", "frame-ancestors 'none'");
197
- }
198
- headers.set("X-Frame-Options", "DENY");
199
- }
200
178
  return new Response(response.body, {
201
179
  status: response.status,
202
180
  statusText: response.statusText,
203
181
  headers,
204
182
  });
205
183
  }
184
+ /** Unknown configured ids refuse the complete view, including management rights. */
185
+ export function validateAuthPermissions(authz, registry) {
186
+ for (const value of [
187
+ authz.connectorIds,
188
+ authz.credentialAdministration,
189
+ authz.personalConnection,
190
+ ]) {
191
+ if (value === "all" || value === "none")
192
+ continue;
193
+ if (!Array.isArray(value) || value.some(id => !registry.getConnector(id))) {
194
+ throw new Error("invalid identity permission connector ids");
195
+ }
196
+ }
197
+ }
198
+ export function mayManageConnector(authz, connector) {
199
+ if (!authz.identity.interactive)
200
+ return false;
201
+ if (authz.connectorIds !== "all" && !authz.connectorIds.includes(connector.id)) {
202
+ return false;
203
+ }
204
+ const permission = connector.authScope === "personal"
205
+ ? authz.personalConnection
206
+ : authz.credentialAdministration;
207
+ return permission === "all" ||
208
+ (permission !== "none" && permission.includes(connector.id));
209
+ }
package/dist/routes/ui.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { CONNECTA_VERSION } from "../version.js";
1
2
  import { CONNECTA_FAVICON_ICO } from "../favicon.js";
2
- import { buildUiData, CONNECTA_FAVICON_SVG, credentialManagementCapability, operatorPageForPath, renderUiHtml, } from "../ui.js";
3
- import { authorize, msg, privateJson, } from "./shared.js";
3
+ import { buildUiData, CONNECTA_FAVICON_SVG, operatorPageForPath, renderUiHtml, } from "../ui.js";
4
+ import { authorize, mayManageConnector, validateAuthPermissions, msg, privateJson, } from "./shared.js";
4
5
  /**
5
6
  * Headers that make an operator-supplied favicon body inert on this origin.
6
7
  * The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
@@ -67,7 +68,7 @@ export async function routeUi(context) {
67
68
  });
68
69
  }
69
70
  const operatorPage = operatorPageForPath(path);
70
- if (operatorPage) {
71
+ if (operatorPage && (operatorPage !== "activity" || opts.activity?.list)) {
71
72
  if (request.method !== "GET" && request.method !== "HEAD") {
72
73
  return privateJson({ error: "method not allowed" }, { status: 405 });
73
74
  }
@@ -97,13 +98,17 @@ export async function routeUi(context) {
97
98
  },
98
99
  });
99
100
  }
100
- if (path !== "/ui/data")
101
+ const detail = /^\/ui\/connectors\/([a-z0-9_-]+)$/.exec(path);
102
+ if (path !== "/ui/data" && !detail)
101
103
  return null;
104
+ if (request.method !== "GET")
105
+ return privateJson({ error: "method not allowed" }, { status: 405 });
102
106
  const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity);
103
107
  if (!authz.ok)
104
108
  return authz.response;
105
109
  let registry;
106
110
  try {
111
+ validateAuthPermissions(authz, opts.registry);
107
112
  registry = opts.registry.scoped({
108
113
  connectorIds: authz.connectorIds,
109
114
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
@@ -113,33 +118,31 @@ export async function routeUi(context) {
113
118
  catch (error) {
114
119
  return privateJson({ error: msg(error) }, { status: 403 });
115
120
  }
116
- const eligibleOperator = authz.uiAdminEligible === true;
117
- const interactiveManager = authz.identity.interactive;
118
- const personalManager = Boolean(interactiveManager && authz.principalKey);
119
- const visibleConnectors = registry.listConnectors();
120
- const hasManageableCredentialSlot = visibleConnectors.some((connector) => Boolean(connector.credential) &&
121
- (connector.authScope !== "personal" || personalManager));
122
- const credentialManagement = interactiveManager && hasManageableCredentialSlot
123
- ? opts.credentialVault
124
- ? "available"
125
- : "vault_not_configured"
126
- : credentialManagementCapability({
127
- eligibleOperator: interactiveManager,
128
- hasCredentialSlots: visibleConnectors.some((connector) => Boolean(connector.credential)),
129
- hasCredentialVault: Boolean(opts.credentialVault),
130
- });
131
- // As with connector credentials, a Bearer-authenticated observer learns
132
- // only that an interactive operator is required, not whether this deployment has opted into
133
- // token issuance. Configuration topology is operator data.
134
- const accessTokenManagement = !eligibleOperator
135
- ? "requires_operator"
136
- : opts.accessTokens
137
- ? "available"
138
- : "not_configured";
139
- const data = await buildUiData(registry, baseUrl, opts.serverInfo,
140
- // A static headless bearer may read connector health, but only an
141
- // interactive human receives credential metadata for visible connectors.
142
- interactiveManager ? opts.credentialVault : undefined, Boolean(opts.activity?.list) &&
143
- (!opts.identity?.operatorAccess || eligibleOperator), credentialManagement, defer, interactiveManager, opts.discoveryConcurrency, accessTokenManagement, personalManager ? authz.principalKey : undefined);
144
- return privateJson(data);
121
+ const visible = registry.listConnectors();
122
+ const mayManage = (id) => { const connector = registry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); };
123
+ const permissions = (connector) => ({
124
+ use: true,
125
+ manageSharedAuth: connector.authScope !== "personal" && mayManage(connector.id),
126
+ connectPersonal: connector.authScope === "personal" && mayManage(connector.id),
127
+ });
128
+ const activityEnabled = Boolean(opts.activity?.list) && authz.operator;
129
+ const credentialManagement = visible.some(c => c.credential && mayManage(c.id))
130
+ ? opts.credentialVault ? "available" : "vault_not_configured"
131
+ : authz.identity.interactive && !visible.some(c => c.credential) ? "no_slots" : "requires_operator";
132
+ if (detail) {
133
+ const connector = registry.getConnector(detail[1]);
134
+ if (!connector)
135
+ return privateJson({ error: "unknown connector" }, { status: 404 });
136
+ const one = opts.registry.scoped({ connectorIds: [connector.id], ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}), ...(authz.principalKey ? { principalKey: authz.principalKey } : {}) });
137
+ const data = await buildUiData(one, baseUrl, opts.serverInfo, opts.credentialVault, activityEnabled, credentialManagement, defer, false, 1, authz.principalKey, { mayManage, timeoutMs: opts.probeTimeoutMs ?? 30_000, signal: request.signal });
138
+ return privateJson({ ...data.connectors[0], permissions: permissions(connector) });
139
+ }
140
+ return privateJson({
141
+ serverInfo: opts.serverInfo,
142
+ connectaVersion: CONNECTA_VERSION,
143
+ activityEnabled,
144
+ credentialManagement,
145
+ oauthManagement: visible.some(c => mayManage(c.id)),
146
+ connectors: visible.map(c => ({ id: c.id, ...(c.title ? { title: c.title } : {}), ...(c.description ? { description: c.description } : {}), authScope: c.authScope ?? "shared", status: "loading", toolCount: 0, tools: [], oauth: Boolean(c.startAuth && c.disconnectAuth), permissions: permissions(c) })),
147
+ });
145
148
  }
package/dist/server.d.ts CHANGED
@@ -4,7 +4,6 @@ export type { ServerOptions } from "./routes/shared.js";
4
4
  * Build the Web-standard fetch handler.
5
5
  *
6
6
  * Route ordering is the contract: private mutation routes precede wildcard
7
- * OPTIONS, every built-in precedes connector-owned routes, and the security
8
- * wrapper is applied to every response.
7
+ * OPTIONS, and the security wrapper is applied to every response, including 404s.
9
8
  */
10
9
  export declare function createFetchHandler(opts: ServerOptions): (request: Request, runtimeContext?: RuntimeExecutionContext) => Promise<Response>;
package/dist/server.js CHANGED
@@ -1,17 +1,12 @@
1
1
  import { isAdmittingExecutor } from "./executor-admission.js";
2
- import { routeAccessTokens } from "./routes/access-tokens.js";
3
- import { routeActivity } from "./routes/activity.js";
4
- import { routeCredentials } from "./routes/credentials.js";
5
2
  import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
6
- import { routeOAuthCallback, routeOAuthManagement, } from "./routes/oauth.js";
3
+ import { routeOAuthCallback, } from "./routes/oauth.js";
7
4
  import { withSecurityHeaders, } from "./routes/shared.js";
8
- import { routeUi } from "./routes/ui.js";
9
5
  /**
10
6
  * Build the Web-standard fetch handler.
11
7
  *
12
8
  * Route ordering is the contract: private mutation routes precede wildcard
13
- * OPTIONS, every built-in precedes connector-owned routes, and the security
14
- * wrapper is applied to every response.
9
+ * OPTIONS, and the security wrapper is applied to every response, including 404s.
15
10
  */
16
11
  export function createFetchHandler(opts) {
17
12
  const { auth, publicUrl, registry } = opts;
@@ -60,15 +55,9 @@ export function createFetchHandler(opts) {
60
55
  };
61
56
  const route = async () => {
62
57
  // Private mutations own OPTIONS so they never inherit wildcard CORS.
63
- const accessTokens = await routeAccessTokens(context);
64
- if (accessTokens)
65
- return accessTokens;
66
- const credentials = await routeCredentials(context);
67
- if (credentials)
68
- return credentials;
69
- const oauthManagement = await routeOAuthManagement(context);
70
- if (oauthManagement)
71
- return oauthManagement;
58
+ const uiResponse = await opts.ui?.handle(context);
59
+ if (uiResponse)
60
+ return uiResponse;
72
61
  if (request.method === "OPTIONS") {
73
62
  for (const provider of auth) {
74
63
  if (provider.handleMetadata) {
@@ -124,12 +113,8 @@ export function createFetchHandler(opts) {
124
113
  },
125
114
  reservedRoutes: [
126
115
  "/health",
127
- "/",
128
- "/credentials",
129
- "/tokens",
130
- "/activity",
131
- "/ui",
132
- "/ui/*",
116
+ ...(opts.ui?.reservedPaths ?? []),
117
+ ...(opts.ui && opts.activity?.list ? ["/activity"] : []),
133
118
  ],
134
119
  },
135
120
  ...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
@@ -138,32 +123,9 @@ export function createFetchHandler(opts) {
138
123
  const oauthCallback = await routeOAuthCallback(context);
139
124
  if (oauthCallback)
140
125
  return oauthCallback;
141
- const ui = await routeUi(context);
142
- if (ui)
143
- return ui;
144
- const activity = await routeActivity(context);
145
- if (activity)
146
- return activity;
147
126
  const mcp = await routeMcp(context);
148
127
  if (mcp)
149
128
  return mcp;
150
- // Connector-owned public routes, dispatched last: a connector can add a
151
- // route but never shadow one of connecta's own. A throw here is the
152
- // connector's bug, not a missing route, so it surfaces as 500 rather
153
- // than falling through to 404.
154
- for (const connector of registry.listConnectors()) {
155
- if (!connector.handleRequest)
156
- continue;
157
- try {
158
- const response = await connector.handleRequest(request, registry.contextFor(connector.id, baseUrl));
159
- if (response)
160
- return response;
161
- }
162
- catch (error) {
163
- opts.logger.error(`[connecta] connector "${connector.id}" handleRequest failed`, error);
164
- return new Response("Internal Server Error", { status: 500 });
165
- }
166
- }
167
129
  return new Response("Not Found", { status: 404 });
168
130
  };
169
131
  return withSecurityHeaders(await route(), url, path);
package/dist/skills.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Connector } from "./types.js";
2
- export declare const CONNECTA_INSTRUCTIONS = "Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with one execute_code program that discovers, calls, and returns the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. After auth_required use authorize_connector. After a truncated direct result use get_result. connecta.ui(html) exists only inside execute_code, not in connector search; return the same summary data the HTML renders. Guidance is on demand: fetch skills({ name: \"usage\" }) only when these instructions and the tool description are insufficient or a run needs repair.";
2
+ export declare const CONNECTA_INSTRUCTIONS = "Choose a route before discovery. A known-address read needs only call_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. Inspect unfamiliar result shapes with a small sample before proceeding. Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: \"usage\" }) only when these instructions and the tool description are insufficient or a run needs repair.";
3
3
  /** Shared Connecta routing guidance, byte-identical across deployments. */
4
4
  export declare const USAGE_SKILL: string;
5
5
  /** The always-loaded MCP `instructions` string. */
package/dist/skills.js CHANGED
@@ -1,59 +1,58 @@
1
- export const CONNECTA_INSTRUCTIONS = 'Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with one execute_code program that discovers, calls, and returns the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. After auth_required use authorize_connector. After a truncated direct result use get_result. connecta.ui(html) exists only inside execute_code, not in connector search; return the same summary data the HTML renders. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
1
+ export const CONNECTA_INSTRUCTIONS = 'Choose a route before discovery. A known-address read needs only call_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. Inspect unfamiliar result shapes with a small sample before proceeding. Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
2
2
  const USAGE_SKILL_BASE = `# Connecta usage
3
3
 
4
4
  ## The surface
5
5
 
6
6
  Seven tools: \`execute_code\`, \`search_tools\`, \`call_tool\`, \`call_destructive_tool\`, \`authorize_connector\`, \`get_result\`, \`skills\`. Read-only discovery and multi-call work live in a program. Top-level search remains for catalog inspection and approval-required work.
7
7
 
8
- The always-loaded MCP instructions are authoritative for choosing the top-level route. Read this skill at most once, and only when their program workflow is insufficient or a run needs repair.
8
+ Follow the MCP instructions for routing. Read at most once for syntax or repair.
9
9
 
10
10
  ## Inside a program
11
11
 
12
- Write one plain-JavaScript async arrow function. TypeScript syntax and portable imports do not work. Return JSON-shaped data and reduce large results before returning.
12
+ Write one plain-JavaScript async arrow function, without TypeScript or imports. Return reduced JSON-shaped data.
13
13
 
14
14
  The minimum guest API is:
15
15
 
16
- - \`<connectorId>.<toolName>(args)\` calls a sanitized shortcut. Non-identifier characters become \`_\`; leading digits gain \`_\`; reserved words gain a trailing \`_\`.
17
16
  - \`connecta.call("connector.tool", args)\` uses the canonical address and returns the unwrapped value.
18
17
  - \`connecta.search(args)\` returns \`{ tools, total, offset, limit, hasMore }\`; \`connecta.describe(args)\` returns \`{ tools }\`.
19
- - \`connecta.batch(calls)\` runs 2–10 independent calls. Each outcome is \`{ address, ok: true, data }\` or \`{ address, ok: false, error, errorDetails }\`.
20
- - \`console.log(...)\` is captured. \`connecta.emit(block)\` and \`connecta.ui(html)\` produce rich output.
18
+ - Use \`Promise.all\` for independent calls, or \`Promise.allSettled\` to keep successes and failures.
19
+ - \`console.log(...)\` is captured. \`connecta.emit(block)\` produces rich output.
21
20
 
22
21
  ## Discover and select
23
22
 
24
- Search inside the run and finish the task there. A discovery-only program wastes a round trip. Use 2–4 distinctive action/object terms, not the full request. Use separate short searches for distinct operations.
23
+ Search and call in one run when schemas suffice. For unfamiliar result formats, return a small sample and continue; do not guess a parser. Use 2–4 distinctive action/object terms; search distinct operations separately.
25
24
 
26
- For top-level catalog inspection or approval-required discovery, omit \`limit\` initially (the default is 10), then page with a limit up to 50 if needed. Empty or whitespace-only queries browse all tools. A non-empty query with no ASCII terms returns no matches; mixed input searches with its ASCII terms. \`includeSchemas: "compact"\` adds bounded input and available output shapes. An observed shape carries \`outputSchemaSource: "observed"\`; treat it as routing evidence rather than a provider contract. Plain objects expose \`inputKeys\`, \`requiredInputKeys\`, and \`outputKeys\`; truncation flags mark incomplete shapes; matches also carry declared annotations.
25
+ For top-level catalog inspection or approval-required discovery, omit \`limit\` initially (the default is 8), then page with a limit up to 50 if needed. Empty or whitespace-only queries browse all tools. A non-empty query with no ASCII terms returns no matches; mixed input searches with its ASCII terms. \`includeSchemas: "compact"\` adds bounded input and available output shapes. An \`outputSchemaSource: "observed"\` shape is a hint, not a contract. Plain objects expose \`inputKeys\`, \`requiredInputKeys\`, and \`outputKeys\`; truncation flags mark incomplete shapes; matches also carry declared annotations.
27
26
 
28
27
  - \`connecta.search({})\` loads all catalogs. Pass \`connector: "<id>"\` when the integration is obvious. Use \`safety: "readOnly"\` for program calls. These inputs filter discovery; they grant no authority.
29
- - Request \`includeSchemas: "compact"\`. Check address, purpose, annotations, required inputs, truncation, safety, and available outputs. Never select only because a result ranks first or has fewer required inputs.
28
+ - Use \`includeSchemas: "json"\` for programmatic schema inspection; compact schemas are text, not objects with \`.properties\`. Check connectorTitle for the account/environment, then address, purpose, annotations, inputs, and outputs. Never select only because a result ranks first or has fewer required inputs.
30
29
  - Supply every \`requiredInputKey\` from the task or a prior result. For dependencies, match the earlier \`outputKey\` to the later required key. An empty required-key list does not permit invented arguments. Missing \`outputKeys\` means inspect \`outputSchema\`.
31
30
  - Use \`connecta.describe({ address })\` or \`{ addresses }\` when a compact schema is truncated or insufficient. Use \`format: "json"\` only for exact constraints. Write the property names the schema displays; never guess positions or aliases.
32
- - Reduce through available output keys. Treat an observed key as a hint, since later results may omit it or add others. Do not guess collection roots such as \`items\` or \`results\`. If a match or result key is missing, inspect, re-search, or describe inside the same run instead of returning discovery for another call.
31
+ - Reduce through available output keys. Treat an observed key as a hint, since later results may omit it or add others. Do not guess collection roots such as \`items\` or \`results\`. If a match is missing, re-search or describe. If a result shape is unclear, return a small sample for inspection before continuing.
33
32
  - Match provider identifiers and names exactly after resolving them from source data or a connector guide. A broad regular expression that merely finds a plausible value is not identity resolution.
34
- - Preserve the schema's JSON types exactly: a numeric id is a number, not a numeric-looking string. Call the search and describe functions directly; batch accepts canonical connector tool addresses, not guest API function names.
33
+ - Preserve the schema's JSON types exactly: a numeric id is a number, not a numeric-looking string.
35
34
  - Validate tabular headers, row arrays, and row widths before mapping them. Never let a header or partial row become data.
36
35
 
37
36
  Only tools explicitly annotated \`readOnlyHint: true\` are reachable. The catalog, credential, admission, and read-only gates run below the sandbox; code cannot widen its authority.
38
37
 
39
38
  ## Errors and repair
40
39
 
41
- Caught Connecta errors expose \`message\`, \`code\`, \`retryable\`, and \`details\`. Batch failures expose the same classification in \`errorDetails\`. Branch on fields, never prose. Do not retry \`retryable: false\`, and do not retry \`rate_limited\` immediately because portable code has no timer.
40
+ Caught Connecta errors expose \`message\`, \`code\`, \`retryable\`, and \`details\`. Promise rejections retain these fields. Branch on fields, never prose. After a shared argument failure, repair one call before repeating it across other records. Do not retry \`retryable: false\`, and do not retry \`rate_limited\` immediately because portable code has no timer.
42
41
 
43
42
  - \`destructive_tool_requires_approval\`: stop the program and use the returned canonical address with top-level \`call_destructive_tool\`.
44
43
  - \`auth_required\`: let the failure reach the model, then use top-level \`authorize_connector\`, give its handoff to the operator, and retry after recovery.
45
44
  - A truncated direct-call result: follow its \`get_result\` action. A truncated program result has no page handle; filter, map, or slice inside a new program.
46
45
  - Unknown addresses and tools carry scoped search recovery. Use it inside the current run. Do not invent an address.
47
46
 
48
- For a direct call, \`resultMode: "value"\` unwraps the result. \`timeoutMs\` sets its deadline. \`maxRetries\` is honored only for safely annotated tools. \`diagnostics: true\` adds timing.
47
+ For a direct call, \`resultMode: "value"\` unwraps the result. \`timeoutMs\` sets its deadline. Every call makes one attempt; use the returned error classification and retry hint to decide whether to reissue. \`diagnostics: true\` adds timing.
49
48
 
50
49
  \`get_result({ id, offset?, maxBytes? })\` returns \`{ text, offset, nextOffset?, totalBytes }\` for a direct-call result. Both sizes are byte counts: \`maxBytes\` must be a whole number at least 1 and defaults to the deployment cap; \`offset\` must be a whole number at least 0 and defaults to 0. An offset inside a multi-byte character moves back to its first byte, and the response reports the served offset. Follow \`nextOffset\` to reassemble pages. An unknown or expired id is an error.
51
50
 
52
- Limits: 20 host calls per run, 10 calls per batch, and a 15-second deadline per host call.
51
+ Limits: 20 host calls per run and a 15-second deadline per host call.
53
52
 
54
53
  ## Runtime portability
55
54
 
56
- Portable code uses only connector globals, \`connecta\`, and \`console.*\`. QuickJS blocks imports and lacks fetch, process, timers, crypto, and WebSocket. Dynamic Workers must use only \`{ loader }\`; bindings, modules, or globalOutbound grant ambient authority. With loader only, environment maps are empty; node:fs/http/https are absent; outbound fetch, WebSocket, node:net, and node:tls are denied; DNS is unresolved. Runtime builtins remain through \`import()\` and \`process.getBuiltinModule()\`, including node:path and cloudflare:workers; this set can drift. Timers, process, crypto, WebSocket, and data: fetch remain. Avoid every runtime-only capability because QuickJS fails.
55
+ Portable code uses standard JavaScript builtins, \`connecta\`, and \`console.*\`. QuickJS blocks imports and lacks fetch, process, timers, crypto, and WebSocket. Dynamic Workers must use only \`{ loader }\`; bindings, modules, or globalOutbound grant ambient authority. With loader only, environment maps are empty; node:fs/http/https are absent; outbound fetch, WebSocket, node:net, and node:tls are denied; DNS is unresolved. Runtime builtins remain through \`import()\` and \`process.getBuiltinModule()\`, including node:path and cloudflare:workers; this set can drift. Timers, process, crypto, WebSocket, and data: fetch remain. Avoid every runtime-only capability because QuickJS fails.
57
56
 
58
57
  ## Examples
59
58
 
@@ -61,13 +60,29 @@ One read-only call at a known address:
61
60
 
62
61
  \`async () => await connecta.call("crm.get_account", { id: "acct_42" })\`
63
62
 
64
- Dependent calls, only when the second needs a value from the first:
63
+ Dependent lookup: verify connector \`ci\`, run 42, and these schema fields first.
65
64
 
66
- \`async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", safety: "readOnly", includeSchemas: "compact" }); const address = (suffix) => { const tool = tools.find((entry) => entry.address.endsWith(suffix)); if (!tool) throw new Error("missing " + suffix); return tool.address; }; const run = await connecta.call(address(".get_run"), { runId: 42 }); const logs = await connecta.call(address(".get_job_logs"), { jobId: run.failedJobId }); return logs.map(({ timestamp, message }) => ({ timestamp, message })); }\`
65
+ \`\`\`js
66
+ async () => {
67
+ const find = async name => {
68
+ const page = await connecta.search({ connector: "ci", query: name, safety: "readOnly", includeSchemas: "compact" });
69
+ if (page.queryAnalysis?.catalogError) throw new Error(JSON.stringify(page.queryAnalysis.catalogError));
70
+ return page.tools.find(tool => tool.name === name);
71
+ };
72
+ const runTool = await find("get_run");
73
+ if (!runTool) return { gap: "Run lookup not resolved" };
74
+ const run = await connecta.call(runTool.address, { runId: 42 });
75
+ if (!run.failedJobId) return { status: run.status, gap: "No failed job identified" };
76
+ const logsTool = await find("get_job_logs");
77
+ if (!logsTool) return { status: run.status, gap: "Job logs not resolved" };
78
+ const logs = await connecta.call(logsTool.address, { jobId: run.failedJobId });
79
+ return logs.filter(row => row.level === "error").map(({ timestamp, message }) => ({ timestamp, message }));
80
+ }
81
+ \`\`\`
67
82
 
68
- ## Rendering a view
83
+ ## Media output
69
84
 
70
- \`connecta.emit\` accepts text, image, or audio blocks and delivers them only on success. \`connecta.ui(html)\` renders one success-only display view outside model context. The view has local HTML and JavaScript only: no network, connector calls, discovery, conversation messages, or host links. One shared budget applies to the UI and emitted content, not separate budgets. Fetch and check the data shape first. On empty or missing data, return a trimmed first record instead of rendering. Otherwise render returned variables and return the same summary because the model reads the return value, not the view. A second, invalid, or over-budget UI call throws catchably.
85
+ \`connecta.emit\` accepts text, image, or audio blocks and delivers them only on success. Its byte and block budgets are separate from the JSON return budget. Return data for the client to render as a view.
71
86
 
72
87
  `;
73
88
  /** Deployment-scoped guide routing appended to the shared usage guide. */
@@ -95,12 +110,33 @@ export function hasConnectorGuides(connectors) {
95
110
  function usageSkill(_connectors) {
96
111
  return USAGE_SKILL;
97
112
  }
113
+ const INVESTIGATE_SKILL = `# Investigate across services
114
+
115
+ ## Plan the investigation
116
+
117
+ Start from the user's question and the evidence that would answer it. Select the app, account, and environment from connector titles, purposes, and relevant guides before looking up records. Reuse verified ids within the task; never carry an id across connectors just because its name matches.
118
+
119
+ - Purchase verification: resolve the same customer and environment across payment, subscription access, and analytics. Check each separately; a recorded payment does not prove access, and a missing analytics event does not prove payment failure.
120
+ - Experiment checks: confirm the project, experiment, time window, and exposure population before comparing outcomes. Return the requested comparison and any missing evidence; do not expand into an unrelated analytics audit.
121
+ - Customer or deployment investigations: locate the exact customer or deployment first, then follow only the records needed to explain the reported symptom. Use provider links or ids so the answer can be checked.
122
+
123
+ Establish capability limits early. A partial search is not proof that a tool is absent, and an unavailable catalog is not an empty dataset. Try a scoped search for the missing operation, inspect its guide when relevant, and distinguish unsupported work from missing data or authorization. If the required evidence is unavailable, return what was verified and the specific gap instead of approximating a different question through repeated calls.
124
+
125
+ A host transport error that requires reconnecting this MCP server cannot be repaired by a downstream tool. Reconnect in the host; do not repeatedly call \`authorize_connector\` through the failed connection.
126
+
127
+ Return the answer first, then the evidence and any unresolved gap. Include the app/environment, time window, and source ids or links needed to check it. Separate observed facts from inferences. Stop when the requested evidence is sufficient.
128
+ `;
98
129
  const AVAILABLE_SKILLS = [
99
130
  {
100
131
  name: "usage",
101
132
  description: "How to route work between one execute_code program and Connecta's explicit call, authorization, and result tools.",
102
133
  content: usageSkill,
103
134
  },
135
+ {
136
+ name: "investigate",
137
+ description: "Plan purchase verification, experiment checks, and customer or deployment investigations across services; resolve scope and capability limits before querying.",
138
+ content: () => INVESTIGATE_SKILL,
139
+ },
104
140
  ];
105
141
  /**
106
142
  * Namespace for operator-authored per-connector guides. Built-in skill names
package/dist/types.d.ts CHANGED
@@ -132,6 +132,8 @@ export interface CredentialTestResult {
132
132
  message?: string;
133
133
  }
134
134
  export interface ConnectorContext {
135
+ /** Explicit downstream consent initiation, never set by status/catalog/calls. */
136
+ allowAuthorization?: boolean;
135
137
  /** Storage namespaced to this connector. */
136
138
  storage: KVStorage;
137
139
  logger: Logger;
@@ -312,19 +314,6 @@ export interface Connector {
312
314
  * server's RFC 9207 `iss` response parameter for SDK validation.
313
315
  */
314
316
  finishAuth?(code: string, ctx: ConnectorContext, callbackParams?: URLSearchParams): Promise<void>;
315
- /**
316
- * Optional: serve a connector-owned HTTP route — for example a signed
317
- * download link minted by one of the connector's tools. Called only after
318
- * every built-in route misses, so a connector can never shadow `/mcp`,
319
- * `/`, `/credentials`, `/activity`, `/health`, or the credential API. The
320
- * first connector to return a Response wins, in registration order; return
321
- * null to decline. See documentation/architecture.md route 9.
322
- *
323
- * These routes are PUBLIC: connecta applies no auth gate to them. A
324
- * connector that serves data here MUST authenticate the request itself — for
325
- * example with a signed capability token in the URL.
326
- */
327
- handleRequest?(request: Request, ctx: ConnectorContext): Promise<Response | null>;
328
317
  }
329
318
  export interface ConnectorUsageGuide {
330
319
  /** Markdown returned verbatim by `skills({ name: "connector:<id>" })`. */
@@ -355,8 +344,7 @@ export interface ExecutorProvider {
355
344
  fns: Record<string, (...args: unknown[]) => Promise<unknown>>;
356
345
  /**
357
346
  * Optional trusted sandbox-side setup run after provider globals exist.
358
- * Connecta uses this to install lazy connector namespace proxies without
359
- * materializing one host closure per tool. This is host-authored code, never
347
+ * Connecta uses this to restore typed host errors. This is host-authored code, never
360
348
  * model input. See documentation/code-mode.md#what-an-executor-must-implement.
361
349
  */
362
350
  prelude?: string;