@zackbart/connecta 0.4.1 → 0.6.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 (85) hide show
  1. package/CHANGELOG.md +527 -0
  2. package/README.md +83 -7
  3. package/SECURITY.md +10 -6
  4. package/dist/activity.d.ts +8 -0
  5. package/dist/activity.d.ts.map +1 -1
  6. package/dist/activity.js +1 -0
  7. package/dist/activity.js.map +1 -1
  8. package/dist/auth/bearer.d.ts +10 -3
  9. package/dist/auth/bearer.d.ts.map +1 -1
  10. package/dist/auth/bearer.js +21 -0
  11. package/dist/auth/bearer.js.map +1 -1
  12. package/dist/auth/clerk.d.ts +26 -1
  13. package/dist/auth/clerk.d.ts.map +1 -1
  14. package/dist/auth/clerk.js +161 -4
  15. package/dist/auth/clerk.js.map +1 -1
  16. package/dist/connectors/api.d.ts +13 -0
  17. package/dist/connectors/api.d.ts.map +1 -1
  18. package/dist/connectors/api.js +2 -0
  19. package/dist/connectors/api.js.map +1 -1
  20. package/dist/connectors/remote-mcp.d.ts +13 -0
  21. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  22. package/dist/connectors/remote-mcp.js +10 -0
  23. package/dist/connectors/remote-mcp.js.map +1 -1
  24. package/dist/credential-health.d.ts +212 -0
  25. package/dist/credential-health.d.ts.map +1 -0
  26. package/dist/credential-health.js +535 -0
  27. package/dist/credential-health.js.map +1 -0
  28. package/dist/execute.d.ts +4 -4
  29. package/dist/execute.d.ts.map +1 -1
  30. package/dist/execute.js +16 -4
  31. package/dist/execute.js.map +1 -1
  32. package/dist/index.d.ts +77 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +112 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/meta-tools.d.ts +76 -7
  37. package/dist/meta-tools.d.ts.map +1 -1
  38. package/dist/meta-tools.js +328 -98
  39. package/dist/meta-tools.js.map +1 -1
  40. package/dist/registry.d.ts +245 -2
  41. package/dist/registry.d.ts.map +1 -1
  42. package/dist/registry.js +377 -27
  43. package/dist/registry.js.map +1 -1
  44. package/dist/server.d.ts +7 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +342 -27
  47. package/dist/server.js.map +1 -1
  48. package/dist/skills.d.ts +53 -2
  49. package/dist/skills.d.ts.map +1 -1
  50. package/dist/skills.js +162 -2
  51. package/dist/skills.js.map +1 -1
  52. package/dist/timeout.d.ts +16 -0
  53. package/dist/timeout.d.ts.map +1 -0
  54. package/dist/timeout.js +38 -0
  55. package/dist/timeout.js.map +1 -0
  56. package/dist/toolkits.d.ts +138 -0
  57. package/dist/toolkits.d.ts.map +1 -0
  58. package/dist/toolkits.js +319 -0
  59. package/dist/toolkits.js.map +1 -0
  60. package/dist/types.d.ts +90 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/ui.d.ts +63 -0
  63. package/dist/ui.d.ts.map +1 -1
  64. package/dist/ui.js +176 -11
  65. package/dist/ui.js.map +1 -1
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +5 -2
  69. package/src/activity.ts +9 -0
  70. package/src/auth/bearer.ts +35 -1
  71. package/src/auth/clerk.ts +202 -5
  72. package/src/connectors/api.ts +15 -0
  73. package/src/connectors/remote-mcp.ts +24 -0
  74. package/src/credential-health.ts +736 -0
  75. package/src/execute.ts +32 -8
  76. package/src/index.ts +226 -2
  77. package/src/meta-tools.ts +397 -119
  78. package/src/registry.ts +540 -29
  79. package/src/server.ts +431 -25
  80. package/src/skills.ts +185 -2
  81. package/src/timeout.ts +49 -0
  82. package/src/toolkits.ts +450 -0
  83. package/src/types.ts +96 -2
  84. package/src/ui.ts +190 -11
  85. package/src/version.ts +1 -1
package/src/ui.ts CHANGED
@@ -24,32 +24,74 @@ interface ResolvedBranding {
24
24
  themeColor: string;
25
25
  }
26
26
 
27
+ export const DEFAULT_FAVICON_HREF = "/favicon.svg";
28
+
29
+ /**
30
+ * Branding arrives from operator config, which is untyped at a JS call site, so
31
+ * every field is treated as `unknown`: a non-string is read as unset rather than
32
+ * throwing on `.trim()`. Rendering must degrade to defaults for a malformed
33
+ * value, never fail — `createConnecta` calls this during construction.
34
+ */
35
+ function trimmedString(value: unknown): string | undefined {
36
+ return typeof value === "string" ? value.trim() || undefined : undefined;
37
+ }
38
+
27
39
  export function resolveBranding(
28
40
  branding?: ConnectaBranding,
29
41
  ): ResolvedBranding {
30
- const productName = branding?.productName?.trim() || "Connecta";
31
- const ownerName = branding?.ownerName?.trim();
42
+ const productName = trimmedString(branding?.productName) ?? "Connecta";
43
+ const ownerName = trimmedString(branding?.ownerName);
32
44
  // Operator branding URLs become masthead/callback hrefs, so a non-http(s)
33
45
  // scheme (javascript:, data:) is dropped the same as an unset URL — the
34
46
  // callers already render a <span> instead of an <a> when it is absent.
35
- const productUrl = branding?.productUrl?.trim();
36
- const ownerUrl = branding?.ownerUrl?.trim();
47
+ const productUrl = trimmedString(branding?.productUrl);
48
+ const ownerUrl = trimmedString(branding?.ownerUrl);
49
+ const faviconHref = trimmedString(branding?.favicon?.href);
37
50
  return {
38
51
  productName,
39
52
  ...(productUrl && isSafeHttpUrl(productUrl) ? { productUrl } : {}),
40
53
  ...(ownerName ? { ownerName } : {}),
41
54
  ...(ownerUrl && isSafeHttpUrl(ownerUrl) ? { ownerUrl } : {}),
42
55
  description:
43
- branding?.description?.trim() ||
56
+ trimmedString(branding?.description) ??
44
57
  `Manage the services this ${productName} instance makes available to agents.`,
45
58
  pageTitle:
46
- branding?.pageTitle?.trim() ||
59
+ trimmedString(branding?.pageTitle) ??
47
60
  (ownerName ? `${productName} — ${ownerName}` : productName),
48
- faviconHref: branding?.favicon?.href?.trim() || "/favicon.svg",
49
- themeColor: branding?.themeColor?.trim() || "#ffffff",
61
+ faviconHref:
62
+ faviconHref && isSafeIconHref(faviconHref)
63
+ ? faviconHref
64
+ : DEFAULT_FAVICON_HREF,
65
+ themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
50
66
  };
51
67
  }
52
68
 
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?: ConnectaBranding): string[] {
76
+ if (!branding) return [];
77
+ const resolved = resolveBranding(branding);
78
+ // A non-string still counts as "set": the operator meant to supply a URL, and
79
+ // that intent is exactly what the warning reports on. A blank string does not.
80
+ const isSet = (value: unknown) =>
81
+ typeof value === "string"
82
+ ? trimmedString(value) !== undefined
83
+ : value !== undefined && value !== null;
84
+ const faviconHref = branding.favicon?.href;
85
+ return [
86
+ ...(isSet(branding.productUrl) && !resolved.productUrl ? ["productUrl"] : []),
87
+ ...(isSet(branding.ownerUrl) && !resolved.ownerUrl ? ["ownerUrl"] : []),
88
+ ...(isSet(faviconHref) &&
89
+ trimmedString(faviconHref) !== resolved.faviconHref
90
+ ? ["favicon.href"]
91
+ : []),
92
+ ];
93
+ }
94
+
53
95
  /**
54
96
  * A JS string literal safe to inline in a <script> block. JSON.stringify alone
55
97
  * leaves `/` untouched, so a value containing "</script>" would close the
@@ -74,6 +116,86 @@ export function isSafeHttpUrl(url: unknown): boolean {
74
116
  }
75
117
  }
76
118
 
119
+ /**
120
+ * Only the second check's base; any origin works because the check is whether
121
+ * the href stays on whatever origin it is resolved against. It is deliberately
122
+ * never the sole gate: a value whose own authority equals this host (say
123
+ * `//connecta.invalid/x`) would resolve to this exact origin and pass, so the
124
+ * structural check below runs first and is what actually rejects `//host`.
125
+ */
126
+ const SAME_ORIGIN_PROBE = "https://connecta.invalid";
127
+
128
+ /** Removed anywhere in a URL by the parser, so a gate must ignore them too. */
129
+ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
130
+
131
+ /**
132
+ * True for values allowed in the page's `<link rel="icon" href>`: an absolute
133
+ * `http(s)` URL (an icon the operator hosts elsewhere) or a path rooted at this
134
+ * origin. The relative carve-out is deliberate rather than accidental — the
135
+ * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
136
+ * reject — and it is kept narrow on both ends.
137
+ *
138
+ * Root-relative only, because `/ui` and `/oauth/callback/<id>` sit at different
139
+ * depths and a document-relative path would resolve differently on each.
140
+ *
141
+ * "Root-relative" is enforced structurally: exactly one leading `/` followed by
142
+ * a character that is neither `/` nor `\`. Both of those would make the value an
143
+ * authority (`//host`, and `/\host` because the URL parser folds `\` to `/` in
144
+ * special schemes), pointing at an origin this server does not control. The test
145
+ * runs on a copy with tab/newline/CR removed, since the parser strips those
146
+ * anywhere and `/\t/host` would otherwise slip through as single-slash. The
147
+ * origin comparison that follows is defense in depth, not the authority check —
148
+ * on its own it would accept an authority that happened to equal the probe host.
149
+ */
150
+ export function isSafeIconHref(href: unknown): boolean {
151
+ if (typeof href !== "string") return false;
152
+ if (isSafeHttpUrl(href)) return true;
153
+ if (!/^\/(?![/\\])/.test(href.replace(URL_STRIPPED_CHARS, ""))) return false;
154
+ try {
155
+ return new URL(href, SAME_ORIGIN_PROBE).origin === SAME_ORIGIN_PROBE;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * True only for an absolute `https:` URL — the gate for `uiAuth.frontendApiUrl`,
163
+ * the last operator-config value that lands in a URL-valued HTML position (the
164
+ * `<script src>` of `/ui`'s sign-in loader). `javascript:` in a `src` does not
165
+ * execute, so this closes a hole in the *invariant* rather than a live vector:
166
+ * every operator value reaching an `href`/`src` is validated, with no exception
167
+ * left to remember.
168
+ *
169
+ * Stricter than `isSafeHttpUrl` on purpose. There is no `http:` carve-out and no
170
+ * loopback carve-out, because nobody types this value: the shipped Clerk adapter
171
+ * derives it from the publishable key and Clerk's Frontend API is always https.
172
+ * A cleartext script source on the dashboard would be a downgrade even where a
173
+ * browser's mixed-content rules had not already blocked it.
174
+ */
175
+ export function isSafeScriptSrcUrl(url: unknown): boolean {
176
+ if (typeof url !== "string") return false;
177
+ try {
178
+ return new URL(url).protocol === "https:";
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Names of the `uiAuth` URLs an inbound-auth provider supplied that failed their
186
+ * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
187
+ * startup warning cannot then drift from what rendering actually drops. Every
188
+ * field is read defensively rather than trusted, because a custom `InboundAuth`
189
+ * is untyped at a JS call site — `isSafeScriptSrcUrl` takes `unknown`, and a
190
+ * `uiAuth` that is not the clerk shape is reported as nothing to warn about.
191
+ */
192
+ export function droppedUiAuthUrls(uiAuth?: UiAuthConfig): string[] {
193
+ if (!uiAuth || uiAuth.kind !== "clerk") return [];
194
+ return isSafeScriptSrcUrl(uiAuth.frontendApiUrl)
195
+ ? []
196
+ : ["uiAuth.frontendApiUrl"];
197
+ }
198
+
77
199
  export interface UiTool {
78
200
  name: string;
79
201
  address: string;
@@ -89,6 +211,17 @@ export interface UiConnector {
89
211
  authorizationUrl?: string;
90
212
  toolCount: number;
91
213
  tools: UiTool[];
214
+ /**
215
+ * Verdict of the last proactive credential liveness check (issue #24), for the
216
+ * connectors that hold a credential connecta stores. Shown beside the live
217
+ * status so an operator can tell "checked just now" from "last verified an
218
+ * hour ago", and see a dead credential the page's own probe may not reach.
219
+ */
220
+ credentialCheck?: {
221
+ state: "ok" | "auth_required" | "error";
222
+ checkedAt: string;
223
+ message?: string;
224
+ };
92
225
  credential?: {
93
226
  label: string;
94
227
  description?: string;
@@ -173,6 +306,7 @@ export async function buildUiData(
173
306
  const connectors = await Promise.all(
174
307
  registry.listConnectors().map(async (c): Promise<UiConnector> => {
175
308
  const status = await registry.statusFor(c.id, baseUrl, requestScope);
309
+ const credentialCheck = await registry.credentialHealthFor(c.id);
176
310
  let tools: UiTool[] = [];
177
311
  // `status()` on an unauthenticated remote connector starts OAuth and
178
312
  // stores its state + PKCE verifier. Probing listTools immediately
@@ -271,6 +405,17 @@ export async function buildUiData(
271
405
  : {}),
272
406
  toolCount: tools.length,
273
407
  tools,
408
+ ...(credentialCheck
409
+ ? {
410
+ credentialCheck: {
411
+ state: credentialCheck.state,
412
+ checkedAt: credentialCheck.checkedAt,
413
+ ...(credentialCheck.message
414
+ ? { message: credentialCheck.message }
415
+ : {}),
416
+ },
417
+ }
418
+ : {}),
274
419
  ...(credential ? { credential } : {}),
275
420
  };
276
421
  }),
@@ -305,7 +450,28 @@ export function renderUiHtml(
305
450
  branding?: ConnectaBranding,
306
451
  nonce?: string,
307
452
  ): string {
308
- const auth = uiAuth ?? { kind: "bearer" as const };
453
+ const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
454
+ // The Clerk loader's origin. A value that fails the gate is dropped rather
455
+ // than escaped into the page: the loader tag is simply not emitted, the gate
456
+ // reports that Clerk could not load, and the rest of the shell still renders —
457
+ // the same fallback-and-warn posture the branding URLs take, with the drop
458
+ // named in a startup warning (see `droppedUiAuthUrls`).
459
+ const clerkScriptOrigin =
460
+ clerk && isSafeScriptSrcUrl(clerk.frontendApiUrl)
461
+ ? clerk.frontendApiUrl
462
+ : undefined;
463
+ // Enumerated field by field, because this object is serialized into the page's
464
+ // inline script: a rejected frontendApiUrl must not reach the document through
465
+ // `AUTH` after being kept out of the `<script src>`.
466
+ const auth = clerk
467
+ ? {
468
+ kind: clerk.kind,
469
+ publishableKey: clerk.publishableKey,
470
+ ...(clerkScriptOrigin ? { frontendApiUrl: clerkScriptOrigin } : {}),
471
+ ...(clerk.signInUrl ? { signInUrl: clerk.signInUrl } : {}),
472
+ ...(clerk.signUpUrl ? { signUpUrl: clerk.signUpUrl } : {}),
473
+ }
474
+ : (uiAuth ?? { kind: "bearer" as const });
309
475
  const brand = resolveBranding(branding);
310
476
  const title = brand.pageTitle;
311
477
  // When the /ui response ships a nonce-based CSP, every <script> it emits must
@@ -327,8 +493,8 @@ export function renderUiHtml(
327
493
  : `<span class="product">${escapeHtmlAttr(brand.productName)}</span>`
328
494
  : "";
329
495
  const clerkScript =
330
- uiAuth?.kind === "clerk"
331
- ? `<script${nonceAttr} defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(uiAuth.publishableKey)}" src="${escapeHtmlAttr(uiAuth.frontendApiUrl)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
496
+ clerk && clerkScriptOrigin
497
+ ? `<script${nonceAttr} defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(clerk.publishableKey)}" src="${escapeHtmlAttr(clerkScriptOrigin)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
332
498
  : "";
333
499
 
334
500
  return `<!doctype html>
@@ -907,6 +1073,19 @@ function render() {
907
1073
  if (c.message) {
908
1074
  head += '<p class="connector-message msg">' + esc(c.message) + "</p>";
909
1075
  }
1076
+ if (c.credentialCheck) {
1077
+ const check = c.credentialCheck;
1078
+ const verdict = check.state === "ok"
1079
+ ? "credential verified"
1080
+ : check.state === "auth_required"
1081
+ ? "credential needs authorization"
1082
+ : "credential check failed";
1083
+ head += '<p class="connector-check meta">Credential check: ' +
1084
+ esc(verdict) + " · " + esc(formatDate(check.checkedAt)) +
1085
+ (check.message && check.message !== c.message
1086
+ ? " — " + esc(check.message)
1087
+ : "") + "</p>";
1088
+ }
910
1089
  if (c.authorizationUrl) {
911
1090
  const safe = safeHttp(c.authorizationUrl);
912
1091
  head += safe
package/src/version.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 const CONNECTA_VERSION = "0.4.1";
7
+ export const CONNECTA_VERSION = "0.6.0";