@zackbart/connecta 0.24.4 → 0.26.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 (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. package/dist/timeout.d.ts +0 -21
package/dist/types.d.ts CHANGED
@@ -12,6 +12,26 @@ export interface KVStorage {
12
12
  * subsystems that need independent, enumerable records require it explicitly.
13
13
  */
14
14
  list?(prefix: string): Promise<string[]>;
15
+ /**
16
+ * Atomic compare-and-set: write `next` only if the key's current value is
17
+ * exactly `expected`, and report whether the write happened. `expected:
18
+ * null` means "absent", and an expired entry is absent. `next: null`
19
+ * deletes; otherwise `next` is stored with `options.ttlSeconds`, or with no
20
+ * expiry when that is omitted, exactly as `set` would. On `false` nothing
21
+ * changed.
22
+ *
23
+ * Atomic means linearizable against every other operation on the same
24
+ * store, `set` and `delete` included: of N concurrent `compareAndSet(key,
25
+ * null, value)` claims on an absent key, exactly one returns true.
26
+ *
27
+ * Optional, because not every backend can promise it: an eventually
28
+ * consistent store such as Cloudflare Workers KV must omit the method
29
+ * rather than emulate it with a read followed by a write. Subsystems that
30
+ * need an atomic claim require it explicitly.
31
+ */
32
+ compareAndSet?(key: string, expected: string | null, next: string | null, options?: {
33
+ ttlSeconds?: number;
34
+ }): Promise<boolean>;
15
35
  }
16
36
  export interface Logger {
17
37
  debug(...args: unknown[]): void;
@@ -106,7 +126,7 @@ export interface ConnectorCredentialFieldConfig {
106
126
  name: string;
107
127
  /** Short field label, e.g. "Account email". */
108
128
  label: string;
109
- /** Plain-language guidance shown in /credentials. Never include the credential itself. */
129
+ /** Plain-language guidance shown in the connection's credential form in the operator UI. Never include the credential itself. */
110
130
  description?: string;
111
131
  /** Input placeholder, e.g. "you@example.com". */
112
132
  placeholder?: string;
@@ -117,7 +137,7 @@ export interface ConnectorCredentialFieldConfig {
117
137
  export interface ConnectorCredentialConfig {
118
138
  /** Short group or field label, e.g. "API token" or "Service credentials". */
119
139
  label: string;
120
- /** Plain-language guidance shown in /credentials. Never include the credential itself. */
140
+ /** Plain-language guidance shown in the connection's credential form in the operator UI. Never include the credential itself. */
121
141
  description?: string;
122
142
  /** Password-field placeholder, e.g. "Paste API token". */
123
143
  placeholder?: string;
@@ -129,6 +149,11 @@ export interface ConnectorCredentialConfig {
129
149
  }
130
150
  export interface CredentialTestResult {
131
151
  ok: boolean;
152
+ /**
153
+ * Detail for the deployment's log. The operator page shows fixed copy for
154
+ * `ok` and never this text, since a downstream's reply quoted here can
155
+ * quote the credential it rejected.
156
+ */
132
157
  message?: string;
133
158
  }
134
159
  export interface ConnectorContext {
@@ -142,7 +167,7 @@ export interface ConnectorContext {
142
167
  /**
143
168
  * Read-only access to this connector's human-managed credential. Present
144
169
  * only when the connector declares `credential` and the deployment configures
145
- * `credentials.encryptionKey`.
170
+ * `vault`.
146
171
  */
147
172
  credential?: ConnectorCredentialAccess;
148
173
  /**
@@ -220,12 +245,23 @@ export interface Connector {
220
245
  title?: string;
221
246
  /** How call_tool wraps results. "mcp" passes the content array through; anything else is JSON-wrapped. */
222
247
  kind?: "mcp" | "api";
248
+ /**
249
+ * This connector's own default for its tools that are not explicitly
250
+ * read-only, inside `execute_code`: `"never"` lets a program call them
251
+ * without pausing for approval. Reserved for connectors connecta ships
252
+ * whose writes are cheap to undo (the planned artifacts connector, whose
253
+ * every write is a new immutable version); a deployment's own connectors
254
+ * leave it unset and exempt through `execute.approval`, which overrides
255
+ * this either way. Never read from a downstream catalog — a server cannot
256
+ * exempt its own tools — and never makes a tool read-only anywhere else.
257
+ */
258
+ approval?: "never";
223
259
  description?: string;
224
260
  /**
225
261
  * Max inline result size (bytes) for this connector's tools before
226
262
  * call_tool truncates and stashes the full text for get_result
227
263
  * paging. Overrides `ConnectaConfig.calls.maxResultBytes`;
228
- * omit to inherit it (which itself defaults to 50_000). Must be a whole
264
+ * omit to inherit it (which itself defaults to 24_000). Must be a whole
229
265
  * number of bytes >= 1; anything else warns at startup and is ignored, so
230
266
  * the connector inherits the deployment-wide cap.
231
267
  */
@@ -248,11 +284,11 @@ export interface Connector {
248
284
  * configuration; no runtime registration or shared mutable copy exists.
249
285
  */
250
286
  usageGuide?: string | ConnectorUsageGuide;
251
- /** Optional human-managed credential slot rendered on /credentials. */
287
+ /** Optional human-managed credential slot rendered in the connection in the operator UI. */
252
288
  credential?: ConnectorCredentialConfig;
253
- /** Optional server-side check used by /credentials' Test action. */
289
+ /** Optional server-side check used by the connection's Test action in the operator UI. */
254
290
  testCredential?(value: string, ctx: ConnectorContext): Promise<CredentialTestResult>;
255
- /** Optional multi-field credential check used by /credentials' Test action. */
291
+ /** Optional multi-field credential check used by the connection's Test action in the operator UI. */
256
292
  testCredentials?(values: ConnectorCredentialValues, ctx: ConnectorContext): Promise<CredentialTestResult>;
257
293
  /**
258
294
  * Statically-known tool defs, exposed by in-code connectors (`api()`) for
@@ -374,6 +410,8 @@ export interface AdmissionSnapshot {
374
410
  maxQueueSize: number;
375
411
  queueTimeoutMs: number;
376
412
  retryAfterMs: number;
413
+ /** Hard admitted-request lifetime, present only on the /mcp pool. */
414
+ maxDurationMs?: number;
377
415
  active: number;
378
416
  queued: number;
379
417
  closed: boolean;
package/dist/ui.d.ts CHANGED
@@ -1,14 +1,31 @@
1
1
  import type { OperatorSurface } from "./module-contracts.js";
2
2
  import type { CredentialVault } from "./credential-contract.js";
3
3
  import { type DeferredWork } from "./connector-scope.js";
4
- import { type CredentialManagementCapability, type UiData } from "./operator-ui/model.js";
4
+ import { type CredentialManagementCapability, type UiData, type UiProblem, type UiToolSafety } from "./operator-ui/model.js";
5
+ import { type ApprovalPolicy } from "./tool-safety.js";
5
6
  import type { RegistryView } from "./registry.js";
6
- import type { ConnectaBranding, UiAuthConfig } from "./types.js";
7
+ import type { ConnectaBranding, Connector, ConnectorStatus, ToolDef, UiAuthConfig } from "./types.js";
7
8
  export { filterUiConnectors, type CredentialManagementCapability, type UiConnector, type UiData, } from "./operator-ui/model.js";
8
9
  export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
9
- export type OperatorPage = "connections" | "activity";
10
+ export type OperatorPage = "connections" | "activity" | "artifacts" | "artifact";
10
11
  export declare function operatorPageForPath(path: string): OperatorPage | undefined;
11
12
  export declare function operatorPageTitle(page: OperatorPage, configuredTitle: string): string;
13
+ /**
14
+ * The badge a tool earns, from the predicates core enforces with. Computed
15
+ * here so the page reports the rule rather than restating it. `exempt` is the
16
+ * config exemption (#566), which `isApprovalExempt` already refuses to grant
17
+ * a read-only tool.
18
+ */
19
+ export declare function uiToolSafety(definition: ToolDef, exempt?: boolean): UiToolSafety;
20
+ /**
21
+ * The fix-prompt key for a connector that is not usable. Chosen from the
22
+ * status state and the connector's declared auth shape — configuration, never
23
+ * the status message, which can carry a downstream error body.
24
+ */
25
+ export declare function uiProblemFor(connector: Pick<Connector, "credential" | "startAuth">, status: ConnectorStatus["state"], observed: {
26
+ credentialDrift: boolean;
27
+ catalogFailed: boolean;
28
+ }): UiProblem | undefined;
12
29
  export declare function credentialManagementCapability(input: {
13
30
  eligibleOperator: boolean;
14
31
  hasCredentialSlots: boolean;
@@ -26,12 +43,16 @@ export declare function buildUiData(registry: RegistryView, baseUrl: string, ser
26
43
  mayManage?: (id: string) => boolean;
27
44
  timeoutMs?: number;
28
45
  signal?: AbortSignal;
46
+ approval?: ApprovalPolicy;
29
47
  }): Promise<UiData>;
30
48
  /**
31
49
  * Every operator page serves this same data-free shell. Connector, credential,
32
50
  * and activity data arrives only through the authenticated `/ui/*` APIs.
33
51
  */
34
- export declare function renderUiHtml(uiAuth?: UiAuthConfig, mcpUrl?: string, branding?: ConnectaBranding, nonce?: string, page?: OperatorPage): string;
52
+ export declare function renderUiHtml(uiAuth?: UiAuthConfig, mcpUrl?: string, branding?: ConnectaBranding, nonce?: string, page?: OperatorPage, options?: {
53
+ /** Where the Connections page lives, when this shell is on another origin. */
54
+ homeUrl?: string;
55
+ }): string;
35
56
  /** Mount the connection UI without enabling any storage or activity module. */
36
57
  export declare function operatorUi(options?: {
37
58
  branding?: ConnectaBranding;
package/dist/ui.js CHANGED
@@ -1,12 +1,10 @@
1
1
  import { routeUi } from "./routes/ui.js";
2
2
  import { routeCredentials } from "./routes/credentials.js";
3
3
  import { routeOAuthManagement } from "./routes/oauth-management.js";
4
- import { withDeadline } from "./timeout.js";
5
- import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "./credential-rules.js";
6
- import { closeConnectorScope, } from "./connector-scope.js";
7
- import { mapSettledWithConcurrency, resolveDiscoveryConcurrency, } from "./concurrency.js";
4
+ import { uiData } from "./routes/ui-data.js";
5
+ import { runEdge } from "./runtime/run.js";
6
+ import { isExplicitlyReadOnly } from "./tool-safety.js";
8
7
  import { OPERATOR_UI_CSS, OPERATOR_UI_SCRIPT, } from "./operator-ui/generated.js";
9
- import { CONNECTA_VERSION } from "./version.js";
10
8
  export { filterUiConnectors, } from "./operator-ui/model.js";
11
9
  import { resolveBranding, isSafeHttpsUrl, themeCss } from "./branding.js";
12
10
  export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
@@ -20,17 +18,57 @@ function stringForInlineScript(value) {
20
18
  const OPERATOR_PAGE_LABELS = {
21
19
  connections: "Connections",
22
20
  activity: "Activity",
21
+ artifacts: "Artifacts",
22
+ artifact: "Artifact",
23
23
  };
24
+ /** `/artifacts/<id>` and its snapshots, `/artifacts/<id>/v/<version>`. */
25
+ const ARTIFACT_PAGE = /^\/artifacts\/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\/v\/\d{1,9})?$/;
24
26
  export function operatorPageForPath(path) {
25
27
  if (path === "/")
26
28
  return "connections";
27
29
  if (path === "/activity")
28
30
  return "activity";
31
+ if (path === "/artifacts")
32
+ return "artifacts";
33
+ if (ARTIFACT_PAGE.test(path))
34
+ return "artifact";
29
35
  return undefined;
30
36
  }
37
+ /** Everything artifact pages own: shells, their API, and the frame. */
38
+ function isArtifactPath(path) {
39
+ return path === "/artifacts" || path.startsWith("/artifacts/");
40
+ }
31
41
  export function operatorPageTitle(page, configuredTitle) {
32
42
  return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
33
43
  }
44
+ /**
45
+ * The badge a tool earns, from the predicates core enforces with. Computed
46
+ * here so the page reports the rule rather than restating it. `exempt` is the
47
+ * config exemption (#566), which `isApprovalExempt` already refuses to grant
48
+ * a read-only tool.
49
+ */
50
+ export function uiToolSafety(definition, exempt = false) {
51
+ if (isExplicitlyReadOnly(definition))
52
+ return "runs_in_programs";
53
+ return exempt ? "exempt" : "needs_approval";
54
+ }
55
+ /**
56
+ * The fix-prompt key for a connector that is not usable. Chosen from the
57
+ * status state and the connector's declared auth shape — configuration, never
58
+ * the status message, which can carry a downstream error body.
59
+ */
60
+ export function uiProblemFor(connector, status, observed) {
61
+ if (observed.credentialDrift)
62
+ return "credential_mismatch";
63
+ if (status === "error")
64
+ return "connector_unavailable";
65
+ if (status === "auth_required") {
66
+ if (connector.startAuth)
67
+ return "oauth_required";
68
+ return connector.credential ? "credential_required" : "auth_required";
69
+ }
70
+ return observed.catalogFailed ? "catalog_failed" : undefined;
71
+ }
34
72
  export function credentialManagementCapability(input) {
35
73
  if (!input.eligibleOperator)
36
74
  return "requires_operator";
@@ -48,169 +86,17 @@ export function credentialManagementCapability(input) {
48
86
  export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false, credentialManagement = credentialVault
49
87
  ? "available"
50
88
  : "requires_operator", defer, oauthManagement = false, discoveryConcurrency, personalCredentialOwner, detailOptions = {}) {
51
- const requestScope = {};
52
- const connectorSet = registry.listConnectors();
53
- const concurrency = resolveDiscoveryConcurrency(discoveryConcurrency);
54
- const settled = await mapSettledWithConcurrency(connectorSet, concurrency, async (c) => {
55
- try {
56
- return await withDeadline(async (outerSignal) => {
57
- const drift = await registry.credentialDriftFor(c.id);
58
- outerSignal.throwIfAborted();
59
- let tools = [];
60
- let status;
61
- try {
62
- status = await withDeadline(async (signal) => {
63
- if (drift)
64
- return { state: "auth_required", message: drift };
65
- const current = await registry.statusFor(c.id, baseUrl, requestScope, { signal });
66
- if (current.state === "ok" && !signal.aborted) {
67
- try {
68
- tools = (await registry.getTools(c.id, baseUrl, requestScope, { signal })).map(t => ({ name: t.name, address: `${c.id}.${t.name}`, ...(t.description ? { description: t.description } : {}) }));
69
- }
70
- catch { /* The registry owns the failed catalog observation. */ }
71
- }
72
- return current;
73
- }, { timeoutMs: detailOptions.timeoutMs ?? 30_000, signal: outerSignal, timeoutError: new Error("Connection details timed out. Retry this connection.") });
74
- }
75
- catch (error) {
76
- status = { state: "error", message: error instanceof Error ? error.message : "Connection details unavailable" };
77
- }
78
- let credential;
79
- const mayManageAuth = detailOptions.mayManage?.(c.id) ?? (c.authScope === "personal" ? Boolean(personalCredentialOwner) : oauthManagement);
80
- if (c.credential && credentialVault && mayManageAuth) {
81
- // One rule, shared with the test route: only the hook matching the
82
- // declared credential shape can run, so the button is offered only
83
- // where a click can succeed (src/credentials.ts).
84
- const testRule = credentialTestRule(c);
85
- const credentialFields = (metadata) => c.credential?.fields?.map((field) => {
86
- const fieldMetadata = metadata?.fields?.[field.name];
87
- return {
88
- name: field.name,
89
- label: field.label,
90
- ...(field.description
91
- ? { description: field.description }
92
- : {}),
93
- ...(field.placeholder
94
- ? { placeholder: field.placeholder }
95
- : {}),
96
- inputType: field.inputType ?? "password",
97
- configured: Boolean(fieldMetadata),
98
- ...(fieldMetadata
99
- ? {
100
- lastFour: fieldMetadata.lastFour,
101
- updatedAt: fieldMetadata.updatedAt,
102
- }
103
- : {}),
104
- };
105
- });
106
- const credentialCard = {
107
- label: c.credential.label,
108
- ...(c.credential.description
109
- ? { description: c.credential.description }
110
- : {}),
111
- ...(c.credential.placeholder
112
- ? { placeholder: c.credential.placeholder }
113
- : {}),
114
- };
115
- try {
116
- const metadata = await credentialVault.metadata(c.id, c.authScope === "personal" ? personalCredentialOwner : undefined);
117
- const fields = credentialFields(metadata);
118
- const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
119
- credential = {
120
- ...credentialCard,
121
- ...(fields?.length ? { fields } : {}),
122
- configured: shape.state === "valid",
123
- removable: Boolean(metadata),
124
- ...(metadata
125
- ? {
126
- lastFour: metadata.lastFour,
127
- updatedAt: metadata.updatedAt,
128
- }
129
- : {}),
130
- testable: testRule.mode !== null && shape.state !== "mismatch",
131
- ...(shape.state === "mismatch"
132
- ? { error: shape.message }
133
- : {}),
134
- // A dropped field leaves its secret in the vault, and the field
135
- // list below only renders fields the connector still declares —
136
- // so without this line there is nowhere an operator could see it.
137
- ...(shape.state === "valid" && shape.undeclared.length
138
- ? {
139
- notice: describeUndeclaredCredentialFields(shape.undeclared),
140
- }
141
- : {}),
142
- };
143
- }
144
- catch {
145
- const fields = credentialFields();
146
- credential = {
147
- ...credentialCard,
148
- ...(fields?.length ? { fields } : {}),
149
- configured: false,
150
- removable: true,
151
- testable: testRule.mode !== null,
152
- error: "Stored credential could not be read.",
153
- };
154
- }
155
- }
156
- outerSignal.throwIfAborted();
157
- return {
158
- id: c.id,
159
- authScope: c.authScope ?? "shared",
160
- ...(c.title ? { title: c.title } : {}),
161
- ...(c.description !== undefined
162
- ? { description: c.description }
163
- : {}),
164
- status: status.state,
165
- ...(status.message ? { message: status.message } : {}),
166
- toolCount: tools.length,
167
- tools,
168
- // Counts only, and only when a refresh in this runtime produced them.
169
- // `Registry.statusFor` already rebuilt the report through
170
- // `boundedCatalogDrift`, so what lands here cannot carry a name or a
171
- // schema even if the plugin seam returned one.
172
- ...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
173
- ...(status.catalogAccess
174
- ? { catalogAccess: status.catalogAccess }
175
- : {}),
176
- oauth: Boolean(c.startAuth && c.disconnectAuth),
177
- ...(credential ? { credential } : {}),
178
- };
179
- }, {
180
- timeoutMs: detailOptions.timeoutMs ?? 30_000,
181
- ...(detailOptions.signal ? { signal: detailOptions.signal } : {}),
182
- timeoutError: new Error("Connection details timed out. Retry this connection."),
183
- });
184
- }
185
- catch (error) {
186
- return {
187
- id: c.id,
188
- ...(c.title ? { title: c.title } : {}),
189
- authScope: c.authScope ?? "shared",
190
- status: "error",
191
- oauth: Boolean(c.startAuth && c.disconnectAuth),
192
- message: error instanceof Error ? error.message : "Connection details unavailable",
193
- toolCount: 0,
194
- tools: [],
195
- };
196
- }
197
- finally {
198
- await closeConnectorScope(c, registry.contextFor(c.id, baseUrl, requestScope), defer);
199
- }
200
- });
201
- const connectors = settled.map((result) => {
202
- if (result.status === "rejected")
203
- throw result.reason;
204
- return result.value;
205
- });
206
- return {
89
+ return runEdge(uiData(registry, baseUrl, {
207
90
  serverInfo,
208
- connectaVersion: CONNECTA_VERSION,
209
- connectors,
91
+ credentialVault,
210
92
  activityEnabled,
211
93
  credentialManagement,
212
- oauthManagement: oauthManagement || Boolean(personalCredentialOwner),
213
- };
94
+ defer,
95
+ oauthManagement,
96
+ discoveryConcurrency,
97
+ personalCredentialOwner,
98
+ ...detailOptions,
99
+ }));
214
100
  }
215
101
  function escapeHtmlAttr(value) {
216
102
  return value
@@ -229,7 +115,7 @@ function jsonForInlineScript(value) {
229
115
  * Every operator page serves this same data-free shell. Connector, credential,
230
116
  * and activity data arrives only through the authenticated `/ui/*` APIs.
231
117
  */
232
- export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "connections") {
118
+ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "connections", options = {}) {
233
119
  const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
234
120
  // The Clerk loader's origin. A value that fails the gate is dropped rather
235
121
  // than escaped into the page: the loader tag is simply not emitted, the gate
@@ -327,6 +213,7 @@ ${clerkScript}
327
213
  const AUTH = ${jsonForInlineScript(auth)};
328
214
  const MCP_URL = ${jsonForInlineScript(mcpUrl)};
329
215
  const INITIAL_PAGE = ${jsonForInlineScript(page)};
216
+ const HOME_URL = ${jsonForInlineScript(options.homeUrl ?? "/")};
330
217
  const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
331
218
  const PRODUCT_NAME = ${stringForInlineScript(brand.productName)};
332
219
  const PRODUCT_DESCRIPTION = ${stringForInlineScript(brand.description)};
@@ -335,15 +222,38 @@ ${OPERATOR_UI_SCRIPT}</script>
335
222
  </body>
336
223
  </html>`;
337
224
  }
225
+ function ownsOperatorPath(reserved, path) {
226
+ if (path === "/activity")
227
+ return true;
228
+ return reserved.some((pattern) => pattern.endsWith("/*")
229
+ ? path.startsWith(pattern.slice(0, -1))
230
+ : path === pattern);
231
+ }
338
232
  /** Mount the connection UI without enabling any storage or activity module. */
339
233
  export function operatorUi(options = {}) {
234
+ const reservedPaths = ["/", "/ui", "/ui/*", "/favicon.svg", "/favicon.ico"];
340
235
  return {
341
236
  ...options,
342
- reservedPaths: ["/", "/ui", "/ui/*", "/favicon.svg", "/favicon.ico"],
237
+ reservedPaths,
343
238
  credentialHandoffUrl(baseUrl) {
344
239
  return new URL("/", baseUrl).toString();
345
240
  },
346
241
  async handle(context) {
242
+ // Everything else belongs to the server, and reaches it untouched: no
243
+ // route here, and no activity module, runs for a path this surface does
244
+ // not own. The Activity page is the one addition; the server reserves
245
+ // it only when history is readable, and routeUi checks the same thing.
246
+ // Artifact pages are the other: they exist only beside the module.
247
+ const artifacts = context.opts.artifactsModule;
248
+ const artifactPath = Boolean(artifacts) && isArtifactPath(context.path);
249
+ if (!artifactPath && !ownsOperatorPath(reservedPaths, context.path))
250
+ return null;
251
+ if (artifactPath && !operatorPageForPath(context.path)) {
252
+ // The pages' JSON API and the sandboxed frame; the module owns both,
253
+ // and this bundle imports none of it.
254
+ return (await artifacts?.handle(context)) ??
255
+ new Response("Not Found", { status: 404 });
256
+ }
347
257
  const routes = [
348
258
  ...(context.opts.credentialVault ? [routeCredentials] : []),
349
259
  routeOAuthManagement,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Syntactic destination checks for URLs a downstream hands connecta to fetch.
3
+ *
4
+ * A remote MCP server's OAuth metadata names further URLs — authorization
5
+ * servers, token and registration endpoints — that connecta then requests
6
+ * server-side. Config is the security model: the connector's configured
7
+ * origin is trusted because an operator wrote it down. Everything else was
8
+ * learned from the downstream, and a compromised downstream must not be able
9
+ * to steer connecta at the host's own network (cloud metadata, a LAN admin
10
+ * panel, a sidecar on loopback).
11
+ *
12
+ * The check is by syntax only. The WHATWG URL parser has already folded every
13
+ * legacy IPv4 spelling (`2130706433`, `0x7f.1`, `0177.0.0.1`) into dotted
14
+ * decimal and every IPv6 literal into compressed hex, so one canonical form is
15
+ * all this module reads. It never resolves a name: that needs DNS, which the
16
+ * Workers-safe core cannot do, and a name that resolves somewhere private — or
17
+ * rebinds after the check — is out of scope. Pure Web API, no `node:` import.
18
+ */
19
+ function classifyIpv4(octets) {
20
+ const [a = 0, b = 0] = octets;
21
+ if (a === 127)
22
+ return "loopback";
23
+ if (a === 0 ||
24
+ a === 10 ||
25
+ (a === 172 && b >= 16 && b <= 31) ||
26
+ (a === 192 && b === 168) ||
27
+ (a === 169 && b === 254) ||
28
+ (a === 100 && b >= 64 && b <= 127)) {
29
+ return "private";
30
+ }
31
+ return "public";
32
+ }
33
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
34
+ const HEXTET = /^[0-9a-f]{1,4}$/;
35
+ function ipv4Octets(host) {
36
+ const match = IPV4.exec(host);
37
+ if (!match)
38
+ return undefined;
39
+ const octets = match.slice(1).map(Number);
40
+ return octets.every((octet) => octet <= 255) ? octets : undefined;
41
+ }
42
+ /** Eight 16-bit pieces of a bracketed IPv6 literal, or undefined. */
43
+ function ipv6Pieces(host) {
44
+ const body = host.slice(1, -1);
45
+ const halves = body.split("::");
46
+ if (halves.length > 2)
47
+ return undefined;
48
+ const pieces = (part) => {
49
+ if (!part)
50
+ return [];
51
+ const parsed = part.split(":").map((piece) => HEXTET.test(piece) ? Number.parseInt(piece, 16) : Number.NaN);
52
+ return parsed.some(Number.isNaN) ? undefined : parsed;
53
+ };
54
+ const head = pieces(halves[0]);
55
+ const tail = pieces(halves[1]);
56
+ if (!head || !tail)
57
+ return undefined;
58
+ const missing = 8 - head.length - tail.length;
59
+ if (halves.length === 1 ? missing !== 0 : missing < 0)
60
+ return undefined;
61
+ return [...head, ...Array.from({ length: missing }, () => 0), ...tail];
62
+ }
63
+ function classifyIpv6(pieces) {
64
+ const [first = 0] = pieces;
65
+ const leadingZeros = pieces.slice(0, 5).every((piece) => piece === 0);
66
+ if (leadingZeros && pieces[5] === 0 && pieces[6] === 0) {
67
+ if (pieces[7] === 0)
68
+ return "private"; // ::
69
+ if (pieces[7] === 1)
70
+ return "loopback"; // ::1
71
+ }
72
+ // ::ffff:a.b.c.d reaches the mapped IPv4 destination; classify that.
73
+ if (leadingZeros && pieces[5] === 0xffff) {
74
+ const high = pieces[6] ?? 0;
75
+ const low = pieces[7] ?? 0;
76
+ return classifyIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]);
77
+ }
78
+ if ((first & 0xfe00) === 0xfc00)
79
+ return "private"; // fc00::/7
80
+ if ((first & 0xffc0) === 0xfe80)
81
+ return "private"; // fe80::/10
82
+ return "public";
83
+ }
84
+ /**
85
+ * The hostname the WHATWG parser settles on. Idempotent for a `URL.hostname`;
86
+ * anything else — a bare IPv6 literal, a legacy IPv4 spelling — is folded the
87
+ * same way the parser would fold it on the way to a fetch.
88
+ */
89
+ function canonicalHost(hostname) {
90
+ if (/[/\\?#@]/.test(hostname))
91
+ return undefined;
92
+ const bracketed = hostname.includes(":") && !hostname.startsWith("[")
93
+ ? `[${hostname}]`
94
+ : hostname;
95
+ try {
96
+ return new URL(`http://${bracketed}/`).hostname;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ /**
103
+ * Classify a URL host by syntax. A host that does not parse is `private`:
104
+ * the check fails closed rather than guessing.
105
+ */
106
+ export function classifyHost(hostname) {
107
+ const host = canonicalHost(hostname);
108
+ if (!host)
109
+ return "private";
110
+ if (host.startsWith("[")) {
111
+ const pieces = ipv6Pieces(host);
112
+ return pieces ? classifyIpv6(pieces) : "private";
113
+ }
114
+ const octets = ipv4Octets(host);
115
+ if (octets)
116
+ return classifyIpv4(octets);
117
+ const name = host.endsWith(".") ? host.slice(0, -1) : host;
118
+ if (name === "localhost" || name.endsWith(".localhost"))
119
+ return "loopback";
120
+ return "public";
121
+ }
122
+ /** True for loopback and private hosts alike. */
123
+ export function isPrivateHost(hostname) {
124
+ return classifyHost(hostname) !== "public";
125
+ }
126
+ /**
127
+ * Why connecta must not fetch `target`, a URL the downstream advertised, for a
128
+ * connector configured at `configured` — or undefined when it may.
129
+ *
130
+ * - The configured origin is trusted outright: the operator chose it.
131
+ * - Anything else must be `https` on a host that is not private by syntax.
132
+ * - A loopback-configured connector (local development) may also learn
133
+ * loopback URLs, over `http` too, so a local MCP server with a local
134
+ * authorization server keeps working. It still may not learn a LAN or
135
+ * link-local address.
136
+ *
137
+ * The reason names only the host, never the path, query, or credentials a
138
+ * learned URL may carry.
139
+ */
140
+ export function learnedUrlRefusal(configured, target) {
141
+ if (target.protocol !== "https:" && target.protocol !== "http:") {
142
+ return `${target.host || "the target"} uses ${target.protocol}, not HTTPS`;
143
+ }
144
+ if (target.origin === configured.origin)
145
+ return undefined;
146
+ const targetClass = classifyHost(target.hostname);
147
+ if (targetClass === "loopback" &&
148
+ classifyHost(configured.hostname) === "loopback") {
149
+ return undefined;
150
+ }
151
+ if (targetClass !== "public") {
152
+ return `${target.host} is a private, loopback, or link-local address`;
153
+ }
154
+ if (target.protocol !== "https:")
155
+ return `${target.host} is not HTTPS`;
156
+ return undefined;
157
+ }
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.24.4";
7
+ export declare const CONNECTA_VERSION = "0.26.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.24.4";
7
+ export const CONNECTA_VERSION = "0.26.0";