@zackbart/connecta 0.23.0 → 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 (78) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +18 -10
  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/connectors/remote-mcp.js +3 -5
  13. package/dist/credential-contract.d.ts +24 -0
  14. package/dist/credential-contract.js +1 -0
  15. package/dist/credential-rules.d.ts +85 -0
  16. package/dist/credential-rules.js +107 -0
  17. package/dist/credentials.d.ts +4 -100
  18. package/dist/credentials.js +3 -107
  19. package/dist/index.d.ts +22 -55
  20. package/dist/index.js +30 -58
  21. package/dist/invocation.js +2 -3
  22. package/dist/meta-tools.d.ts +4 -0
  23. package/dist/meta-tools.js +8 -4
  24. package/dist/module-contracts.d.ts +19 -0
  25. package/dist/module-contracts.js +1 -0
  26. package/dist/operator-ui/generated.js +2 -2
  27. package/dist/operator-ui/model.d.ts +6 -3
  28. package/dist/operator-ui/view.d.ts +2 -18
  29. package/dist/operator-ui/view.js +3 -20
  30. package/dist/registry.d.ts +4 -1
  31. package/dist/registry.js +4 -6
  32. package/dist/routes/activity.js +1 -1
  33. package/dist/routes/credentials.js +5 -2
  34. package/dist/routes/mcp.js +7 -3
  35. package/dist/routes/oauth-management.d.ts +2 -0
  36. package/dist/routes/oauth-management.js +108 -0
  37. package/dist/routes/oauth.d.ts +0 -1
  38. package/dist/routes/oauth.js +21 -121
  39. package/dist/routes/shared.d.ts +19 -17
  40. package/dist/routes/shared.js +48 -44
  41. package/dist/routes/ui.js +36 -33
  42. package/dist/server.js +6 -26
  43. package/dist/types.d.ts +2 -0
  44. package/dist/ui.d.ts +15 -70
  45. package/dist/ui.js +176 -317
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/documentation/architecture.md +26 -17
  49. package/documentation/auth.md +61 -106
  50. package/documentation/cloudflare.md +1 -1
  51. package/documentation/connectors.md +1 -1
  52. package/documentation/linear.md +1 -1
  53. package/documentation/meta-tools.md +6 -4
  54. package/documentation/mixpanel.md +1 -1
  55. package/documentation/notion.md +2 -2
  56. package/documentation/operations.md +12 -14
  57. package/documentation/operator-ui.md +82 -104
  58. package/documentation/optional-modules-upgrade.md +243 -0
  59. package/documentation/provider-conventions.md +5 -3
  60. package/documentation/revenuecat.md +1 -1
  61. package/documentation/storage-and-credentials.md +59 -40
  62. package/documentation/stripe.md +1 -1
  63. package/documentation/upgrading.md +29 -4
  64. package/ethos.md +22 -30
  65. package/examples/worker/AGENTS.md +3 -1
  66. package/examples/worker/README.md +68 -84
  67. package/examples/worker/src/d1-activity.ts +1 -1
  68. package/examples/worker/src/index.ts +11 -6
  69. package/package.json +18 -2
  70. package/templates/node/AGENTS.md +8 -6
  71. package/templates/node/README.md +56 -67
  72. package/templates/node/package.json +1 -1
  73. package/templates/node/src/file-activity.ts +1 -1
  74. package/templates/node/src/index.ts +11 -12
  75. package/dist/access-tokens.d.ts +0 -31
  76. package/dist/access-tokens.js +0 -236
  77. package/dist/routes/access-tokens.d.ts +0 -6
  78. package/dist/routes/access-tokens.js +0 -83
package/dist/ui.js CHANGED
@@ -1,64 +1,15 @@
1
- import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "./credentials.js";
1
+ import { routeUi } from "./routes/ui.js";
2
+ import { routeCredentials } from "./routes/credentials.js";
3
+ import { routeOAuthManagement } from "./routes/oauth-management.js";
4
+ import { withDeadline } from "./timeout.js";
5
+ import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "./credential-rules.js";
2
6
  import { closeConnectorScope, } from "./connector-scope.js";
3
7
  import { mapSettledWithConcurrency, resolveDiscoveryConcurrency, } from "./concurrency.js";
4
8
  import { OPERATOR_UI_CSS, OPERATOR_UI_SCRIPT, } from "./operator-ui/generated.js";
5
9
  import { CONNECTA_VERSION } from "./version.js";
6
10
  export { filterUiConnectors, } from "./operator-ui/model.js";
7
- /** Connecta's default monochrome "C" mark. */
8
- export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
9
- <style>
10
- .fg { fill: #000 }
11
- @media (prefers-color-scheme: dark) { .fg { fill: #fff } }
12
- </style>
13
- <path class="fg" d="M27 9.4A13 13 0 1 0 27 22.6l-4.4-2.5a8 8 0 1 1 0-8.2z"/>
14
- </svg>`;
15
- const DEFAULT_FAVICON_HREF = "/favicon.svg";
16
- /**
17
- * Branding arrives from operator config, which is untyped at a JS call site, so
18
- * every field is treated as `unknown`: a non-string is read as unset rather than
19
- * throwing on `.trim()`. Rendering must degrade to defaults for a malformed
20
- * value, never fail — `createConnecta` calls this during construction.
21
- */
22
- function trimmedString(value) {
23
- return typeof value === "string" ? value.trim() || undefined : undefined;
24
- }
25
- export function resolveBranding(branding) {
26
- const productName = trimmedString(branding?.productName) ?? "Connecta";
27
- const ownerName = trimmedString(branding?.ownerName);
28
- // Operator branding URLs become masthead/callback hrefs, so a non-http(s)
29
- // scheme (javascript:, data:) is dropped the same as an unset URL — the
30
- // callers already render a <span> instead of an <a> when it is absent.
31
- const productUrl = trimmedString(branding?.productUrl);
32
- const ownerUrl = trimmedString(branding?.ownerUrl);
33
- const faviconHref = trimmedString(branding?.favicon?.href);
34
- return {
35
- productName,
36
- ...(productUrl && isSafeHttpUrl(productUrl) ? { productUrl } : {}),
37
- ...(ownerName ? { ownerName } : {}),
38
- ...(ownerUrl && isSafeHttpUrl(ownerUrl) ? { ownerUrl } : {}),
39
- description: trimmedString(branding?.description) ??
40
- `Manage the services this ${productName} instance makes available to agents.`,
41
- pageTitle: trimmedString(branding?.pageTitle) ??
42
- (ownerName ? `${productName} — ${ownerName}` : productName),
43
- faviconHref: faviconHref && isSafeIconHref(faviconHref)
44
- ? faviconHref
45
- : DEFAULT_FAVICON_HREF,
46
- themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
47
- };
48
- }
49
- /**
50
- * Whether the operator meant to supply a value here — the question every
51
- * dropped-URL warning asks before naming a field, and one definition so the
52
- * branding and `uiAuth` warnings cannot answer it differently. A non-string
53
- * counts as set: the intent was there and is exactly what the warning reports
54
- * on. A blank or whitespace-only string does not; that is indistinguishable
55
- * from leaving the field alone, and both take the default silently.
56
- */
57
- function isSetUrlValue(value) {
58
- return typeof value === "string"
59
- ? trimmedString(value) !== undefined
60
- : value !== undefined && value !== null;
61
- }
11
+ import { resolveBranding, isSafeHttpsUrl } from "./branding.js";
12
+ export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
62
13
  /**
63
14
  * A JS string literal safe to inline in a script element. Escaping `/` keeps
64
15
  * an operator-supplied `</script>` from terminating the element early.
@@ -66,140 +17,13 @@ function isSetUrlValue(value) {
66
17
  function stringForInlineScript(value) {
67
18
  return JSON.stringify(value).replace(/\//g, "\\/");
68
19
  }
69
- /**
70
- * Names of the branding URLs the operator set that failed their gate and were
71
- * replaced by a default. Lives beside the gates so the startup warning cannot
72
- * drift from them, and takes `unknown` fields for the same reason
73
- * `resolveBranding` does — a warning helper must never throw.
74
- */
75
- export function droppedBrandingUrls(branding) {
76
- if (!branding)
77
- return [];
78
- const resolved = resolveBranding(branding);
79
- const faviconHref = branding.favicon?.href;
80
- return [
81
- ...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
82
- ? ["productUrl"]
83
- : []),
84
- ...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
85
- ? ["ownerUrl"]
86
- : []),
87
- ...(isSetUrlValue(faviconHref) &&
88
- trimmedString(faviconHref) !== resolved.faviconHref
89
- ? ["favicon.href"]
90
- : []),
91
- ];
92
- }
93
- /**
94
- * True only for absolute `http:`/`https:` URLs. Downstream connectors control
95
- * their `authorizationUrl`, so a hostile/misconfigured one could hand back a
96
- * `javascript:` (or other) scheme; gate it before it can become an href.
97
- */
98
- function safeUrl(url, schemes) {
99
- if (typeof url !== "string")
100
- return false;
101
- try {
102
- return schemes.includes(new URL(url).protocol);
103
- }
104
- catch {
105
- return false;
106
- }
107
- }
108
- export function isSafeHttpUrl(url) {
109
- return safeUrl(url, ["http:", "https:"]);
110
- }
111
- /**
112
- * Only the second check's base; any origin works because the check is whether
113
- * the href stays on whatever origin it is resolved against. It is deliberately
114
- * never the sole gate: a value whose own authority equals this host (say
115
- * `//connecta.invalid/x`) would resolve to this exact origin and pass, so the
116
- * structural check below runs first and is what actually rejects `//host`.
117
- */
118
- const SAME_ORIGIN_PROBE = "https://connecta.invalid";
119
- /** Removed anywhere in a URL by the parser, so a gate must ignore them too. */
120
- const URL_STRIPPED_CHARS = /[\t\n\r]/g;
121
- /**
122
- * True for values allowed in the page's `<link rel="icon" href>`: an absolute
123
- * `http(s)` URL (an icon the operator hosts elsewhere) or a path rooted at this
124
- * origin. The relative carve-out is deliberate rather than accidental — the
125
- * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
126
- * reject — and it is kept narrow on both ends.
127
- *
128
- * Root-relative only, because operator and OAuth callback pages sit at
129
- * different depths and a document-relative path would resolve differently.
130
- *
131
- * "Root-relative" is enforced structurally: exactly one leading `/` followed by
132
- * a character that is neither `/` nor `\`. Both of those would make the value an
133
- * authority (`//host`, and `/\host` because the URL parser folds `\` to `/` in
134
- * special schemes), pointing at an origin this server does not control. The test
135
- * runs on a copy with tab/newline/CR removed, since the parser strips those
136
- * anywhere and `/\t/host` would otherwise slip through as single-slash. The
137
- * origin comparison that follows is defense in depth, not the authority check —
138
- * on its own it would accept an authority that happened to equal the probe host.
139
- */
140
- export function isSafeIconHref(href) {
141
- if (typeof href !== "string")
142
- return false;
143
- if (isSafeHttpUrl(href))
144
- return true;
145
- if (!/^\/(?![/\\])/.test(href.replace(URL_STRIPPED_CHARS, "")))
146
- return false;
147
- try {
148
- return new URL(href, SAME_ORIGIN_PROBE).origin === SAME_ORIGIN_PROBE;
149
- }
150
- catch {
151
- return false;
152
- }
153
- }
154
- /** Absolute HTTPS gate for the `UiAuthConfig` URL fields documented in types.ts. */
155
- export function isSafeHttpsUrl(url) {
156
- return safeUrl(url, ["https:"]);
157
- }
158
- /**
159
- * Names of the `uiAuth` URLs an inbound-auth provider supplied that failed their
160
- * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
161
- * startup warning cannot then drift from what rendering actually drops. Every
162
- * field is read defensively rather than trusted, because a custom `InboundAuth`
163
- * is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
164
- * `uiAuth` that is not the clerk shape is reported as nothing to warn about.
165
- *
166
- * `frontendApiUrl` is required, so anything that fails its gate is a drop.
167
- * `signInUrl` and `signUpUrl` are optional, so only a value the operator
168
- * *supplied* and the gate then rejected is worth a warning — an unset field
169
- * took no default away from anyone. `isSetUrlValue` decides that, the same way
170
- * and for the same reasons it decides it for the branding URLs: a warning that
171
- * fires for one and not the other would be reporting on the field rather than
172
- * on the operator's intent. Rendering is not consulted for this: it drops on
173
- * the gate alone, and a blank string fails that gate too — it is simply not
174
- * *reported*, because a blank is indistinguishable from leaving the field
175
- * alone.
176
- */
177
- export function droppedUiAuthUrls(uiAuth) {
178
- if (!uiAuth || uiAuth.kind !== "clerk")
179
- return [];
180
- return [
181
- ...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
182
- ...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
183
- ? ["uiAuth.signInUrl"]
184
- : []),
185
- ...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
186
- ? ["uiAuth.signUpUrl"]
187
- : []),
188
- ];
189
- }
190
20
  const OPERATOR_PAGE_LABELS = {
191
21
  connections: "Connections",
192
- credentials: "Credentials",
193
- tokens: "Access tokens",
194
22
  activity: "Activity",
195
23
  };
196
24
  export function operatorPageForPath(path) {
197
25
  if (path === "/")
198
26
  return "connections";
199
- if (path === "/credentials")
200
- return "credentials";
201
- if (path === "/tokens")
202
- return "tokens";
203
27
  if (path === "/activity")
204
28
  return "activity";
205
29
  return undefined;
@@ -223,151 +47,157 @@ export function credentialManagementCapability(input) {
223
47
  */
224
48
  export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false, credentialManagement = credentialVault
225
49
  ? "available"
226
- : "requires_operator", defer, oauthManagement = false, discoveryConcurrency, accessTokenManagement = "not_configured", personalCredentialOwner) {
50
+ : "requires_operator", defer, oauthManagement = false, discoveryConcurrency, personalCredentialOwner, detailOptions = {}) {
227
51
  const requestScope = {};
228
52
  const connectorSet = registry.listConnectors();
229
53
  const concurrency = resolveDiscoveryConcurrency(discoveryConcurrency);
230
54
  const settled = await mapSettledWithConcurrency(connectorSet, concurrency, async (c) => {
231
- const drift = await registry.credentialDriftFor(c.id);
232
- const status = drift
233
- ? { state: "auth_required", message: drift }
234
- : await registry.statusFor(c.id, baseUrl, requestScope);
235
- if (status.authorizationUrl) {
236
- await registry.bindOAuthHandoff(c.id, status.authorizationUrl);
237
- }
238
- let tools = [];
239
- // `status()` on an unauthenticated remote connector starts OAuth and
240
- // stores its state + PKCE verifier. Probing listTools immediately
241
- // afterward would start a second flow, overwrite that state, and return
242
- // the now-stale first URL to the operator. Only inspect tools after
243
- // status proves the connector is healthy.
244
- if (status.state === "ok") {
245
- try {
246
- tools = (await registry.getTools(c.id, baseUrl, requestScope)).map((t) => ({
247
- name: t.name,
248
- address: `${c.id}.${t.name}`,
249
- ...(t.description !== undefined
250
- ? { description: t.description }
251
- : {}),
252
- }));
253
- }
254
- catch {
255
- // broken connector: reported via status "error", tools stay empty
256
- }
257
- }
258
- let credential;
259
- const mayManageAuth = c.authScope === "personal"
260
- ? Boolean(personalCredentialOwner)
261
- : oauthManagement;
262
- if (c.credential && credentialVault && mayManageAuth) {
263
- // One rule, shared with the test route: only the hook matching the
264
- // declared credential shape can run, so the button is offered only
265
- // where a click can succeed (src/credentials.ts).
266
- const testRule = credentialTestRule(c);
267
- const credentialFields = (metadata) => c.credential?.fields?.map((field) => {
268
- const fieldMetadata = metadata?.fields?.[field.name];
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();
269
157
  return {
270
- name: field.name,
271
- label: field.label,
272
- ...(field.description
273
- ? { description: field.description }
158
+ id: c.id,
159
+ authScope: c.authScope ?? "shared",
160
+ ...(c.title ? { title: c.title } : {}),
161
+ ...(c.description !== undefined
162
+ ? { description: c.description }
274
163
  : {}),
275
- ...(field.placeholder
276
- ? { placeholder: field.placeholder }
277
- : {}),
278
- inputType: field.inputType ?? "password",
279
- configured: Boolean(fieldMetadata),
280
- ...(fieldMetadata
281
- ? {
282
- lastFour: fieldMetadata.lastFour,
283
- updatedAt: fieldMetadata.updatedAt,
284
- }
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 }
285
175
  : {}),
176
+ oauth: Boolean(c.startAuth && c.disconnectAuth),
177
+ ...(credential ? { credential } : {}),
286
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."),
287
183
  });
288
- const credentialCard = {
289
- label: c.credential.label,
290
- ...(c.credential.description
291
- ? { description: c.credential.description }
292
- : {}),
293
- ...(c.credential.placeholder
294
- ? { placeholder: c.credential.placeholder }
295
- : {}),
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: [],
296
195
  };
297
- try {
298
- const metadata = await credentialVault.metadata(c.id, c.authScope === "personal" ? personalCredentialOwner : undefined);
299
- const fields = credentialFields(metadata);
300
- const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
301
- credential = {
302
- ...credentialCard,
303
- ...(fields?.length ? { fields } : {}),
304
- configured: shape.state === "valid",
305
- removable: Boolean(metadata),
306
- ...(metadata
307
- ? {
308
- lastFour: metadata.lastFour,
309
- updatedAt: metadata.updatedAt,
310
- }
311
- : {}),
312
- testable: testRule.mode !== null && shape.state !== "mismatch",
313
- ...(shape.state === "mismatch"
314
- ? { error: shape.message }
315
- : {}),
316
- // A dropped field leaves its secret in the vault, and the field
317
- // list below only renders fields the connector still declares —
318
- // so without this line there is nowhere an operator could see it.
319
- ...(shape.state === "valid" && shape.undeclared.length
320
- ? {
321
- notice: describeUndeclaredCredentialFields(shape.undeclared),
322
- }
323
- : {}),
324
- };
325
- }
326
- catch {
327
- const fields = credentialFields();
328
- credential = {
329
- ...credentialCard,
330
- ...(fields?.length ? { fields } : {}),
331
- configured: false,
332
- removable: true,
333
- testable: testRule.mode !== null,
334
- error: "Stored credential could not be read.",
335
- };
336
- }
337
196
  }
338
- return {
339
- id: c.id,
340
- authScope: c.authScope ?? "shared",
341
- ...(c.title ? { title: c.title } : {}),
342
- ...(c.description !== undefined
343
- ? { description: c.description }
344
- : {}),
345
- status: status.state,
346
- ...(status.message ? { message: status.message } : {}),
347
- ...(isSafeHttpUrl(status.authorizationUrl)
348
- ? { authorizationUrl: status.authorizationUrl }
349
- : {}),
350
- toolCount: tools.length,
351
- tools,
352
- // Counts only, and only when a refresh in this runtime produced them.
353
- // `Registry.statusFor` already rebuilt the report through
354
- // `boundedCatalogDrift`, so what lands here cannot carry a name or a
355
- // schema even if the plugin seam returned one.
356
- ...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
357
- ...(status.catalogAccess
358
- ? { catalogAccess: status.catalogAccess }
359
- : {}),
360
- ...(c.disconnectAuth &&
361
- c.startAuth &&
362
- (oauthManagement ||
363
- c.authScope === "personal" ||
364
- !personalCredentialOwner)
365
- ? { oauth: true }
366
- : {}),
367
- ...(credential ? { credential } : {}),
368
- };
197
+ finally {
198
+ await closeConnectorScope(c, registry.contextFor(c.id, baseUrl, requestScope), defer);
199
+ }
369
200
  });
370
- await mapSettledWithConcurrency(connectorSet, concurrency, (connector) => closeConnectorScope(connector, registry.contextFor(connector.id, baseUrl, requestScope), defer));
371
201
  const connectors = settled.map((result) => {
372
202
  if (result.status === "rejected")
373
203
  throw result.reason;
@@ -379,7 +209,6 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
379
209
  connectors,
380
210
  activityEnabled,
381
211
  credentialManagement,
382
- accessTokenManagement,
383
212
  oauthManagement: oauthManagement || Boolean(personalCredentialOwner),
384
213
  };
385
214
  }
@@ -498,3 +327,33 @@ ${OPERATOR_UI_SCRIPT}</script>
498
327
  </body>
499
328
  </html>`;
500
329
  }
330
+ /** Mount the connection UI without enabling any storage or activity module. */
331
+ export function operatorUi(options = {}) {
332
+ return {
333
+ ...options,
334
+ reservedPaths: ["/", "/ui", "/ui/*", "/favicon.svg", "/favicon.ico"],
335
+ credentialHandoffUrl(baseUrl) {
336
+ return new URL("/", baseUrl).toString();
337
+ },
338
+ async handle(context) {
339
+ const routes = [
340
+ ...(context.opts.credentialVault ? [routeCredentials] : []),
341
+ routeOAuthManagement,
342
+ routeUi,
343
+ ];
344
+ for (const route of routes) {
345
+ const response = await route(context);
346
+ if (response) {
347
+ if (operatorPageForPath(context.path) || context.path === "/ui") {
348
+ response.headers.set("X-Frame-Options", "DENY");
349
+ if (!response.headers.has("Content-Security-Policy")) {
350
+ response.headers.set("Content-Security-Policy", "frame-ancestors 'none'");
351
+ }
352
+ }
353
+ return response;
354
+ }
355
+ }
356
+ return context.opts.activityModule?.handle(context) ?? null;
357
+ },
358
+ };
359
+ }
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.23.0";
7
+ export declare const CONNECTA_VERSION = "0.24.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.23.0";
7
+ export const CONNECTA_VERSION = "0.24.0";
@@ -66,19 +66,17 @@ read top to bottom.
66
66
  | --- | --- | --- |
67
67
  | 0 | HTTPS upgrade | 308 to `publicUrl` when it is HTTPS and the request arrived over HTTP. Path and query are *assigned* onto the configured URL, never resolved against it, so a `//host` pathname cannot replace the deployment origin. `/health` is exempt: a loopback container probe must not depend on public DNS and TLS. `/ui` is canonicalized to `/` while upgrading. |
68
68
  | 0 | Cloudflare Access (Worker deployment, when enabled) | Edge admission before this route table. Managed OAuth owns its challenge and discovery metadata; an admitted direct invocation carries trusted identity in `ctx.access`. |
69
- | 1 | `/ui/access-tokens[/<id>]`, `/ui/credentials/<id>[/<action>]`, `/ui/oauth/<id>` | Private mutation routes, matched **first** so nothing can shadow them and so they own their own `OPTIONS` they answer it with a refusal rather than inheriting the wildcard CORS preflight. |
70
- | 2 | `OPTIONS` | Each auth provider's `handleMetadata` gets a chance (CORS preflight for browser MCP clients); otherwise 204 with MCP CORS. |
71
- | 3 | `/.well-known/*` | Auth providers' `handleMetadata`, open. 404 when none handles it. |
72
- | 4 | `/health` | Open JSON: status, connector count, `serverInfo`, the configured executor's sanitized name when it has one, catalog-drift counts, admission snapshots, reserved route names, and `deployment` when `deploymentInfo` is set. Payload-free by construction, and it never joins the MCP queue. |
73
- | 5 | `/oauth/callback/<connectorId>` | Downstream-OAuth completion, open, `verifyState` before `finishAuth`. Personal flows first resolve the short-lived state hash to the principal partition. |
74
- | 6 | `/favicon.*`, `/ui` `/`, the operator shells, `/ui/data` | The operator surface ([operator UI](./operator-ui.md)). The shells are open and data-free; `/ui/data` behind them is gated. |
75
- | 7 | `/ui/activity` | Gated, plus the optional `activity.readGate`. `GET` only; 404 with no `activity.store.list`. |
76
- | 8 | `/mcp` | **Admission before auth**, then the auth gate, then a fresh MCP server. |
77
- | 9 | — | 404. Custom HTTP routes belong to the deployment. |
69
+ | 1 | Mounted UI routes | The optional UI handles its shells, assets, data, details, and auth mutations before wildcard OPTIONS. Mutation routes refuse preflight rather than inheriting MCP CORS. No UI module means none of these routes. |
70
+ | 2 | `OPTIONS` | Auth metadata gets a chance, otherwise MCP CORS preflight. |
71
+ | 3 | `/.well-known/*` | Auth metadata, or 404. |
72
+ | 4 | `/health` | Open payload-free health, executor, admission, and deployment metadata; reserved routes reflect installed modules. |
73
+ | 5 | `/oauth/callback/<connectorId>` | Core downstream OAuth completion, state verification and personal ownership checks; independent of UI. |
74
+ | 6 | `/mcp` | Admission before auth, then a request-local MCP server. |
75
+ | 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
76
+
78
77
 
79
78
  Every response leaves through `withSecurityHeaders`: `nosniff`, a no-referrer
80
- policy, HSTS on HTTPS, and — on the operator shells a nonce-based script CSP
81
- and framing denial. `test/server-route-contracts.test.ts` pins this ordering
79
+ policy, HSTS on HTTPS, while the UI module adds a nonce-based script CSP and framing denial to its shells. `test/server-route-contracts.test.ts` pins this ordering
82
80
  and the exact refusal bodies; it exists because the ordering is invisible in
83
81
  any one file and a reordering reads like a harmless refactor.
84
82
 
@@ -125,6 +123,19 @@ read-only check. `test/execute.test.ts` asserts that parity directly, because
125
123
  the alternative — a sandbox path that quietly diverges — is how generated code
126
124
  would mint a capability.
127
125
 
126
+ ## Optional deployment modules
127
+
128
+ `createConnecta` takes closed typed `ui`, `vault`, and `activity` slots. Factories
129
+ live at `/ui`, `/credentials`, and `/activity`; bearer auth lives at
130
+ `/auth/bearer`. Root exports the contracts, never these implementations. There
131
+ is no module array, runtime registration, or plugin lifecycle.
132
+
133
+ Core keeps connector discovery, the executor contract, invocation, permissions,
134
+ and OAuth callback verification together. Optional modules contribute no
135
+ runtime work when omitted. The UI supplies credential handoff URLs only while
136
+ mounted. Status reads never initiate OAuth, and each lazy details request owns
137
+ its downstream scope. See [operator UI](./operator-ui.md).
138
+
128
139
  ## Import-graph purity
129
140
 
130
141
  Nothing reachable from `src/index.ts` may import a `node:` builtin. The core is
@@ -142,8 +153,8 @@ specific to a direct Worker invocation carrying `ctx.access`.
142
153
 
143
154
  `test/purity.test.ts` walks the relative-import graph from `src/index.ts` and
144
155
  fails on (a) any `node:` specifier in a reachable file and (b) the Node
145
- adapter, file storage, QuickJS parent or child, or the Clerk adapter being
146
- reachable at all. `test/package-surface.test.ts` and
156
+ adapter, file storage, QuickJS parent or child, auth adapters, UI bundle,
157
+ encrypted vault implementation, or activity implementation being reachable at all. `test/package-surface.test.ts` and
147
158
  `scripts/check-package.mjs` guard the other half — that the published tarball
148
159
  matches the same boundary.
149
160
 
@@ -169,8 +180,7 @@ src/
169
180
  invocation.ts one tool call, end to end
170
181
  catalog-drift.ts vetted manifests and the counts a refresh produces
171
182
  credentials.ts the AES-GCM connector vault over KVStorage
172
- access-tokens.ts operator-issued MCP bearer tokens
173
- activity.ts payload-free event contracts + best-effort recorder
183
+ activity.ts optional history factory and best-effort recorder
174
184
  call-admission.ts connector-partitioned downstream permits and budgets
175
185
  executor-admission.ts the portable bounded queue both pools use
176
186
  ui.ts the served operator shell and /ui/data payload
@@ -199,8 +209,7 @@ src/
199
209
  the connector limiters, then the executor. Node's `listen()` calls it on
200
210
  SIGTERM/SIGINT.
201
211
  - **Structural mistakes throw at construction.** A duplicate connector id, an
202
- invalid admission rule, `accessTokens` without an interactive operator provider, a missing
203
- executor: all refuse to boot. A deployment that starts in the wrong shape is
212
+ invalid admission rule, removed `accessTokens` option, or missing executor: all refuse to boot. A deployment that starts in the wrong shape is
204
213
  worse than one that does not start.
205
214
 
206
215
  ## Tests that enforce this