@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
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The frame every copyable fix prompt shares: the operator page's failure
3
+ * notices and the OAuth callback page both hand an operator text to paste into
4
+ * a coding agent working in the deployment's repository.
5
+ *
6
+ * A prompt is assembled from fixed catalogue text and, at most, a configured
7
+ * connector id. It has no parameter an error message, a downstream body, a
8
+ * token, or a URL could arrive through — which is the whole guarantee: the
9
+ * text leaves the page on a clipboard, into a tool the deployment does not
10
+ * control, so it must be something the deployment would be content to publish.
11
+ * The agent finds the underlying cause where it belongs, in the deployment's
12
+ * own logs and a local reproduction.
13
+ */
14
+ const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
15
+ /**
16
+ * Render one prompt. A connector id is the only variable part, and only when
17
+ * it has the shape configuration enforces; anything else is dropped rather
18
+ * than escaped, since a value that is not a connector id is not config.
19
+ */
20
+ export function renderFixPrompt(spec, connectorId) {
21
+ const id = connectorId !== undefined && CONNECTOR_ID_RE.test(connectorId)
22
+ ? connectorId
23
+ : undefined;
24
+ return [
25
+ "Diagnose and fix a problem in this connecta deployment (the @zackbart/connecta package). " +
26
+ "It is configured as code: connectors, credential slots, OAuth clients, pools, and inbound auth " +
27
+ "are declared in the deployment's source and environment, so the fix belongs there and not in the operator page.",
28
+ `Problem: ${spec.problem}` + (id ? `\nConnector id: ${id}` : ""),
29
+ `Where to look:\n${spec.steps.map((step) => `- ${step}`).join("\n")}`,
30
+ "This prompt deliberately carries no error text, tokens, or URLs. Find the underlying cause in the deployment's " +
31
+ "own logs (lines prefixed [connecta]) or by reproducing the failure locally. Never put a secret in source, " +
32
+ "a log line, or your reply: secrets belong in the deployment's environment or its credential vault.",
33
+ "Make the smallest change that fixes it, then verify it by redeploying and refreshing the connection on the " +
34
+ "operator Connections page. If the fix needs something you cannot do yourself — a provider console setting, " +
35
+ "a secret only the operator holds — name that exact step instead.",
36
+ ].join("\n\n");
37
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { CredentialVault } from "./credential-contract.js";
2
2
  import { Registry } from "./registry.js";
3
- import type { ActivityModule, OperatorSurface } from "./module-contracts.js";
4
- export type { ActivityModule, OperatorSurface } from "./module-contracts.js";
3
+ import type { ConnectorGrant } from "./connector-access.js";
4
+ import type { ActivityModule, ArtifactsModule, OperatorSurface } from "./module-contracts.js";
5
+ export type { ActivityModule, ArtifactsModule, OperatorSurface, } from "./module-contracts.js";
5
6
  export type { CredentialVault, CredentialMetadata } from "./credential-contract.js";
6
7
  import type { AuthenticatedIdentity, Connector, Executor, IdentityReference, InboundAuth, KVStorage, Logger } from "./types.js";
7
8
  /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
@@ -47,7 +48,7 @@ export interface ConnectaCallsConfig {
47
48
  /**
48
49
  * Max inline result size (bytes) before truncation and `get_result` paging.
49
50
  * Must be a finite whole number >= 1; invalid values warn and fall back to
50
- * 50_000. Connectors may override it individually.
51
+ * 24_000. Connectors may override it individually.
51
52
  */
52
53
  maxResultBytes?: number;
53
54
  }
@@ -81,6 +82,55 @@ export interface ConnectaExecuteConfig {
81
82
  * values fall back to the default.
82
83
  */
83
84
  hostCallTimeoutMs?: number;
85
+ /**
86
+ * Hard ceiling on one execution, in milliseconds, enforced outside the
87
+ * sandbox. Default 120_000, above both executors' own deadlines (QuickJS
88
+ * 30_000, the Dynamic Worker 60_000), so it only ends a run whose executor
89
+ * never settled; that run fails as unresponsive and its admission slot is
90
+ * released. Keep it above any raised executor deadline. Invalid values
91
+ * fall back to the default.
92
+ */
93
+ watchdogMs?: number;
94
+ /**
95
+ * Let programs write. A call to a tool that is not explicitly read-only
96
+ * pauses the run before anything is sent and returns the exact write with
97
+ * a token; `resume_execution` repeating it is the approval, and the
98
+ * program replays from a journal to send it and continue. Needs `storage`
99
+ * with `compareAndSet`, because two resumes of one pause must send its
100
+ * writes at most once. Omitted, it is on exactly when the storage has one
101
+ * (a single startup warning says when it is not); `true` over storage
102
+ * without one refuses to construct; `false` keeps E4's refusal.
103
+ * `resume_execution` is listed either way.
104
+ */
105
+ resumableWrites?: boolean;
106
+ /**
107
+ * Consequential calls one run may send, on top of the host-call budget
108
+ * every call already spends. Default 10. A replayed write spends it again,
109
+ * so it bounds the run, not each play. Invalid values fall back to the
110
+ * default.
111
+ */
112
+ maxWrites?: number;
113
+ /**
114
+ * How long a paused run can be resumed, in seconds from its first pause.
115
+ * Default 1_800. A later pause in the same run keeps the first deadline, so
116
+ * a run never replays reads older than this. Invalid values fall back to
117
+ * the default.
118
+ */
119
+ pausedRunTtlSeconds?: number;
120
+ /**
121
+ * Which writes a program may make without pausing for approval. Keys are
122
+ * connector ids (all of that connector's tools) or `connector.tool`
123
+ * addresses (that tool); values are `"never"` (never ask) or `"ask"`. The
124
+ * most specific key wins, and `"ask"` switches off a connector's own
125
+ * default. An exempt write still spends the write budget, is recorded in
126
+ * activity, and is journaled like any other; it is never read-only
127
+ * anywhere else — discovery still lists it as approval-required and
128
+ * `call_tool` still refuses it. Works with resumable writes off too. An
129
+ * unknown connector id, or an `api()` address its tools do not include,
130
+ * refuses to construct; a remote connector's tool names load later, so an
131
+ * address naming one it never serves simply never matches.
132
+ */
133
+ approval?: Readonly<Record<string, "never" | "ask">>;
84
134
  }
85
135
  export interface AdmissionPoolConfig {
86
136
  /** Simultaneous work admitted to this pool. */
@@ -92,6 +142,10 @@ export interface AdmissionPoolConfig {
92
142
  /** Retry hint returned with overload failures, in milliseconds. */
93
143
  retryAfterMs?: number;
94
144
  }
145
+ export interface RequestAdmissionConfig extends AdmissionPoolConfig {
146
+ /** Hard lifetime of an admitted /mcp request. Default 300,000 ms. */
147
+ maxDurationMs?: number;
148
+ }
95
149
  /**
96
150
  * Runtime-portable server-memory boundaries. `/health` and operator routes do
97
151
  * not consume these permits, so they remain responsive during MCP saturation.
@@ -101,7 +155,7 @@ export interface ConnectaAdmissionConfig {
101
155
  * The `/mcp` request boundary. Defaults to 16 active, 32 queued, and a
102
156
  * 5-second maximum wait.
103
157
  */
104
- requests?: AdmissionPoolConfig;
158
+ requests?: RequestAdmissionConfig;
105
159
  /**
106
160
  * Fallback pool for an `executor` that does not implement its own `acquire`.
107
161
  * Defaults to 2 active, 8 queued, and a 5-second maximum wait. Bounded
@@ -115,10 +169,13 @@ export interface ConnectaIdentityConfig {
115
169
  /**
116
170
  * What this admitted identity may discover and call: `"all"`, or a list
117
171
  * whose entries are connector ids (the whole connector) and `connector.tool`
118
- * addresses (that tool only). Grants are additive. An address naming a tool
119
- * the catalog lacks is unreachable and warned once, never widened.
172
+ * addresses (that tool only), or `{ tool: "connector.tool",
173
+ * requireReadOnly: true }` for an exact tool only while its loaded catalog
174
+ * explicitly classifies it read-only. Grants are additive: an unrestricted
175
+ * connector or address grant wins over the guarded form. An address naming
176
+ * a tool the catalog lacks is unreachable and warned once, never widened.
120
177
  */
121
- connectorAccess?(identity: Readonly<AuthenticatedIdentity>): "all" | readonly string[] | Promise<"all" | readonly string[]>;
178
+ connectorAccess?(identity: Readonly<AuthenticatedIdentity>): "all" | readonly ConnectorGrant[] | Promise<"all" | readonly ConnectorGrant[]>;
122
179
  /** Global payload-free activity reads. Defaults to interactive humans. */
123
180
  activityAccess?(principal: Readonly<IdentityReference>): boolean | Promise<boolean>;
124
181
  /** Shared credential and OAuth administration. Defaults to none. */
@@ -167,6 +224,14 @@ export interface ConnectaConfig {
167
224
  vault?: CredentialVault;
168
225
  /** Optional connection UI, created by operatorUi() from /ui. */
169
226
  ui?: OperatorSurface;
227
+ /**
228
+ * Optional team pages over stored data, created by artifacts() from
229
+ * /artifacts. Adds the built-in `artifacts` connector; needs `publicUrl`,
230
+ * because the links it hands out are shared.
231
+ */
232
+ artifacts?: ArtifactsModule;
233
+ /** Optional dedicated HTTPS origin that serves only artifact pages and their library. */
234
+ artifactOrigin?: string;
170
235
  /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
171
236
  discovery?: ConnectaDiscoveryConfig;
172
237
  /** Deployment-wide call deadlines and result paging threshold. */
package/dist/index.js CHANGED
@@ -1,39 +1,38 @@
1
1
  import { credentialTestRule, describeCredentialTestMismatch, } from "./credential-rules.js";
2
2
  import { Registry } from "./registry.js";
3
- import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
3
+ import { intersectAccess, parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
4
4
  import { createFetchHandler } from "./server.js";
5
+ import { createProgramRunner } from "./execute.js";
5
6
  import { droppedBrandingUrls, droppedThemeTokens, droppedUiAuthUrls, } from "./branding.js";
6
7
  import { memoryStorage } from "./storage/memory.js";
7
8
  import { CONNECTA_VERSION } from "./version.js";
8
9
  import { AdmissionController, executorName, isAdmittingExecutor, withExecutorAdmission, } from "./executor-admission.js";
10
+ import { DEFAULT_MAX_WRITES, DEFAULT_PAUSED_RUN_TTL_SECONDS, } from "./resumable.js";
11
+ import { disposeEdgeRuntime } from "./runtime/run.js";
12
+ import { NO_EXEMPTIONS } from "./tool-safety.js";
13
+ import { createCoreRuntime, resolveLogger } from "./runtime/services.js";
9
14
  const REQUEST_ADMISSION_DEFAULTS = {
10
15
  concurrency: 16,
11
16
  maxQueueSize: 32,
12
17
  queueTimeoutMs: 5_000,
13
18
  retryAfterMs: 1_000,
14
19
  };
20
+ const DEFAULT_REQUEST_MAX_DURATION_MS = 300_000;
15
21
  const CODE_ADMISSION_DEFAULTS = {
16
22
  concurrency: 2,
17
23
  maxQueueSize: 8,
18
24
  queueTimeoutMs: 5_000,
19
25
  retryAfterMs: 1_000,
20
26
  };
21
- function admissionController(options, defaults) {
27
+ function admissionController(options, defaults, maxDurationMs) {
22
28
  return new AdmissionController({
23
29
  concurrency: options?.concurrency ?? defaults.concurrency,
24
30
  maxQueueSize: options?.maxQueueSize ?? defaults.maxQueueSize,
25
31
  queueTimeoutMs: options?.queueTimeoutMs ?? defaults.queueTimeoutMs,
26
32
  retryAfterMs: options?.retryAfterMs ?? defaults.retryAfterMs,
33
+ ...(maxDurationMs !== undefined ? { maxDurationMs } : {}),
27
34
  });
28
35
  }
29
- function defaultLogger() {
30
- return {
31
- debug: (...a) => console.debug("[connecta]", ...a),
32
- info: (...a) => console.info("[connecta]", ...a),
33
- warn: (...a) => console.warn("[connecta]", ...a),
34
- error: (...a) => console.error("[connecta]", ...a),
35
- };
36
- }
37
36
  /** Bearer providers are checked before Clerk (per spec). */
38
37
  function normalizeAuth(auth) {
39
38
  const list = auth ? (Array.isArray(auth) ? auth : [auth]) : [];
@@ -48,6 +47,10 @@ const admissionPoolSchema = {
48
47
  queueTimeoutMs: null,
49
48
  retryAfterMs: null,
50
49
  };
50
+ const requestAdmissionSchema = {
51
+ ...admissionPoolSchema,
52
+ maxDurationMs: null,
53
+ };
51
54
  const CONFIG_SCHEMA = {
52
55
  connectors: null,
53
56
  auth: null,
@@ -64,6 +67,8 @@ const CONFIG_SCHEMA = {
64
67
  activity: null,
65
68
  vault: null,
66
69
  ui: null,
70
+ artifacts: null,
71
+ artifactOrigin: null,
67
72
  discovery: {
68
73
  concurrency: null,
69
74
  catalogTtlSeconds: null,
@@ -84,9 +89,14 @@ const CONFIG_SCHEMA = {
84
89
  maxEmittedBlocks: null,
85
90
  maxHostCalls: null,
86
91
  hostCallTimeoutMs: null,
92
+ watchdogMs: null,
93
+ resumableWrites: null,
94
+ maxWrites: null,
95
+ pausedRunTtlSeconds: null,
96
+ approval: null,
87
97
  },
88
98
  admission: {
89
- requests: admissionPoolSchema,
99
+ requests: requestAdmissionSchema,
90
100
  code: admissionPoolSchema,
91
101
  },
92
102
  logger: null,
@@ -151,7 +161,7 @@ function assertKnownConfig(config) {
151
161
  }
152
162
  }
153
163
  }
154
- if (config.vault && ["get", "getAll", "set", "setAll", "metadata", "delete"].some(key => typeof config.vault[key] !== "function"))
164
+ if (config.vault && (["get", "getAll", "set", "setAll", "metadata", "delete"].some(key => typeof config.vault[key] !== "function") || ["seal", "open"].some(key => !["undefined", "function"].includes(typeof config.vault[key]))))
155
165
  throw new Error("ConnectaConfig.vault must implement CredentialVault");
156
166
  if (config.ui && (typeof config.ui.handle !== "function" || typeof config.ui.credentialHandoffUrl !== "function" || !Array.isArray(config.ui.reservedPaths)))
157
167
  throw new Error("ConnectaConfig.ui must be created with operatorUi(...)");
@@ -162,6 +172,47 @@ function assertKnownConfig(config) {
162
172
  typeof activity.recordTool !== "function")) {
163
173
  throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
164
174
  }
175
+ const artifacts = config.artifacts;
176
+ if (artifacts !== undefined) {
177
+ const connector = artifacts?.connector;
178
+ if (typeof artifacts !== "object" ||
179
+ artifacts === null ||
180
+ !connector ||
181
+ connector.id !== "artifacts" ||
182
+ typeof connector.callTool !== "function" ||
183
+ typeof artifacts.handle !== "function") {
184
+ throw new Error("ConnectaConfig.artifacts must be created with artifacts(...) from @zackbart/connecta/artifacts");
185
+ }
186
+ if (config.connectors?.some((candidate) => candidate?.id === "artifacts")) {
187
+ throw new Error('Connector id "artifacts" is reserved by the artifacts module; rename ' +
188
+ "your connector or drop the artifacts option");
189
+ }
190
+ if (!config.publicUrl) {
191
+ throw new Error("ConnectaConfig.artifacts needs publicUrl: artifact links are shared " +
192
+ "with teammates, so they must name the deployment's own origin, never " +
193
+ "one taken from a request's Host header");
194
+ }
195
+ }
196
+ if (config.artifactOrigin !== undefined) {
197
+ if (!config.artifacts || !config.ui || !config.publicUrl) {
198
+ throw new Error("ConnectaConfig.artifactOrigin needs artifacts, ui, and publicUrl");
199
+ }
200
+ if (typeof config.artifactOrigin !== "string") {
201
+ throw new Error("ConnectaConfig.artifactOrigin must be an HTTPS origin");
202
+ }
203
+ let origin;
204
+ try {
205
+ origin = new URL(config.artifactOrigin);
206
+ }
207
+ catch {
208
+ throw new Error("ConnectaConfig.artifactOrigin must be an HTTPS origin");
209
+ }
210
+ if (origin.protocol !== "https:" ||
211
+ origin.toString() !== `${origin.origin}/` ||
212
+ origin.origin === new URL(config.publicUrl).origin) {
213
+ throw new Error("ConnectaConfig.artifactOrigin must be a distinct HTTPS origin");
214
+ }
215
+ }
165
216
  }
166
217
  /**
167
218
  * Validate declared pools against the connector set. Everything checkable at
@@ -224,6 +275,92 @@ function resolvePools(pools, registry) {
224
275
  }
225
276
  return resolved;
226
277
  }
278
+ /**
279
+ * Validate `execute.approval` against the connector set, like the pools: a
280
+ * malformed key or value, an unknown connector, and an `api()` address its
281
+ * tools do not include all throw. Remote catalogs load lazily, so an address
282
+ * on one is kept and simply never matches a tool it does not serve.
283
+ */
284
+ function resolveApprovalPolicy(approval, registry) {
285
+ for (const connector of registry.listConnectors()) {
286
+ if (connector.approval !== undefined && connector.approval !== "never") {
287
+ throw new Error(`Connector "${connector.id}": approval must be "never" or omitted`);
288
+ }
289
+ }
290
+ if (approval === undefined)
291
+ return NO_EXEMPTIONS;
292
+ if (!approval || typeof approval !== "object" || Array.isArray(approval)) {
293
+ throw new Error("ConnectaConfig.execute.approval must be an object keyed by connector ids and connector.tool addresses");
294
+ }
295
+ const connectors = new Map();
296
+ const tools = new Map();
297
+ for (const [key, value] of Object.entries(approval)) {
298
+ const where = `ConnectaConfig.execute.approval[${JSON.stringify(key)}]`;
299
+ if (value !== "never" && value !== "ask") {
300
+ throw new Error(`${where} must be "never" or "ask"`);
301
+ }
302
+ const dot = key.indexOf(".");
303
+ const id = dot === -1 ? key : key.slice(0, dot);
304
+ const tool = dot === -1 ? undefined : key.slice(dot + 1);
305
+ if (!id || tool === "") {
306
+ throw new Error(`${where}: keys are connector ids or connector.tool addresses`);
307
+ }
308
+ const connector = registry.getConnector(id);
309
+ if (!connector)
310
+ throw new Error(`${where}: unknown connector "${id}"`);
311
+ if (tool === undefined) {
312
+ connectors.set(id, value);
313
+ continue;
314
+ }
315
+ if (connector.staticTools &&
316
+ !connector.staticTools.some((candidate) => candidate.name === tool)) {
317
+ throw new Error(`${where}: connector "${id}" has no tool "${tool}"`);
318
+ }
319
+ tools.set(key, value);
320
+ }
321
+ return { connectors, tools };
322
+ }
323
+ /** A positive whole number, or the default when the value is unusable. */
324
+ function positiveWhole(value, fallback) {
325
+ return typeof value === "number" && Number.isFinite(value) && value >= 1
326
+ ? Math.trunc(value)
327
+ : fallback;
328
+ }
329
+ /**
330
+ * Resumable writes' deployment settings, or undefined when they are off.
331
+ * Omitted, they are on exactly when the storage can claim atomically, and a
332
+ * deployment left without them hears so once. Asked for over storage that
333
+ * cannot claim, they throw: at-most-once delivery of an approved write rests
334
+ * on exactly one resume winning the claim, and a read-then-write stand-in
335
+ * would let two win.
336
+ */
337
+ function resolveResumableWrites(execute, storage, logger) {
338
+ const enabled = execute?.resumableWrites;
339
+ if (enabled !== undefined && typeof enabled !== "boolean") {
340
+ throw new Error("ConnectaConfig.execute.resumableWrites must be a boolean");
341
+ }
342
+ if (enabled === false)
343
+ return undefined;
344
+ if (enabled === undefined && typeof storage.compareAndSet !== "function") {
345
+ logger.warn("[connecta] resumable writes are off: the configured storage has no " +
346
+ "compareAndSet, so programs refuse writes (E4) and resume_execution " +
347
+ "answers resumable_writes_unavailable. Use storage with an atomic " +
348
+ "compareAndSet (the Worker example's D1 store, fileStorage, " +
349
+ "memoryStorage) to let programs pause for approval, or set " +
350
+ "execute.resumableWrites: false to keep this and silence the warning.");
351
+ return undefined;
352
+ }
353
+ if (typeof storage.compareAndSet !== "function") {
354
+ throw new Error("ConnectaConfig.execute.resumableWrites needs storage with compareAndSet: " +
355
+ "two resumes of one paused run must send its writes at most once, which " +
356
+ "takes an atomic claim. memoryStorage() and fileStorage() provide it, as " +
357
+ "does the Worker example's D1 store; Cloudflare KV cannot.");
358
+ }
359
+ return {
360
+ maxWrites: positiveWhole(execute?.maxWrites, DEFAULT_MAX_WRITES),
361
+ ttlSeconds: positiveWhole(execute?.pausedRunTtlSeconds, DEFAULT_PAUSED_RUN_TTL_SECONDS),
362
+ };
363
+ }
227
364
  /**
228
365
  * One-time construction warnings for deployment shapes that run fine but are
229
366
  * usually unintended. Warning-only — never throws and never changes behavior;
@@ -236,7 +373,7 @@ function warnInsecureConfig(config, inboundAuth, logger) {
236
373
  // Static API headers can carry secrets without declaring credential hooks.
237
374
  // Any configured connector warrants the open-deployment warning.
238
375
  if (inboundAuth.length === 0 &&
239
- config.connectors.length > 0) {
376
+ (config.connectors.length > 0 || config.artifacts)) {
240
377
  logger.warn("[connecta] running with no inbound authentication: any caller can " +
241
378
  "invoke every shared connector. " +
242
379
  (hasCredentialConnector || oauthConnectors.length > 0
@@ -244,6 +381,18 @@ function warnInsecureConfig(config, inboundAuth, logger) {
244
381
  : "") +
245
382
  "Configure `auth` (for example bearerToken(...) or Clerk) to gate access.");
246
383
  }
384
+ // Artifact pages mount inside the operator UI and open only for an
385
+ // authenticated viewer; either missing leaves the connector with no page.
386
+ if (config.artifacts && !config.ui) {
387
+ logger.warn("[connecta] artifacts is configured without ui: agents can publish and " +
388
+ "read artifacts, but there is no viewer, so their links answer 404. " +
389
+ "Add ui: operatorUi() to serve artifact pages.");
390
+ }
391
+ else if (config.artifacts && inboundAuth.length === 0) {
392
+ logger.warn("[connecta] artifacts is configured with no inbound authentication: " +
393
+ "artifact pages refuse every request, because there is no team to " +
394
+ "show them to. Configure `auth` to open them.");
395
+ }
247
396
  // Unset publicUrl with OAuth connectors: the downstream redirect_uri is
248
397
  // derived per-request from the attacker-influenced inbound Host header.
249
398
  if (oauthConnectors.length > 0 && !config.publicUrl) {
@@ -296,18 +445,31 @@ function warnInsecureConfig(config, inboundAuth, logger) {
296
445
  }
297
446
  // A credential test hook that cannot test the declared credential shape.
298
447
  // The shape picks the hook (see `credentialTestRule`) and the other one is
299
- // never substituted, so the connector is simply not testable: /credentials offers no
300
- // Test action and the route answers 400. Without this line the only way to
301
- // discover the mistake is to click a button that isn't there.
448
+ // never substituted, so the connection offers no Test action in the operator
449
+ // UI and the route answers 400. Without this line the only way to discover
450
+ // the mistake is to click a button that isn't there.
302
451
  for (const connector of config.connectors) {
303
452
  const { mismatch } = credentialTestRule(connector);
304
453
  if (!mismatch)
305
454
  continue;
306
455
  logger.warn(`[connecta] connector "${connector.id}" cannot test its credential: ` +
307
- `${describeCredentialTestMismatch(mismatch)}. /credentials offers no Test ` +
456
+ `${describeCredentialTestMismatch(mismatch)}. The connection in the operator UI offers no Test ` +
308
457
  `action and POST /ui/credentials/${connector.id}/test answers 400 ` +
309
458
  "until the matching hook is implemented.");
310
459
  }
460
+ // A vault written before seal/open existed encrypts credentials but cannot
461
+ // seal downstream OAuth state, so tokens stay plaintext at rest beside them.
462
+ const vault = config.vault;
463
+ if (vault && (typeof vault.seal !== "function" || typeof vault.open !== "function")) {
464
+ for (const connector of config.connectors) {
465
+ if (!connector.startAuth)
466
+ continue;
467
+ logger.warn(`[connecta] connector "${connector.id}" stores its downstream OAuth ` +
468
+ "tokens, client registration, and PKCE verifier as plaintext: the " +
469
+ "configured vault implements no `seal`/`open`. Use " +
470
+ "encryptedCredentialVault(...) or add both methods to seal them.");
471
+ }
472
+ }
311
473
  // OAuth connectors whose callback cannot perform a state/CSRF check. The
312
474
  // public route refuses every callback for these connectors rather than hand
313
475
  // an unverified code to finishAuth, so this warning explains why auth cannot
@@ -331,9 +493,7 @@ export function createConnecta(config) {
331
493
  '"@cloudflare/codemode" on Workers.');
332
494
  }
333
495
  const storage = config.storage ?? memoryStorage();
334
- const logger = config.logger === "silent"
335
- ? { debug() { }, info() { }, warn() { }, error() { } }
336
- : config.logger ?? defaultLogger();
496
+ const logger = resolveLogger(config.logger);
337
497
  const credentialVault = config.vault;
338
498
  const configuredAuth = normalizeAuth(config.auth);
339
499
  const serverInfo = {
@@ -341,7 +501,12 @@ export function createConnecta(config) {
341
501
  name: config.serverInfo?.name ?? "connecta",
342
502
  version: config.serverInfo?.version ?? CONNECTA_VERSION,
343
503
  };
344
- const registry = new Registry(config.connectors, {
504
+ // The artifacts module contributes one prebuilt connector, appended like any
505
+ // configured one: same catalog, invocation, admission, and activity.
506
+ const connectors = config.artifacts
507
+ ? [...config.connectors, config.artifacts.connector]
508
+ : config.connectors;
509
+ const registry = new Registry(connectors, {
345
510
  storage,
346
511
  logger,
347
512
  credentialVault,
@@ -364,8 +529,12 @@ export function createConnecta(config) {
364
529
  });
365
530
  const inboundAuth = configuredAuth;
366
531
  const pools = resolvePools(config.pools, registry);
532
+ const resumable = resolveResumableWrites(config.execute, storage, logger);
533
+ const approval = resolveApprovalPolicy(config.execute?.approval, registry);
534
+ // Exempt writes need a budget whether or not programs can pause.
535
+ const maxWrites = positiveWhole(config.execute?.maxWrites, DEFAULT_MAX_WRITES);
367
536
  warnInsecureConfig(config, inboundAuth, logger);
368
- const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS);
537
+ const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS, config.admission?.requests?.maxDurationMs ?? DEFAULT_REQUEST_MAX_DURATION_MS);
369
538
  const configuredCodeAdmission = admissionController(config.admission?.code, CODE_ADMISSION_DEFAULTS);
370
539
  let codeAdmission;
371
540
  let executor = config.executor;
@@ -381,17 +550,91 @@ export function createConnecta(config) {
381
550
  "implements acquire() and owns its admission pool; configure that " +
382
551
  "executor's concurrency and queue options instead.");
383
552
  }
553
+ if (config.artifacts?.bindRefresh) {
554
+ const sharedIds = registry.listConnectors()
555
+ .filter((connector) => connector.id !== "artifacts" && connector.authScope !== "personal")
556
+ .map((connector) => connector.id);
557
+ const refreshConfig = {
558
+ discoveryConcurrency: config.discovery?.concurrency,
559
+ probeTimeoutMs: config.discovery?.probeTimeoutMs,
560
+ maxEmittedBytes: config.execute?.maxEmittedBytes,
561
+ maxEmittedBlocks: config.execute?.maxEmittedBlocks,
562
+ maxHostCalls: config.execute?.maxHostCalls,
563
+ hostCallTimeoutMs: config.execute?.hostCallTimeoutMs,
564
+ watchdogMs: config.execute?.watchdogMs,
565
+ failOnInvocationFailure: true,
566
+ // A connector's own `approval: "never"` still applies when policy maps
567
+ // are empty. Override every shared connector explicitly for refresh.
568
+ approval: {
569
+ connectors: new Map(sharedIds.map((id) => [id, "ask"])),
570
+ tools: new Map(),
571
+ },
572
+ };
573
+ const refreshRunner = createProgramRunner(registry.scoped({ connectorIds: [] }), config.publicUrl, executor, logger, undefined, refreshConfig);
574
+ config.artifacts.bindRefresh({
575
+ claimMs: refreshRunner.claimMs,
576
+ execute: async (program, owner, signal) => {
577
+ if (!owner)
578
+ throw new Error("Refresh owner is missing; reconfigure this program.");
579
+ let access = parseConnectorAccess(config.identity?.connectorAccess
580
+ ? await config.identity.connectorAccess(owner.identity) : "all", { allowReadOnly: true });
581
+ if (owner.pool) {
582
+ const pool = pools.get(owner.pool);
583
+ if (!pool || await pool.grant(owner.identity) !== true) {
584
+ throw new Error("Refresh owner's pool grant is no longer available.");
585
+ }
586
+ access = intersectAccess(access, pool.access);
587
+ }
588
+ if (access.connectorIds !== "all" && !access.connectorIds.includes("artifacts")) {
589
+ throw new Error("Refresh owner no longer has artifact publishing access.");
590
+ }
591
+ if (access.toolAccess?.get("artifacts") &&
592
+ !access.toolAccess.get("artifacts").has("set_refresh")) {
593
+ throw new Error("Refresh owner no longer has refresh configuration access.");
594
+ }
595
+ if (access.guardedToolAccess?.get("artifacts")?.has("set_refresh")) {
596
+ throw new Error("A read-only guarded grant cannot configure refresh.");
597
+ }
598
+ access = intersectAccess(access, { connectorIds: sharedIds });
599
+ if (signal.aborted)
600
+ throw new Error("Refresh deadline expired before execution.");
601
+ const view = registry.scoped({ connectorIds: access.connectorIds,
602
+ ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
603
+ ...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}) });
604
+ const runner = createProgramRunner(view, config.publicUrl, executor, logger, undefined, refreshConfig);
605
+ return runner.execute({ code: program }, { signal });
606
+ },
607
+ });
608
+ }
609
+ // Every structural check has passed, so this is the configuration the
610
+ // deployment runs with. Creating the runtime builds nothing — a Worker may
611
+ // construct its Connecta at global scope, where no fiber may start.
612
+ const runtime = createCoreRuntime(registry, {
613
+ storage,
614
+ logger,
615
+ vault: credentialVault,
616
+ activity: config.activity,
617
+ config: {
618
+ config,
619
+ serverInfo,
620
+ auth: inboundAuth,
621
+ pools,
622
+ executorName: configuredExecutorName,
623
+ },
624
+ });
384
625
  const handler = createFetchHandler({
385
626
  registry,
386
627
  auth: inboundAuth,
387
628
  identity: config.identity,
388
629
  pools,
389
630
  publicUrl: config.publicUrl,
631
+ artifactOrigin: config.artifactOrigin,
390
632
  allowedOrigins: config.allowedOrigins,
391
633
  serverInfo,
392
634
  logger,
393
635
  activity: config.activity?.store,
394
636
  activityModule: config.activity,
637
+ artifactsModule: config.artifacts,
395
638
  activityReadGate: config.activity?.readGate,
396
639
  activityDeploymentId: config.activity?.deploymentId,
397
640
  executor,
@@ -404,6 +647,10 @@ export function createConnecta(config) {
404
647
  maxEmittedBlocks: config.execute?.maxEmittedBlocks,
405
648
  maxHostCalls: config.execute?.maxHostCalls,
406
649
  hostCallTimeoutMs: config.execute?.hostCallTimeoutMs,
650
+ watchdogMs: config.execute?.watchdogMs,
651
+ resumable,
652
+ approval,
653
+ maxWrites,
407
654
  credentialVault,
408
655
  ui: config.ui,
409
656
  deploymentInfo: config.deploymentInfo,
@@ -417,10 +664,18 @@ export function createConnecta(config) {
417
664
  registry,
418
665
  close: async () => {
419
666
  closePromise ??= Promise.resolve().then(async () => {
420
- requestAdmission.close();
421
- codeAdmission?.close();
422
- registry.closeCallAdmission();
423
- await config.executor?.close?.();
667
+ try {
668
+ requestAdmission.close();
669
+ codeAdmission?.close();
670
+ registry.closeCallAdmission();
671
+ await config.executor?.close?.();
672
+ }
673
+ finally {
674
+ // Last, and whether or not the executor closed cleanly. A fiber
675
+ // already running keeps the services it was given; only a run
676
+ // started after this is refused.
677
+ await disposeEdgeRuntime(runtime);
678
+ }
424
679
  });
425
680
  await closePromise;
426
681
  },