@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.
- package/CHANGELOG.md +505 -0
- package/README.md +159 -267
- 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 +28 -3
- 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 +220 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +551 -0
- package/dist/credential-health.js.map +1 -0
- 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/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 +118 -15
- 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 +305 -40
- 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 +3 -3
- package/dist/skills.js.map +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 +81 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +52 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +144 -13
- 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 +204 -7
- package/src/connectors/remote-mcp.ts +9 -0
- package/src/credential-health.ts +753 -0
- package/src/credentials.ts +71 -1
- package/src/execute.ts +28 -4
- package/src/index.ts +204 -22
- package/src/meta-tools.ts +286 -109
- package/src/registry.ts +125 -1
- package/src/server.ts +366 -38
- package/src/skills.ts +3 -3
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +241 -6
- package/src/types.ts +87 -1
- package/src/ui.ts +156 -14
- 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,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
|
-
| {
|
|
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
|
-
...(
|
|
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
|
: []),
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
415
|
-
? `<script${nonceAttr} defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(
|
|
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