@zackbart/connecta 0.5.0 → 0.6.1

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 (76) hide show
  1. package/CHANGELOG.md +505 -0
  2. package/README.md +159 -267
  3. package/dist/auth/bearer.d.ts +10 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +21 -0
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +28 -3
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +161 -4
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  12. package/dist/connectors/remote-mcp.js +8 -0
  13. package/dist/connectors/remote-mcp.js.map +1 -1
  14. package/dist/credential-health.d.ts +220 -0
  15. package/dist/credential-health.d.ts.map +1 -0
  16. package/dist/credential-health.js +551 -0
  17. package/dist/credential-health.js.map +1 -0
  18. package/dist/credentials.d.ts +35 -1
  19. package/dist/credentials.d.ts.map +1 -1
  20. package/dist/credentials.js +42 -0
  21. package/dist/credentials.js.map +1 -1
  22. package/dist/execute.d.ts.map +1 -1
  23. package/dist/execute.js +16 -4
  24. package/dist/execute.js.map +1 -1
  25. package/dist/index.d.ts +46 -5
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +118 -15
  28. package/dist/index.js.map +1 -1
  29. package/dist/meta-tools.d.ts +56 -5
  30. package/dist/meta-tools.d.ts.map +1 -1
  31. package/dist/meta-tools.js +249 -92
  32. package/dist/meta-tools.js.map +1 -1
  33. package/dist/registry.d.ts +62 -0
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +85 -1
  36. package/dist/registry.js.map +1 -1
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +305 -40
  39. package/dist/server.js.map +1 -1
  40. package/dist/skills.d.ts +1 -1
  41. package/dist/skills.d.ts.map +1 -1
  42. package/dist/skills.js +3 -3
  43. package/dist/skills.js.map +1 -1
  44. package/dist/timeout.d.ts +16 -0
  45. package/dist/timeout.d.ts.map +1 -0
  46. package/dist/timeout.js +38 -0
  47. package/dist/timeout.js.map +1 -0
  48. package/dist/toolkits.d.ts +95 -1
  49. package/dist/toolkits.d.ts.map +1 -1
  50. package/dist/toolkits.js +190 -5
  51. package/dist/toolkits.js.map +1 -1
  52. package/dist/types.d.ts +81 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/ui.d.ts +52 -0
  55. package/dist/ui.d.ts.map +1 -1
  56. package/dist/ui.js +144 -13
  57. package/dist/ui.js.map +1 -1
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +1 -1
  61. package/src/auth/bearer.ts +35 -1
  62. package/src/auth/clerk.ts +204 -7
  63. package/src/connectors/remote-mcp.ts +9 -0
  64. package/src/credential-health.ts +753 -0
  65. package/src/credentials.ts +71 -1
  66. package/src/execute.ts +28 -4
  67. package/src/index.ts +204 -22
  68. package/src/meta-tools.ts +286 -109
  69. package/src/registry.ts +125 -1
  70. package/src/server.ts +366 -38
  71. package/src/skills.ts +3 -3
  72. package/src/timeout.ts +49 -0
  73. package/src/toolkits.ts +241 -6
  74. package/src/types.ts +87 -1
  75. package/src/ui.ts +156 -14
  76. package/src/version.ts +1 -1
package/src/toolkits.ts CHANGED
@@ -4,10 +4,16 @@
4
4
  // A connecta deployment belongs to an ORG; a toolkit is the view a GROUP OF
5
5
  // TEAM MEMBERS inside that org gets — a "support" toolkit seeing Zendesk and
6
6
  // Notion, an "exec" toolkit that also sees Gmail. This module only *defines and
7
- // validates* scopes. Enforcement lives in one place: `ScopedRegistry`
8
- // (src/registry.ts), which every meta-tool inherits through `RegistryView`.
7
+ // validates* scopes and the identity bindings that gate them. Enforcement lives
8
+ // in two places, each with one job:
9
+ //
10
+ // - WHICH toolkit an identity may open: the connect-time binding check in
11
+ // `resolveToolkitScope` (src/server.ts), run after the auth gate and before
12
+ // any scoped registry exists.
13
+ // - WHAT a selected toolkit may see: `ScopedRegistry` (src/registry.ts),
14
+ // which every meta-tool inherits through `RegistryView`.
9
15
 
10
- import type { Connector } from "./types.js";
16
+ import type { Connector, InboundAuth, ToolkitBinding } from "./types.js";
11
17
 
12
18
  /** Toolkit names share the connector-id grammar: URL-safe, no separators. */
13
19
  export const TOOLKIT_NAME_RE = /^[a-z0-9_-]+$/;
@@ -121,9 +127,9 @@ function toolFilter(
121
127
  *
122
128
  * Structural mistakes THROW at construction rather than warn: a typo'd id in
123
129
  * an allowlist is a scope the operator did not write, and a scope nobody wrote
124
- * is not one an operator can reason about. (A toolkit scopes visibility, not
125
- * identity it is not itself an access check; see the module header and
126
- * documentation.md §16.) Tool names are checked only for connectors that expose
130
+ * is not one an operator can reason about. (A definition scopes visibility only;
131
+ * WHICH identity may select it is the separate binding below see the module
132
+ * header and documentation.md §16.) Tool names are checked only for connectors that expose
127
133
  * `staticTools` (i.e. `api()`); a remote connector's catalog is fetched lazily
128
134
  * over the network and is unknown at construction time.
129
135
  */
@@ -213,3 +219,232 @@ export function resolveToolkits(
213
219
  }
214
220
  return resolved;
215
221
  }
222
+
223
+ /**
224
+ * The binding half of an inbound-auth adapter's options — the shape every
225
+ * shipped adapter (`bearerToken`, `clerkAuth`) mixes into its own options so an
226
+ * operator writes one thing in one style, next to the credential it binds.
227
+ */
228
+ export interface ToolkitBindingOptions {
229
+ /**
230
+ * Toolkit names this credential may select with `?toolkit=<name>`. Present ⇒
231
+ * the identity is BOUND: any other toolkit, and (unless `unscoped`) a
232
+ * connection with no `?toolkit=`, is refused at connect time. Absent ⇒
233
+ * unbound, exactly as before bindings existed.
234
+ */
235
+ toolkits?: readonly string[];
236
+ /**
237
+ * Also allow a connection with no `?toolkit=` (the full registry, and the
238
+ * deployment-wide operator surfaces). Only meaningful beside `toolkits`.
239
+ */
240
+ unscoped?: boolean;
241
+ }
242
+
243
+ /**
244
+ * Validate one adapter's binding options into a `ToolkitBinding`, or undefined
245
+ * when the adapter declares none. Structural mistakes THROW where the operator
246
+ * wrote them (adapter construction), for the same reason toolkit definitions do:
247
+ * a binding that does not say what its author meant is worse than none, because
248
+ * it is invisible until the day it denies — or admits — the wrong caller.
249
+ *
250
+ * Names are only checked against the *grammar* here; cross-checking them
251
+ * against the configured toolkits happens in `validateToolkitBindings`, which
252
+ * runs in `createConnecta` where both halves are finally in scope.
253
+ */
254
+ export function resolveToolkitBinding(
255
+ source: string,
256
+ options: ToolkitBindingOptions,
257
+ ): ToolkitBinding | undefined {
258
+ const { toolkits, unscoped } = options;
259
+ if (toolkits === undefined) {
260
+ if (unscoped !== undefined) {
261
+ // `unscoped` alone reads like a permission but grants nothing an unbound
262
+ // identity does not already have, so it is almost certainly a half-written
263
+ // binding — the one shape here that would silently fail OPEN.
264
+ throw new Error(
265
+ `${source}: \`unscoped\` only means something beside \`toolkits\`. ` +
266
+ "List the toolkits this credential may open, or drop `unscoped` to " +
267
+ "leave the credential unbound.",
268
+ );
269
+ }
270
+ return undefined;
271
+ }
272
+ if (!Array.isArray(toolkits)) {
273
+ throw new Error(
274
+ `${source}: \`toolkits\` must be an array of toolkit names.`,
275
+ );
276
+ }
277
+ const names: string[] = [];
278
+ for (const name of toolkits) {
279
+ if (typeof name !== "string" || !TOOLKIT_NAME_RE.test(name)) {
280
+ // A name outside the grammar can never match a declared toolkit, so this
281
+ // would bind the credential to nothing selectable.
282
+ throw new Error(
283
+ `${source}: \`toolkits\` entry ${JSON.stringify(name)} is not a ` +
284
+ `toolkit name (must match ${TOOLKIT_NAME_RE.source}).`,
285
+ );
286
+ }
287
+ if (!names.includes(name)) names.push(name);
288
+ }
289
+ if (names.length === 0 && unscoped !== true) {
290
+ throw new Error(
291
+ `${source}: binds no toolkits and no unscoped access, so this credential ` +
292
+ "could authenticate but never connect. List at least one toolkit, or " +
293
+ "pass `unscoped: true` to bind it to the full registry only.",
294
+ );
295
+ }
296
+ return Object.freeze({
297
+ toolkits: Object.freeze(names) as readonly string[],
298
+ ...(unscoped === true ? { unscoped: true } : {}),
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Coerce an arbitrary value into a `ToolkitBinding`, or null when it is not one.
304
+ *
305
+ * The shipped adapters build bindings through `resolveToolkitBinding` above, but
306
+ * `InboundAuth` is an open interface and `AuthResult.toolkitBinding` arrives at
307
+ * REQUEST time from code connecta does not own — a custom adapter, or one
308
+ * mapping an IdP claim. Every field is therefore re-checked here rather than
309
+ * trusted from the type, because each way of being wrong fails OPEN if it is
310
+ * merely believed:
311
+ *
312
+ * - `unscoped` is compared to `true` by identity, so a truthy non-boolean (the
313
+ * string `"false"` out of an env var, say) cannot grant the full registry;
314
+ * - `toolkits` must be a real array — a bare string would otherwise reach
315
+ * `String.prototype.includes`, where `?toolkit=sup` would "match" `"support"`
316
+ * by substring;
317
+ * - a missing/!array `toolkits` is not treated as an empty binding, because the
318
+ * caller of a null return refuses the request outright.
319
+ *
320
+ * Returns a frozen, deduplicated copy: nothing downstream can be mutated by the
321
+ * adapter after the check, and every name is known to fit the grammar.
322
+ */
323
+ export function normalizeToolkitBinding(value: unknown): ToolkitBinding | null {
324
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
325
+ return null;
326
+ }
327
+ const { toolkits, unscoped } = value as {
328
+ toolkits?: unknown;
329
+ unscoped?: unknown;
330
+ };
331
+ if (!Array.isArray(toolkits)) return null;
332
+ if (unscoped !== undefined && typeof unscoped !== "boolean") return null;
333
+ const names: string[] = [];
334
+ for (const name of toolkits) {
335
+ if (typeof name !== "string" || !TOOLKIT_NAME_RE.test(name)) return null;
336
+ if (!names.includes(name)) names.push(name);
337
+ }
338
+ return Object.freeze({
339
+ toolkits: Object.freeze(names) as readonly string[],
340
+ ...(unscoped === true ? { unscoped: true } : {}),
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Resolve the binding one admitted identity is actually held to, from the
346
+ * provider's static declaration and whatever its `authorize` returned.
347
+ *
348
+ * - Neither ⇒ unbound (undefined), the pre-binding behavior.
349
+ * - Declaration only ⇒ the declaration.
350
+ * - Per-identity only ⇒ that binding, validated. This is the custom-adapter
351
+ * seam: a provider that declares nothing is asserting it resolves membership
352
+ * itself, so there is nothing to check it against.
353
+ * - Both ⇒ the **intersection**. The declaration is a CEILING, not a default: an
354
+ * adapter that maps a user-writable IdP claim to toolkits must not be able to
355
+ * widen the credential's own binding, which would turn "support token" into
356
+ * "any toolkit, plus the full registry" for anyone who can set that claim.
357
+ * Narrowing is fine and useful (per-user subsets of the team's view).
358
+ *
359
+ * A malformed binding on either side is not silently ignored — it returns
360
+ * `{ ok: false }` and the caller refuses the request, because the alternative
361
+ * (dropping it) is the fail-open reading.
362
+ */
363
+ export function resolveIdentityBinding(
364
+ declared: unknown,
365
+ perIdentity: unknown,
366
+ ):
367
+ | { ok: true; binding?: ToolkitBinding }
368
+ | { ok: false; reason: string } {
369
+ const ceiling =
370
+ declared === undefined ? undefined : normalizeToolkitBinding(declared);
371
+ if (declared !== undefined && !ceiling) {
372
+ return {
373
+ ok: false,
374
+ reason:
375
+ "the toolkit binding declared on the provider is malformed " +
376
+ "(`toolkits` must be an array of toolkit names, `unscoped` a boolean)",
377
+ };
378
+ }
379
+ if (perIdentity === undefined) {
380
+ return ceiling ? { ok: true, binding: ceiling } : { ok: true };
381
+ }
382
+ const identity = normalizeToolkitBinding(perIdentity);
383
+ if (!identity) {
384
+ return {
385
+ ok: false,
386
+ reason:
387
+ "the toolkit binding its authorize() returned for this identity is " +
388
+ "malformed (`toolkits` must be an array of toolkit names, `unscoped` " +
389
+ "a boolean)",
390
+ };
391
+ }
392
+ if (!ceiling) return { ok: true, binding: identity };
393
+ return {
394
+ ok: true,
395
+ binding: Object.freeze({
396
+ toolkits: Object.freeze(
397
+ identity.toolkits.filter((name) => ceiling.toolkits.includes(name)),
398
+ ) as readonly string[],
399
+ ...(identity.unscoped === true && ceiling.unscoped === true
400
+ ? { unscoped: true }
401
+ : {}),
402
+ }),
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Cross-check every statically declared binding against the deployment's
408
+ * toolkits, in `createConnecta`. A name that no toolkit declares is a typo, and
409
+ * a typo here fails CLOSED — the credential would be refused every connection
410
+ * with a 403 the client reads as a transport failure — so it throws at
411
+ * construction rather than becoming a support ticket. A structurally malformed
412
+ * declaration (only reachable from a hand-written `InboundAuth`, since the
413
+ * shipped adapters validate their own options) throws here too, rather than
414
+ * waiting to refuse every request at runtime.
415
+ *
416
+ * Bindings a provider mints per-identity (`AuthResult.toolkitBinding`) do not
417
+ * exist yet and cannot be checked here; they are validated on arrival and capped
418
+ * by the declaration (`resolveIdentityBinding`).
419
+ */
420
+ export function validateToolkitBindings(
421
+ auth: readonly InboundAuth[],
422
+ toolkits: ReadonlyMap<string, Toolkit> | undefined,
423
+ ): void {
424
+ for (const provider of auth) {
425
+ if (provider.toolkitBinding === undefined) continue;
426
+ const binding = normalizeToolkitBinding(provider.toolkitBinding);
427
+ if (!binding) {
428
+ throw new Error(
429
+ `Inbound auth provider "${provider.kind}" declares a malformed ` +
430
+ "toolkitBinding: `toolkits` must be an array of toolkit names " +
431
+ `(matching ${TOOLKIT_NAME_RE.source}) and \`unscoped\` a boolean.`,
432
+ );
433
+ }
434
+ if (!toolkits || toolkits.size === 0) {
435
+ throw new Error(
436
+ `Inbound auth provider "${provider.kind}" binds toolkits ` +
437
+ `(${binding.toolkits.join(", ")}) but this deployment configures no ` +
438
+ "toolkits. Declare them in `toolkits`, or drop the binding.",
439
+ );
440
+ }
441
+ for (const name of binding.toolkits) {
442
+ if (!toolkits.has(name)) {
443
+ throw new Error(
444
+ `Inbound auth provider "${provider.kind}" binds unknown toolkit ` +
445
+ `"${name}". Configured toolkits: ${[...toolkits.keys()].join(", ")}.`,
446
+ );
447
+ }
448
+ }
449
+ }
450
+ }
package/src/types.ts CHANGED
@@ -164,6 +164,19 @@ export interface Connector {
164
164
  values: ConnectorCredentialValues,
165
165
  ctx: ConnectorContext,
166
166
  ): Promise<CredentialTestResult>;
167
+ /**
168
+ * Optional: whether this connector currently holds a stored downstream
169
+ * credential — an OAuth grant it persisted, typically. Read only by the
170
+ * credential liveness checks: a connector with nothing stored has no
171
+ * credential whose liveness could be in question, and probing it anyway would
172
+ * start an authorization flow nobody asked for.
173
+ *
174
+ * Implement it on connectors that manage their own credential storage (the
175
+ * shipped `remoteMcp` does, for `auth: { type: "oauth" }`). Connectors whose
176
+ * credential lives in connecta's vault (`credential` above) need not: the
177
+ * vault answers for them. Must not perform downstream I/O.
178
+ */
179
+ hasStoredCredential?(ctx: ConnectorContext): Promise<boolean>;
167
180
  /**
168
181
  * Statically-known tool defs, exposed by in-code connectors (`api()`) for
169
182
  * startup convention checks. Remote connectors omit this — their tools are
@@ -239,17 +252,82 @@ export interface Executor {
239
252
  execute(code: string, providers: ExecutorProvider[]): Promise<ExecuteResult>;
240
253
  }
241
254
 
255
+ /**
256
+ * Which toolkits one inbound identity may open — the membership half of the
257
+ * deployment=org / toolkit=team framing (§16). A mapping, never a policy
258
+ * engine: one identity → the toolkit names it may select, plus whether it may
259
+ * connect with no `?toolkit=` at all.
260
+ *
261
+ * An identity with NO binding is unbound and keeps the pre-binding behavior:
262
+ * any declared toolkit, or the full registry. A binding is enforced at connect
263
+ * time, before any scoped registry is constructed.
264
+ */
265
+ export interface ToolkitBinding {
266
+ /** Toolkit names this identity may select with `?toolkit=<name>`. */
267
+ readonly toolkits: readonly string[];
268
+ /**
269
+ * Whether this identity may also connect with no `?toolkit=` and see the full
270
+ * registry (and read the deployment-wide operator surfaces). Defaults to
271
+ * false: binding a credential to a toolkit means binding it.
272
+ */
273
+ readonly unscoped?: boolean;
274
+ }
275
+
242
276
  /** Result of an inbound-auth check. */
243
277
  export type AuthResult =
244
- | { ok: true; userId?: string; subjectId?: string }
278
+ | {
279
+ ok: true;
280
+ userId?: string;
281
+ subjectId?: string;
282
+ /**
283
+ * Toolkit binding resolved for THIS identity — the seam for an adapter
284
+ * that maps its own users (or an IdP claim) to views. Omit to inherit the
285
+ * provider's `toolkitBinding`.
286
+ *
287
+ * When the provider also declares one, the declaration is a **CEILING**,
288
+ * not a default: connecta intersects the two, and grants `unscoped` only
289
+ * if both do. A per-identity binding can therefore narrow the credential's
290
+ * view but never widen it — otherwise an adapter reading a user-writable
291
+ * claim would let the user name their own toolkits. When the provider
292
+ * declares nothing, this binding is used as given.
293
+ *
294
+ * Validated on arrival (a malformed one refuses the request with 403
295
+ * rather than being ignored), but never checked against the configured
296
+ * toolkits, which is only possible for the static declaration at startup.
297
+ */
298
+ toolkitBinding?: ToolkitBinding;
299
+ }
245
300
  | { ok: false; response: Response };
246
301
 
247
302
  /** Public browser-auth configuration exposed to connecta's status UI. */
248
303
  export type UiAuthConfig = {
249
304
  kind: "clerk";
250
305
  publishableKey: string;
306
+ /**
307
+ * Origin `/ui` fetches its browser sign-in loader from. **Must be an absolute
308
+ * `https:` URL** — the value lands in a `<script src>`, so the gate is
309
+ * stricter than the branding href gate: no `http:`, no loopback exemption, and
310
+ * no root-relative form (a relative path is rejected, not resolved). The
311
+ * shipped `clerkAuth` adapter derives this from the publishable key and
312
+ * Clerk's Frontend API is always https, so nothing legitimate needs a
313
+ * carve-out. A value that fails the gate reaches neither the loader tag nor
314
+ * the page's inline auth config: `/ui` renders without the loader and reports
315
+ * that Clerk could not load, and `createConnecta` names the drop in a startup
316
+ * warning.
317
+ */
251
318
  frontendApiUrl: string;
319
+ /**
320
+ * Hosted Account Portal sign-in address, handed to `Clerk.load`. **Must be an
321
+ * absolute `https:` URL** — the same gate `frontendApiUrl` passes, because
322
+ * this value is where Clerk *navigates* the operator's browser. An Account
323
+ * Portal address is always https, so the stricter gate costs nothing real: a
324
+ * value that fails it (a `javascript:`/`data:` payload, a cleartext `http:`
325
+ * address, a relative path) reaches no part of the page, `/ui` signs in
326
+ * through Clerk's default instead, and `createConnecta` names the drop in a
327
+ * startup warning.
328
+ */
252
329
  signInUrl?: string;
330
+ /** Hosted Account Portal sign-up address. Gated exactly like `signInUrl`. */
253
331
  signUpUrl?: string;
254
332
  };
255
333
 
@@ -300,6 +378,14 @@ export interface InboundAuth {
300
378
  * provider instead of asking the operator to paste a static bearer secret.
301
379
  */
302
380
  uiAuth?: UiAuthConfig;
381
+ /**
382
+ * Optional toolkit binding for every identity this provider admits (§16).
383
+ * Declared statically so `createConnecta` can validate the names against
384
+ * `ConnectaConfig.toolkits` and throw on a typo — a binding nobody wrote is
385
+ * not one an operator can reason about. An `authorize` result may narrow it
386
+ * per identity with its own `toolkitBinding`.
387
+ */
388
+ toolkitBinding?: ToolkitBinding;
303
389
  /** Serve/short-circuit .well-known + OPTIONS. Return null when not handled. */
304
390
  handleMetadata?(
305
391
  request: Request,
package/src/ui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { credentialTestRule } from "./credentials.js";
1
2
  import type { CredentialVault } from "./credentials.js";
2
3
  import type { Registry } from "./registry.js";
3
4
  import type { ConnectaBranding, UiAuthConfig } from "./types.js";
@@ -66,6 +67,20 @@ export function resolveBranding(
66
67
  };
67
68
  }
68
69
 
70
+ /**
71
+ * Whether the operator meant to supply a value here — the question every
72
+ * dropped-URL warning asks before naming a field, and one definition so the
73
+ * branding and `uiAuth` warnings cannot answer it differently. A non-string
74
+ * counts as set: the intent was there and is exactly what the warning reports
75
+ * on. A blank or whitespace-only string does not; that is indistinguishable
76
+ * from leaving the field alone, and both take the default silently.
77
+ */
78
+ function isSetUrlValue(value: unknown): boolean {
79
+ return typeof value === "string"
80
+ ? trimmedString(value) !== undefined
81
+ : value !== undefined && value !== null;
82
+ }
83
+
69
84
  /**
70
85
  * Names of the branding URLs the operator set that failed their gate and were
71
86
  * replaced by a default. Lives beside the gates so the startup warning cannot
@@ -75,17 +90,15 @@ export function resolveBranding(
75
90
  export function droppedBrandingUrls(branding?: ConnectaBranding): string[] {
76
91
  if (!branding) return [];
77
92
  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
93
  const faviconHref = branding.favicon?.href;
85
94
  return [
86
- ...(isSet(branding.productUrl) && !resolved.productUrl ? ["productUrl"] : []),
87
- ...(isSet(branding.ownerUrl) && !resolved.ownerUrl ? ["ownerUrl"] : []),
88
- ...(isSet(faviconHref) &&
95
+ ...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
96
+ ? ["productUrl"]
97
+ : []),
98
+ ...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
99
+ ? ["ownerUrl"]
100
+ : []),
101
+ ...(isSetUrlValue(faviconHref) &&
89
102
  trimmedString(faviconHref) !== resolved.faviconHref
90
103
  ? ["favicon.href"]
91
104
  : []),
@@ -158,6 +171,67 @@ export function isSafeIconHref(href: unknown): boolean {
158
171
  }
159
172
  }
160
173
 
174
+ /**
175
+ * True only for an absolute `https:` URL — the gate every `uiAuth` URL passes:
176
+ * `frontendApiUrl`, which becomes the `<script src>` of `/ui`'s sign-in loader,
177
+ * and `signInUrl`/`signUpUrl`, which ClerkJS uses as *navigation targets* when
178
+ * the operator signs in. With those three gated, no operator-config value
179
+ * reaches the browser in a URL position — attribute or navigation — without
180
+ * validation, and there is no exception left to remember.
181
+ *
182
+ * Stricter than `isSafeHttpUrl` on purpose: no `http:` carve-out, no loopback
183
+ * carve-out, and no relative form. Nobody types `frontendApiUrl` — the shipped
184
+ * Clerk adapter derives it from the publishable key, and Clerk's Frontend API is
185
+ * always https — and a cleartext script source on the dashboard would be a
186
+ * downgrade even where a browser's mixed-content rules had not already blocked
187
+ * it. `signInUrl`/`signUpUrl` *are* typed by the operator, but what belongs
188
+ * there is a hosted Account Portal address (`https://accounts.<domain>` or
189
+ * `https://<slug>.accounts.dev`), which is https as well; `http:` would carry a
190
+ * sign-in over cleartext, and a path relative to this origin is meaningless
191
+ * because this server hosts no sign-in page of its own. So the looser gate would
192
+ * buy nothing real, and the same strictness holds for all three.
193
+ */
194
+ export function isSafeHttpsUrl(url: unknown): boolean {
195
+ if (typeof url !== "string") return false;
196
+ try {
197
+ return new URL(url).protocol === "https:";
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Names of the `uiAuth` URLs an inbound-auth provider supplied that failed their
205
+ * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
206
+ * startup warning cannot then drift from what rendering actually drops. Every
207
+ * field is read defensively rather than trusted, because a custom `InboundAuth`
208
+ * is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
209
+ * `uiAuth` that is not the clerk shape is reported as nothing to warn about.
210
+ *
211
+ * `frontendApiUrl` is required, so anything that fails its gate is a drop.
212
+ * `signInUrl` and `signUpUrl` are optional, so only a value the operator
213
+ * *supplied* and the gate then rejected is worth a warning — an unset field
214
+ * took no default away from anyone. `isSetUrlValue` decides that, the same way
215
+ * and for the same reasons it decides it for the branding URLs: a warning that
216
+ * fires for one and not the other would be reporting on the field rather than
217
+ * on the operator's intent. Rendering is not consulted for this: it drops on
218
+ * the gate alone, and a blank string fails that gate too — it is simply not
219
+ * *reported*, because a blank is indistinguishable from leaving the field
220
+ * alone.
221
+ */
222
+ export function droppedUiAuthUrls(uiAuth?: UiAuthConfig): string[] {
223
+ if (!uiAuth || uiAuth.kind !== "clerk") return [];
224
+ return [
225
+ ...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
226
+ ...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
227
+ ? ["uiAuth.signInUrl"]
228
+ : []),
229
+ ...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
230
+ ? ["uiAuth.signUpUrl"]
231
+ : []),
232
+ ];
233
+ }
234
+
161
235
  export interface UiTool {
162
236
  name: string;
163
237
  address: string;
@@ -173,6 +247,17 @@ export interface UiConnector {
173
247
  authorizationUrl?: string;
174
248
  toolCount: number;
175
249
  tools: UiTool[];
250
+ /**
251
+ * Verdict of the last proactive credential liveness check (issue #24), for the
252
+ * connectors that hold a credential connecta stores. Shown beside the live
253
+ * status so an operator can tell "checked just now" from "last verified an
254
+ * hour ago", and see a dead credential the page's own probe may not reach.
255
+ */
256
+ credentialCheck?: {
257
+ state: "ok" | "auth_required" | "error";
258
+ checkedAt: string;
259
+ message?: string;
260
+ };
176
261
  credential?: {
177
262
  label: string;
178
263
  description?: string;
@@ -257,6 +342,7 @@ export async function buildUiData(
257
342
  const connectors = await Promise.all(
258
343
  registry.listConnectors().map(async (c): Promise<UiConnector> => {
259
344
  const status = await registry.statusFor(c.id, baseUrl, requestScope);
345
+ const credentialCheck = await registry.credentialHealthFor(c.id);
260
346
  let tools: UiTool[] = [];
261
347
  // `status()` on an unauthenticated remote connector starts OAuth and
262
348
  // stores its state + PKCE verifier. Probing listTools immediately
@@ -278,6 +364,10 @@ export async function buildUiData(
278
364
  }
279
365
  let credential: UiConnector["credential"];
280
366
  if (c.credential && credentialVault) {
367
+ // One rule, shared with the test route: only the hook matching the
368
+ // declared credential shape can run, so the button is offered only
369
+ // where a click can succeed (src/credentials.ts).
370
+ const testable = credentialTestRule(c).mode !== null;
281
371
  const credentialFields = (
282
372
  metadata?: Awaited<ReturnType<CredentialVault["metadata"]>>,
283
373
  ) =>
@@ -324,7 +414,7 @@ export async function buildUiData(
324
414
  updatedAt: metadata.updatedAt,
325
415
  }
326
416
  : {}),
327
- testable: Boolean(c.testCredential || c.testCredentials),
417
+ testable,
328
418
  };
329
419
  } catch {
330
420
  const fields = credentialFields();
@@ -339,7 +429,7 @@ export async function buildUiData(
339
429
  ...(fields?.length ? { fields } : {}),
340
430
  configured: false,
341
431
  removable: true,
342
- testable: Boolean(c.testCredential || c.testCredentials),
432
+ testable,
343
433
  error: "Stored credential could not be read.",
344
434
  };
345
435
  }
@@ -355,6 +445,17 @@ export async function buildUiData(
355
445
  : {}),
356
446
  toolCount: tools.length,
357
447
  tools,
448
+ ...(credentialCheck
449
+ ? {
450
+ credentialCheck: {
451
+ state: credentialCheck.state,
452
+ checkedAt: credentialCheck.checkedAt,
453
+ ...(credentialCheck.message
454
+ ? { message: credentialCheck.message }
455
+ : {}),
456
+ },
457
+ }
458
+ : {}),
358
459
  ...(credential ? { credential } : {}),
359
460
  };
360
461
  }),
@@ -389,7 +490,35 @@ export function renderUiHtml(
389
490
  branding?: ConnectaBranding,
390
491
  nonce?: string,
391
492
  ): string {
392
- const auth = uiAuth ?? { kind: "bearer" as const };
493
+ const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
494
+ // The Clerk loader's origin. A value that fails the gate is dropped rather
495
+ // than escaped into the page: the loader tag is simply not emitted, the gate
496
+ // reports that Clerk could not load, and the rest of the shell still renders —
497
+ // the same fallback-and-warn posture the branding URLs take, with the drop
498
+ // named in a startup warning (see `droppedUiAuthUrls`).
499
+ const clerkScriptOrigin =
500
+ clerk && isSafeHttpsUrl(clerk.frontendApiUrl)
501
+ ? clerk.frontendApiUrl
502
+ : undefined;
503
+ // Enumerated field by field, because this object is serialized into the page's
504
+ // inline script: a rejected frontendApiUrl must not reach the document through
505
+ // `AUTH` after being kept out of the `<script src>`, and a rejected
506
+ // signInUrl/signUpUrl — which `AUTH` is the only path into the page for — must
507
+ // not reach it at all. Dropping one leaves the key absent, so `Clerk.load`
508
+ // falls back to its own default the same way it does for an unset value.
509
+ const auth = clerk
510
+ ? {
511
+ kind: clerk.kind,
512
+ publishableKey: clerk.publishableKey,
513
+ ...(clerkScriptOrigin ? { frontendApiUrl: clerkScriptOrigin } : {}),
514
+ ...(isSafeHttpsUrl(clerk.signInUrl)
515
+ ? { signInUrl: clerk.signInUrl }
516
+ : {}),
517
+ ...(isSafeHttpsUrl(clerk.signUpUrl)
518
+ ? { signUpUrl: clerk.signUpUrl }
519
+ : {}),
520
+ }
521
+ : (uiAuth ?? { kind: "bearer" as const });
393
522
  const brand = resolveBranding(branding);
394
523
  const title = brand.pageTitle;
395
524
  // When the /ui response ships a nonce-based CSP, every <script> it emits must
@@ -411,8 +540,8 @@ export function renderUiHtml(
411
540
  : `<span class="product">${escapeHtmlAttr(brand.productName)}</span>`
412
541
  : "";
413
542
  const clerkScript =
414
- uiAuth?.kind === "clerk"
415
- ? `<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>`
543
+ clerk && clerkScriptOrigin
544
+ ? `<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>`
416
545
  : "";
417
546
 
418
547
  return `<!doctype html>
@@ -991,6 +1120,19 @@ function render() {
991
1120
  if (c.message) {
992
1121
  head += '<p class="connector-message msg">' + esc(c.message) + "</p>";
993
1122
  }
1123
+ if (c.credentialCheck) {
1124
+ const check = c.credentialCheck;
1125
+ const verdict = check.state === "ok"
1126
+ ? "credential verified"
1127
+ : check.state === "auth_required"
1128
+ ? "credential needs authorization"
1129
+ : "credential check failed";
1130
+ head += '<p class="connector-check meta">Credential check: ' +
1131
+ esc(verdict) + " · " + esc(formatDate(check.checkedAt)) +
1132
+ (check.message && check.message !== c.message
1133
+ ? " — " + esc(check.message)
1134
+ : "") + "</p>";
1135
+ }
994
1136
  if (c.authorizationUrl) {
995
1137
  const safe = safeHttp(c.authorizationUrl);
996
1138
  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.5.0";
7
+ export const CONNECTA_VERSION = "0.6.1";