@zackbart/connecta 0.5.0 → 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.
- package/CHANGELOG.md +358 -0
- package/README.md +53 -12
- package/dist/auth/bearer.d.ts +10 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +21 -0
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +26 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +161 -4
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +8 -0
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +212 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +535 -0
- package/dist/credential-health.js.map +1 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +16 -4
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +46 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +96 -13
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +56 -5
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +249 -92
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +62 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +85 -1
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +292 -37
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +1 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +1 -1
- package/dist/timeout.d.ts +16 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/timeout.js +38 -0
- package/dist/timeout.js.map +1 -0
- package/dist/toolkits.d.ts +95 -1
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +190 -5
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +35 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +87 -3
- 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/bearer.ts +35 -1
- package/src/auth/clerk.ts +202 -5
- package/src/connectors/remote-mcp.ts +9 -0
- package/src/credential-health.ts +736 -0
- package/src/execute.ts +28 -4
- package/src/index.ts +176 -20
- package/src/meta-tools.ts +286 -109
- package/src/registry.ts +125 -1
- package/src/server.ts +349 -34
- package/src/skills.ts +1 -1
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +241 -6
- package/src/types.ts +76 -1
- package/src/ui.ts +98 -3
- 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
|
|
8
|
-
//
|
|
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
|
|
125
|
-
* identity
|
|
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,15 +252,69 @@ 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
|
-
| {
|
|
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;
|
|
252
319
|
signInUrl?: string;
|
|
253
320
|
signUpUrl?: string;
|
|
@@ -300,6 +367,14 @@ export interface InboundAuth {
|
|
|
300
367
|
* provider instead of asking the operator to paste a static bearer secret.
|
|
301
368
|
*/
|
|
302
369
|
uiAuth?: UiAuthConfig;
|
|
370
|
+
/**
|
|
371
|
+
* Optional toolkit binding for every identity this provider admits (§16).
|
|
372
|
+
* Declared statically so `createConnecta` can validate the names against
|
|
373
|
+
* `ConnectaConfig.toolkits` and throw on a typo — a binding nobody wrote is
|
|
374
|
+
* not one an operator can reason about. An `authorize` result may narrow it
|
|
375
|
+
* per identity with its own `toolkitBinding`.
|
|
376
|
+
*/
|
|
377
|
+
toolkitBinding?: ToolkitBinding;
|
|
303
378
|
/** Serve/short-circuit .well-known + OPTIONS. Return null when not handled. */
|
|
304
379
|
handleMetadata?(
|
|
305
380
|
request: Request,
|
package/src/ui.ts
CHANGED
|
@@ -158,6 +158,44 @@ export function isSafeIconHref(href: unknown): boolean {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
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
|
+
|
|
161
199
|
export interface UiTool {
|
|
162
200
|
name: string;
|
|
163
201
|
address: string;
|
|
@@ -173,6 +211,17 @@ export interface UiConnector {
|
|
|
173
211
|
authorizationUrl?: string;
|
|
174
212
|
toolCount: number;
|
|
175
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
|
+
};
|
|
176
225
|
credential?: {
|
|
177
226
|
label: string;
|
|
178
227
|
description?: string;
|
|
@@ -257,6 +306,7 @@ export async function buildUiData(
|
|
|
257
306
|
const connectors = await Promise.all(
|
|
258
307
|
registry.listConnectors().map(async (c): Promise<UiConnector> => {
|
|
259
308
|
const status = await registry.statusFor(c.id, baseUrl, requestScope);
|
|
309
|
+
const credentialCheck = await registry.credentialHealthFor(c.id);
|
|
260
310
|
let tools: UiTool[] = [];
|
|
261
311
|
// `status()` on an unauthenticated remote connector starts OAuth and
|
|
262
312
|
// stores its state + PKCE verifier. Probing listTools immediately
|
|
@@ -355,6 +405,17 @@ export async function buildUiData(
|
|
|
355
405
|
: {}),
|
|
356
406
|
toolCount: tools.length,
|
|
357
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
|
+
: {}),
|
|
358
419
|
...(credential ? { credential } : {}),
|
|
359
420
|
};
|
|
360
421
|
}),
|
|
@@ -389,7 +450,28 @@ export function renderUiHtml(
|
|
|
389
450
|
branding?: ConnectaBranding,
|
|
390
451
|
nonce?: string,
|
|
391
452
|
): string {
|
|
392
|
-
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 });
|
|
393
475
|
const brand = resolveBranding(branding);
|
|
394
476
|
const title = brand.pageTitle;
|
|
395
477
|
// When the /ui response ships a nonce-based CSP, every <script> it emits must
|
|
@@ -411,8 +493,8 @@ export function renderUiHtml(
|
|
|
411
493
|
: `<span class="product">${escapeHtmlAttr(brand.productName)}</span>`
|
|
412
494
|
: "";
|
|
413
495
|
const clerkScript =
|
|
414
|
-
|
|
415
|
-
? `<script${nonceAttr} defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(
|
|
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>`
|
|
416
498
|
: "";
|
|
417
499
|
|
|
418
500
|
return `<!doctype html>
|
|
@@ -991,6 +1073,19 @@ function render() {
|
|
|
991
1073
|
if (c.message) {
|
|
992
1074
|
head += '<p class="connector-message msg">' + esc(c.message) + "</p>";
|
|
993
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
|
+
}
|
|
994
1089
|
if (c.authorizationUrl) {
|
|
995
1090
|
const safe = safeHttp(c.authorizationUrl);
|
|
996
1091
|
head += safe
|
package/src/version.ts
CHANGED