@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.
- package/CHANGELOG.md +147 -0
- package/README.md +159 -308
- package/dist/auth/clerk.d.ts +2 -2
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/credential-health.d.ts +13 -5
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +32 -16
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +35 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +42 -0
- package/dist/credentials.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -11
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +13 -3
- package/dist/server.js.map +1 -1
- package/dist/skills.js +2 -2
- package/dist/skills.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +30 -13
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +77 -30
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/auth/clerk.ts +2 -2
- package/src/credential-health.ts +35 -18
- package/src/credentials.ts +71 -1
- package/src/index.ts +37 -11
- package/src/server.ts +17 -4
- package/src/skills.ts +2 -2
- package/src/types.ts +11 -0
- package/src/ui.ts +78 -31
- package/src/version.ts +1 -1
package/src/credential-health.ts
CHANGED
|
@@ -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
|
|
52
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
363
|
-
*
|
|
364
|
-
* `
|
|
365
|
-
*
|
|
366
|
-
* `
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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
|
-
|
|
388
|
+
const { mode } = credentialTestRule(connector);
|
|
389
|
+
if (mode === "multiple") {
|
|
376
390
|
return (ctx) => connector.testCredentials!(values, ctx);
|
|
377
391
|
}
|
|
378
|
-
|
|
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:
|
|
643
|
-
*
|
|
644
|
-
* back on. Neither ⇒ there is no
|
|
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,
|
package/src/credentials.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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 {
|
|
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
|
|
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
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
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 defaults — both 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:
|
|
345
|
-
"
|
|
346
|
-
"
|
|
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
|
-
|
|
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
|
-
{
|
|
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 (
|
|
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
|
-
...(
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
|
170
|
-
*
|
|
171
|
-
* derives it from the publishable key and Clerk's Frontend API is
|
|
172
|
-
*
|
|
173
|
-
* browser's mixed-content rules had not already blocked
|
|
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
|
|
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 — `
|
|
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
|
|
195
|
-
? []
|
|
196
|
-
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
472
|
-
|
|
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