@zackbart/connecta 0.6.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.
@@ -15,6 +15,7 @@
15
15
  // There is no background daemon and no long-lived timer, so Workers and Node run
16
16
  // the same code.
17
17
 
18
+ import { credentialTestRule } from "./credentials.js";
18
19
  import type { CredentialVault } from "./credentials.js";
19
20
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, withTimeout } from "./timeout.js";
20
21
  import type {
@@ -48,8 +49,9 @@ export interface CredentialHealthRecord {
48
49
  * through an explicit `ids` request, and reported rather than dropped so a
49
50
  * typo in a scheduled check is visible instead of silent.
50
51
  * - `not_checkable` — it stores no credential connecta manages, or exposes no
51
- * usable way to ask: neither `status()` nor a credential test hook that fits
52
- * the stored value's shape.
52
+ * usable way to ask: neither `status()` nor a credential test hook the
53
+ * declared credential shape can use (`credentialTestRule`), against a value
54
+ * actually stored under it.
53
55
  * - `no_credential` — checkable, but nothing is stored yet: there is no
54
56
  * credential whose liveness could be in question, and probing would start an
55
57
  * OAuth flow nobody asked for.
@@ -342,40 +344,54 @@ export interface CredentialCheckOptions {
342
344
  * to be asked about: an operator-managed `credential`, or a stored downstream
343
345
  * grant it reports via `hasStoredCredential`. A static-token connector stores
344
346
  * nothing here and is never probed on a timer.
347
+ *
348
+ * Whether a test hook counts is `credentialTestRule`'s call, not this function's
349
+ * — the same rule /ui's Test button and the credential API read (issue #55), so
350
+ * a credential the operator cannot test by hand is not one a sweep tests behind
351
+ * their back. A connector whose only hook cannot test its declared shape is
352
+ * checkable only if it also implements `status()`.
345
353
  */
346
354
  export function isCheckableConnector(connector: Connector): boolean {
347
355
  const hasCredentialStore = Boolean(
348
356
  connector.credential || connector.hasStoredCredential,
349
357
  );
350
358
  const canAsk = Boolean(
351
- (connector.credential &&
352
- (connector.testCredentials || connector.testCredential)) ||
353
- connector.status,
359
+ credentialTestRule(connector).mode !== null || connector.status,
354
360
  );
355
361
  return hasCredentialStore && canAsk;
356
362
  }
357
363
 
358
364
  /**
359
- * The credential test that fits the STORED value's shape, or undefined.
365
+ * The credential test the connector's DECLARED shape selects, bound to what is
366
+ * actually stored — or undefined when there is no honest question to put.
360
367
  *
361
368
  * `isCheckableConnector` answers the static question ("could this connector be
362
- * asked at all"); this answers it against what is actually in the vault. The gap
363
- * that matters is a connector with named fields but only the single-value
364
- * `testCredential` hook: handing it `values.value` the reserved single-value
365
- * field, absent here would test the empty string and record a confident
366
- * `auth_required` about a credential nothing examined. The credential API
367
- * refuses that same shape with a 409 rather than testing it; here the connector
368
- * is skipped rather than given an invented verdict.
369
+ * asked at all"); this answers it against the vault. The hook itself is picked
370
+ * by `credentialTestRule` (src/credentials.ts, issue #55), the one rule /ui's
371
+ * `testable` flag and `POST /ui/credentials/<id>/test` also read: named
372
+ * `credential.fields` are tested as a set by `testCredentials`, a single-value
373
+ * `credential` by `testCredential` on the vault's reserved `value` field, and
374
+ * the other hook is never substituted. Substituting it is what a sweep must not
375
+ * do quietly handing `testCredential` a `values.value` that named fields never
376
+ * wrote would test the empty string and record a confident `auth_required` about
377
+ * a credential nothing examined, and handing `testCredentials` the reserved
378
+ * `{ value }` map would call a hook with a shape its connector never declared.
379
+ * Either way the connector is skipped (`not_checkable`) rather than given an
380
+ * invented verdict, and `createConnecta` already warned about the mismatch at
381
+ * construction.
369
382
  */
370
383
  function testHookFor(
371
384
  connector: Connector,
372
385
  values: ConnectorCredentialValues | null,
373
386
  ): ((ctx: ConnectorContext) => Promise<CredentialTestResult>) | undefined {
374
387
  if (!values) return undefined;
375
- if (connector.testCredentials) {
388
+ const { mode } = credentialTestRule(connector);
389
+ if (mode === "multiple") {
376
390
  return (ctx) => connector.testCredentials!(values, ctx);
377
391
  }
378
- if (connector.testCredential && typeof values.value === "string") {
392
+ // A single-value shape with nothing under the reserved field is still nothing
393
+ // to test, so the stored value gets the last word even when the rule fits.
394
+ if (mode === "single" && typeof values.value === "string") {
379
395
  return (ctx) => connector.testCredential!(values.value, ctx);
380
396
  }
381
397
  return undefined;
@@ -639,9 +655,10 @@ export class CredentialHealthChecker {
639
655
  }
640
656
 
641
657
  /**
642
- * `isCheckableConnector` re-asked against what is actually stored: a hook that
643
- * fits the value's shape (see {@link testHookFor}), or a `status()` to fall
644
- * back on. Neither ⇒ there is no honest question to put to this connector.
658
+ * `isCheckableConnector` re-asked against what is actually stored: the hook
659
+ * the declared shape selects, bound to a value that fits it (see
660
+ * {@link testHookFor}), or a `status()` to fall back on. Neither ⇒ there is no
661
+ * honest question to put to this connector.
645
662
  */
646
663
  private canAsk(
647
664
  connector: Connector,
@@ -1,4 +1,8 @@
1
- import type { ConnectorCredentialValues, KVStorage } from "./types.js";
1
+ import type {
2
+ Connector,
3
+ ConnectorCredentialValues,
4
+ KVStorage,
5
+ } from "./types.js";
2
6
 
3
7
  const KEY_BYTES = 32;
4
8
  const IV_BYTES = 12;
@@ -35,6 +39,72 @@ export interface CredentialMetadata {
35
39
  fields?: Record<string, CredentialFieldMetadata>;
36
40
  }
37
41
 
42
+ /** Which hook a testable credential is checked with. */
43
+ export type CredentialTestMode = "single" | "multiple";
44
+
45
+ /** A declared credential shape whose only test hook cannot test it. */
46
+ export interface CredentialTestMismatch {
47
+ /** The shape the connector declared. */
48
+ shape: CredentialTestMode;
49
+ /** The hook it implements, which that shape cannot use. */
50
+ hook: "testCredential" | "testCredentials";
51
+ }
52
+
53
+ export interface CredentialTestRule {
54
+ /** The hook to call, or null when this credential cannot be tested at all. */
55
+ mode: CredentialTestMode | null;
56
+ /** Set only when the sole implemented hook is the one the shape cannot use. */
57
+ mismatch?: CredentialTestMismatch;
58
+ }
59
+
60
+ /**
61
+ * The one rule deciding whether a connector's credential can be tested — read
62
+ * by /ui's `testable` flag, by the `POST /ui/credentials/<id>/test` route when
63
+ * it picks a hook, and by the construction-time mismatch warning, so those
64
+ * three cannot drift apart.
65
+ *
66
+ * The declared credential *shape* selects the hook: named `credential.fields`
67
+ * are tested as a set by `testCredentials`, a single-value `credential` by
68
+ * `testCredential` on the vault's reserved `value` field. The other hook is
69
+ * never substituted — it would be handed a shape the connector never declared —
70
+ * so a connector implementing only the mismatched hook is not testable, and
71
+ * says so at construction rather than under an operator's click.
72
+ */
73
+ export function credentialTestRule(
74
+ connector: Pick<
75
+ Connector,
76
+ "credential" | "testCredential" | "testCredentials"
77
+ >,
78
+ ): CredentialTestRule {
79
+ if (!connector.credential) return { mode: null };
80
+ if (connector.credential.fields?.length) {
81
+ if (connector.testCredentials) return { mode: "multiple" };
82
+ return connector.testCredential
83
+ ? { mode: null, mismatch: { shape: "multiple", hook: "testCredential" } }
84
+ : { mode: null };
85
+ }
86
+ if (connector.testCredential) return { mode: "single" };
87
+ return connector.testCredentials
88
+ ? { mode: null, mismatch: { shape: "single", hook: "testCredentials" } }
89
+ : { mode: null };
90
+ }
91
+
92
+ /**
93
+ * One clause naming a mismatch, shared by the startup warning and the test
94
+ * route's 400 so an operator reads the same explanation in both places.
95
+ */
96
+ export function describeCredentialTestMismatch(
97
+ mismatch: CredentialTestMismatch,
98
+ ): string {
99
+ return mismatch.shape === "multiple"
100
+ ? "it declares named credential fields, which only " +
101
+ "`testCredentials(values, ctx)` can test, but implements " +
102
+ "`testCredential`"
103
+ : "it declares a single-value credential, which only " +
104
+ "`testCredential(value, ctx)` can test, but implements " +
105
+ "`testCredentials`";
106
+ }
107
+
38
108
  function storageKey(connectorId: string): string {
39
109
  return `conn:${connectorId}:credential:v1`;
40
110
  }
package/src/index.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { CredentialVault } from "./credentials.js";
1
+ import {
2
+ CredentialVault,
3
+ credentialTestRule,
4
+ describeCredentialTestMismatch,
5
+ } from "./credentials.js";
2
6
  import { Registry } from "./registry.js";
3
7
  import { createFetchHandler } from "./server.js";
4
8
  import { droppedBrandingUrls, droppedUiAuthUrls } from "./ui.js";
@@ -222,7 +226,8 @@ function normalizeAuth(auth: ConnectaConfig["auth"]): InboundAuth[] {
222
226
  /**
223
227
  * One-time construction warnings for deployment shapes that run fine but are
224
228
  * usually unintended. Warning-only — never throws and never changes behavior;
225
- * each condition emits at most one `logger.warn`. Iterates connectors once.
229
+ * each deployment-wide condition emits at most one `logger.warn`, and each
230
+ * per-connector condition at most one per connector it names.
226
231
  */
227
232
  function warnInsecureConfig(
228
233
  config: ConnectaConfig,
@@ -330,20 +335,41 @@ function warnInsecureConfig(
330
335
 
331
336
  // /ui renders exactly one provider's browser sign-in config — the first that
332
337
  // offers one, which is the same `find` the /ui route performs — and that
333
- // provider's frontendApiUrl becomes the loader's `<script src>`. Gate-or-drop
334
- // like a branding href: rendering omits the loader for a rejected value and
335
- // the dashboard then reports that Clerk could not load, a confusing symptom
336
- // without this line naming the cause. Checking only the rendered provider
337
- // keeps the claim true a later provider's uiAuth never reaches the page, so
338
- // there is nothing there to warn about.
338
+ // provider's URLs reach the browser: frontendApiUrl as the loader's
339
+ // `<script src>`, signInUrl/signUpUrl as the addresses ClerkJS navigates to.
340
+ // Gate-or-drop like a branding href: rendering drops a rejected value and the
341
+ // dashboard then either reports that Clerk could not load or quietly signs in
342
+ // through Clerk's defaultsboth confusing symptoms without this line naming
343
+ // the cause. Checking only the rendered provider keeps the claim true — a
344
+ // later provider's uiAuth never reaches the page, so there is nothing there
345
+ // to warn about.
339
346
  const uiAuthProvider = inboundAuth.find((provider) => provider.uiAuth);
340
347
  const droppedUiAuth = droppedUiAuthUrls(uiAuthProvider?.uiAuth);
341
348
  if (uiAuthProvider && droppedUiAuth.length > 0) {
342
349
  logger.warn(
343
350
  `[connecta] inbound auth provider "${uiAuthProvider.kind}" had ` +
344
- `${droppedUiAuth.join(", ")} dropped: the browser sign-in loader is ` +
345
- "fetched from this origin, so it must be an absolute https URL. /ui " +
346
- "renders without the loader and cannot start a sign-in.",
351
+ `${droppedUiAuth.join(", ")} dropped: every uiAuth URL reaches the ` +
352
+ "browser as the sign-in loader's source, or as a place Clerk sends " +
353
+ "the operator so each must be an absolute https URL. A dropped " +
354
+ "value reaches no part of the page: without frontendApiUrl /ui renders " +
355
+ "no loader and cannot start a sign-in, and without signInUrl/signUpUrl " +
356
+ "it signs in through Clerk's defaults.",
357
+ );
358
+ }
359
+
360
+ // A credential test hook that cannot test the declared credential shape.
361
+ // The shape picks the hook (see `credentialTestRule`) and the other one is
362
+ // never substituted, so the connector is simply not testable: /ui offers no
363
+ // Test action and the route answers 400. Without this line the only way to
364
+ // discover the mistake is to click a button that isn't there.
365
+ for (const connector of config.connectors) {
366
+ const { mismatch } = credentialTestRule(connector);
367
+ if (!mismatch) continue;
368
+ logger.warn(
369
+ `[connecta] connector "${connector.id}" cannot test its credential: ` +
370
+ `${describeCredentialTestMismatch(mismatch)}. /ui offers no Test ` +
371
+ `action and POST /ui/credentials/${connector.id}/test answers 400 ` +
372
+ "until the matching hook is implemented.",
347
373
  );
348
374
  }
349
375
 
package/src/server.ts CHANGED
@@ -10,6 +10,10 @@ import type {
10
10
  ActivityStore,
11
11
  } from "./activity.js";
12
12
  import { InvalidActivityCursorError } from "./activity.js";
13
+ import {
14
+ credentialTestRule,
15
+ describeCredentialTestMismatch,
16
+ } from "./credentials.js";
13
17
  import type { CredentialVault } from "./credentials.js";
14
18
  import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
15
19
  import {
@@ -546,16 +550,25 @@ async function handleCredentialRequest(
546
550
  if (request.method !== "POST") {
547
551
  return privateJson({ error: "method not allowed" }, { status: 405 });
548
552
  }
549
- if (!connector.testCredential && !connector.testCredentials) {
553
+ // The declared credential shape picks the hook — the same single rule /ui
554
+ // asks for its Test affordance, so a shown button always reaches a hook
555
+ // that reads the shape the credential was stored in.
556
+ const rule = credentialTestRule(connector);
557
+ if (!rule.mode) {
550
558
  return privateJson(
551
- { error: "this connector does not support credential testing" },
559
+ {
560
+ error: rule.mismatch
561
+ ? "this connector cannot test its credential: " +
562
+ describeCredentialTestMismatch(rule.mismatch)
563
+ : "this connector does not support credential testing",
564
+ },
552
565
  { status: 400 },
553
566
  );
554
567
  }
555
568
  try {
556
569
  const ctx = opts.registry.contextFor(connectorId, baseUrl);
557
570
  let result;
558
- if (connector.testCredentials) {
571
+ if (rule.mode === "multiple") {
559
572
  const values = await opts.credentialVault.getAll(connectorId);
560
573
  if (!values) {
561
574
  return privateJson(
@@ -563,7 +576,7 @@ async function handleCredentialRequest(
563
576
  { status: 409 },
564
577
  );
565
578
  }
566
- result = await connector.testCredentials(values, ctx);
579
+ result = await connector.testCredentials!(values, ctx);
567
580
  } else {
568
581
  const value = await opts.credentialVault.get(connectorId);
569
582
  if (!value) {
package/src/skills.ts CHANGED
@@ -235,12 +235,12 @@ export function resolveSkill(
235
235
  return {
236
236
  found: false,
237
237
  message: connectorGuide(bare)
238
- ? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available: ${available()}.`
238
+ ? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available skills: ${available()}.`
239
239
  : `Connector "${name}" has no usage guide. Available skills: ${available()}.`,
240
240
  };
241
241
  }
242
242
  return {
243
243
  found: false,
244
- message: `Unknown skill "${name}". Available: ${available()}.`,
244
+ message: `Unknown skill "${name}". Available skills: ${available()}.`,
245
245
  };
246
246
  }
package/src/types.ts CHANGED
@@ -316,7 +316,18 @@ export type UiAuthConfig = {
316
316
  * warning.
317
317
  */
318
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
+ */
319
329
  signInUrl?: string;
330
+ /** Hosted Account Portal sign-up address. Gated exactly like `signInUrl`. */
320
331
  signUpUrl?: string;
321
332
  };
322
333
 
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
  : []),
@@ -159,20 +172,26 @@ export function isSafeIconHref(href: unknown): boolean {
159
172
  }
160
173
 
161
174
  /**
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.
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.
168
181
  *
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.
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.
174
193
  */
175
- export function isSafeScriptSrcUrl(url: unknown): boolean {
194
+ export function isSafeHttpsUrl(url: unknown): boolean {
176
195
  if (typeof url !== "string") return false;
177
196
  try {
178
197
  return new URL(url).protocol === "https:";
@@ -186,14 +205,31 @@ export function isSafeScriptSrcUrl(url: unknown): boolean {
186
205
  * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
187
206
  * startup warning cannot then drift from what rendering actually drops. Every
188
207
  * field is read defensively rather than trusted, because a custom `InboundAuth`
189
- * is untyped at a JS call site — `isSafeScriptSrcUrl` takes `unknown`, and a
208
+ * is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
190
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.
191
221
  */
192
222
  export function droppedUiAuthUrls(uiAuth?: UiAuthConfig): string[] {
193
223
  if (!uiAuth || uiAuth.kind !== "clerk") return [];
194
- return isSafeScriptSrcUrl(uiAuth.frontendApiUrl)
195
- ? []
196
- : ["uiAuth.frontendApiUrl"];
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
+ ];
197
233
  }
198
234
 
199
235
  export interface UiTool {
@@ -328,6 +364,10 @@ export async function buildUiData(
328
364
  }
329
365
  let credential: UiConnector["credential"];
330
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;
331
371
  const credentialFields = (
332
372
  metadata?: Awaited<ReturnType<CredentialVault["metadata"]>>,
333
373
  ) =>
@@ -374,7 +414,7 @@ export async function buildUiData(
374
414
  updatedAt: metadata.updatedAt,
375
415
  }
376
416
  : {}),
377
- testable: Boolean(c.testCredential || c.testCredentials),
417
+ testable,
378
418
  };
379
419
  } catch {
380
420
  const fields = credentialFields();
@@ -389,7 +429,7 @@ export async function buildUiData(
389
429
  ...(fields?.length ? { fields } : {}),
390
430
  configured: false,
391
431
  removable: true,
392
- testable: Boolean(c.testCredential || c.testCredentials),
432
+ testable,
393
433
  error: "Stored credential could not be read.",
394
434
  };
395
435
  }
@@ -457,19 +497,26 @@ export function renderUiHtml(
457
497
  // the same fallback-and-warn posture the branding URLs take, with the drop
458
498
  // named in a startup warning (see `droppedUiAuthUrls`).
459
499
  const clerkScriptOrigin =
460
- clerk && isSafeScriptSrcUrl(clerk.frontendApiUrl)
500
+ clerk && isSafeHttpsUrl(clerk.frontendApiUrl)
461
501
  ? clerk.frontendApiUrl
462
502
  : undefined;
463
503
  // Enumerated field by field, because this object is serialized into the page's
464
504
  // inline script: a rejected frontendApiUrl must not reach the document through
465
- // `AUTH` after being kept out of the `<script src>`.
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.
466
509
  const auth = clerk
467
510
  ? {
468
511
  kind: clerk.kind,
469
512
  publishableKey: clerk.publishableKey,
470
513
  ...(clerkScriptOrigin ? { frontendApiUrl: clerkScriptOrigin } : {}),
471
- ...(clerk.signInUrl ? { signInUrl: clerk.signInUrl } : {}),
472
- ...(clerk.signUpUrl ? { signUpUrl: clerk.signUpUrl } : {}),
514
+ ...(isSafeHttpsUrl(clerk.signInUrl)
515
+ ? { signInUrl: clerk.signInUrl }
516
+ : {}),
517
+ ...(isSafeHttpsUrl(clerk.signUpUrl)
518
+ ? { signUpUrl: clerk.signUpUrl }
519
+ : {}),
473
520
  }
474
521
  : (uiAuth ?? { kind: "bearer" as const });
475
522
  const brand = resolveBranding(branding);
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.6.0";
7
+ export const CONNECTA_VERSION = "0.6.1";