@zackbart/connecta 0.6.1 → 0.7.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 +294 -0
- package/README.md +24 -20
- package/dist/auth/bearer.d.ts +4 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +10 -8
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +8 -7
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +27 -8
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connector-scope.d.ts +13 -0
- package/dist/connector-scope.d.ts.map +1 -0
- package/dist/connector-scope.js +35 -0
- package/dist/connector-scope.js.map +1 -0
- package/dist/connectors/api.d.ts +5 -5
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +27 -4
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +400 -19
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +8 -5
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +99 -51
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +51 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +68 -3
- package/dist/credentials.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +10 -8
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +84 -83
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +74 -24
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +28 -3
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +131 -16
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +3 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -3
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +152 -52
- package/dist/server.js.map +1 -1
- package/dist/toolkits.js +1 -1
- package/dist/types.d.ts +41 -27
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +24 -10
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +594 -172
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/auth/bearer.ts +10 -8
- package/src/auth/clerk.ts +28 -9
- package/src/connector-scope.ts +41 -0
- package/src/connectors/api.ts +5 -5
- package/src/connectors/remote-mcp.ts +489 -35
- package/src/credential-health.ts +120 -57
- package/src/credentials.ts +96 -3
- package/src/execute.ts +18 -7
- package/src/index.ts +174 -107
- package/src/meta-tools.ts +173 -24
- package/src/registry.ts +4 -3
- package/src/server.ts +191 -70
- package/src/toolkits.ts +1 -1
- package/src/types.ts +41 -27
- package/src/ui.ts +631 -170
- package/src/version.ts +1 -1
package/src/registry.ts
CHANGED
|
@@ -33,7 +33,7 @@ export const MIN_MAX_RESULT_BYTES = 1;
|
|
|
33
33
|
/**
|
|
34
34
|
* The one definition of a usable `maxResultBytes`: a finite whole number of at
|
|
35
35
|
* least {@link MIN_MAX_RESULT_BYTES} bytes. Shared by all three intake points
|
|
36
|
-
* —
|
|
36
|
+
* — `calls.maxResultBytes`, the per-connector override, and `get_result`'s
|
|
37
37
|
* `maxBytes` argument — so a value that is valid at one is valid at all.
|
|
38
38
|
*
|
|
39
39
|
* Everything else is rejected rather than coerced, because each rejected shape
|
|
@@ -286,7 +286,7 @@ export class Registry implements RegistryView {
|
|
|
286
286
|
): void {
|
|
287
287
|
if (configured !== undefined && !isValidMaxResultBytes(configured)) {
|
|
288
288
|
logger.warn(
|
|
289
|
-
`[connecta] maxResultBytes ${configured} is not a whole number of ` +
|
|
289
|
+
`[connecta] calls.maxResultBytes ${configured} is not a whole number of ` +
|
|
290
290
|
`bytes >= ${MIN_MAX_RESULT_BYTES}: it would serve an empty, ` +
|
|
291
291
|
"oversized, or unguarded result instead of truncating. Using the " +
|
|
292
292
|
`default ${DEFAULT_MAX_RESULT_BYTES} instead.`,
|
|
@@ -612,7 +612,8 @@ export class Registry implements RegistryView {
|
|
|
612
612
|
|
|
613
613
|
/**
|
|
614
614
|
* Drop a connector's liveness verdict, because its credential just changed
|
|
615
|
-
* under us (OAuth callback completed, credential stored or removed
|
|
615
|
+
* under us (OAuth callback completed, credential stored or removed on
|
|
616
|
+
* /credentials). A
|
|
616
617
|
* stale `auth_required` must not outlive the re-authorization that fixed it —
|
|
617
618
|
* that is the difference between recovery working and needing a restart.
|
|
618
619
|
*/
|
package/src/server.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { InvalidActivityCursorError } from "./activity.js";
|
|
|
13
13
|
import {
|
|
14
14
|
credentialTestRule,
|
|
15
15
|
describeCredentialTestMismatch,
|
|
16
|
+
storedCredentialShape,
|
|
16
17
|
} from "./credentials.js";
|
|
17
18
|
import type { CredentialVault } from "./credentials.js";
|
|
18
19
|
import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
type Toolkit,
|
|
23
24
|
} from "./toolkits.js";
|
|
24
25
|
import type {
|
|
26
|
+
ConnectorContext,
|
|
25
27
|
ConnectorCredentialConfig,
|
|
26
28
|
ConnectorCredentialValues,
|
|
27
29
|
ConnectaBranding,
|
|
@@ -34,6 +36,8 @@ import { CONNECTA_FAVICON_ICO } from "./favicon.js";
|
|
|
34
36
|
import {
|
|
35
37
|
buildUiData,
|
|
36
38
|
CONNECTA_FAVICON_SVG,
|
|
39
|
+
credentialManagementCapability,
|
|
40
|
+
operatorPageForPath,
|
|
37
41
|
resolveBranding,
|
|
38
42
|
renderUiHtml,
|
|
39
43
|
} from "./ui.js";
|
|
@@ -92,7 +96,7 @@ export interface ServerOptions {
|
|
|
92
96
|
probeTimeoutMs?: number;
|
|
93
97
|
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
94
98
|
executor?: Executor;
|
|
95
|
-
/** Encrypted connector-credential storage backing the
|
|
99
|
+
/** Encrypted connector-credential storage backing the Credentials page. */
|
|
96
100
|
credentialVault?: CredentialVault;
|
|
97
101
|
/** Optional browser UI and OAuth result-page labels. */
|
|
98
102
|
branding?: ConnectaBranding;
|
|
@@ -107,7 +111,7 @@ function msg(err: unknown): string {
|
|
|
107
111
|
return err instanceof Error ? err.message : String(err);
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
/** Per-request base64 nonce for
|
|
114
|
+
/** Per-request base64 nonce for an operator shell's scripts (Node 20+ and Workers). */
|
|
111
115
|
function uiScriptNonce(): string {
|
|
112
116
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
113
117
|
let binary = "";
|
|
@@ -190,7 +194,7 @@ function html(
|
|
|
190
194
|
<h1>Connection status</h1>
|
|
191
195
|
<div class="copy">
|
|
192
196
|
<p>${escapeHtml(body)}</p>
|
|
193
|
-
<p><a href="/
|
|
197
|
+
<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>
|
|
194
198
|
</div>
|
|
195
199
|
</main>
|
|
196
200
|
</body>
|
|
@@ -220,7 +224,7 @@ async function authorize(
|
|
|
220
224
|
actor: ActivityActor;
|
|
221
225
|
providerKind?: string;
|
|
222
226
|
userId?: string;
|
|
223
|
-
/** The admitting identity's toolkit binding
|
|
227
|
+
/** The admitting identity's toolkit binding (docs/toolkits.md). */
|
|
224
228
|
toolkitBinding?: ToolkitBinding;
|
|
225
229
|
}
|
|
226
230
|
| { ok: false; response: Response }
|
|
@@ -316,10 +320,10 @@ function withSecurityHeaders(
|
|
|
316
320
|
if (requestUrl.protocol === "https:") {
|
|
317
321
|
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
318
322
|
}
|
|
319
|
-
if (path === "/ui") {
|
|
320
|
-
//
|
|
321
|
-
// includes frame-ancestors 'none'); only fall back to the
|
|
322
|
-
// directive when no CSP is present (
|
|
323
|
+
if (operatorPageForPath(path) || path === "/ui") {
|
|
324
|
+
// Operator HTML responses ship their own nonce-based script CSP (which
|
|
325
|
+
// already includes frame-ancestors 'none'); only fall back to the
|
|
326
|
+
// framing-only directive when no CSP is present (for example redirects).
|
|
323
327
|
if (!headers.has("Content-Security-Policy")) {
|
|
324
328
|
headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
325
329
|
}
|
|
@@ -344,11 +348,11 @@ async function authorizeUiAdmin(
|
|
|
344
348
|
//
|
|
345
349
|
// EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
|
|
346
350
|
// documented per-team pattern is several `clerkAuth(...)`s that differ only in
|
|
347
|
-
// `gate` and `toolkits` (
|
|
348
|
-
// depend on config order: the team-bound provider listed first
|
|
349
|
-
// the operator outright, and a refusal here — a failed gate, a
|
|
350
|
-
// toolkit-bound identity — is exactly the case where a later
|
|
351
|
-
// one meant to admit. The last refusal is returned if none do.
|
|
351
|
+
// `gate` and `toolkits` (docs/toolkits.md). Stopping at the first would make
|
|
352
|
+
// admission depend on config order: the team-bound provider listed first
|
|
353
|
+
// would refuse the operator outright, and a refusal here — a failed gate, a
|
|
354
|
+
// missing user, a toolkit-bound identity — is exactly the case where a later
|
|
355
|
+
// provider is the one meant to admit. The last refusal is returned if none do.
|
|
352
356
|
const providers = auth.filter(
|
|
353
357
|
(candidate) => candidate.uiAuth?.kind === "clerk",
|
|
354
358
|
);
|
|
@@ -550,8 +554,8 @@ async function handleCredentialRequest(
|
|
|
550
554
|
if (request.method !== "POST") {
|
|
551
555
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
552
556
|
}
|
|
553
|
-
// The declared credential shape picks the hook — the same single rule
|
|
554
|
-
// asks for its Test affordance, so a shown button
|
|
557
|
+
// The declared credential shape picks the hook — the same single rule the
|
|
558
|
+
// Credentials page asks for its Test affordance, so a shown button reaches
|
|
555
559
|
// that reads the shape the credential was stored in.
|
|
556
560
|
const rule = credentialTestRule(connector);
|
|
557
561
|
if (!rule.mode) {
|
|
@@ -566,29 +570,32 @@ async function handleCredentialRequest(
|
|
|
566
570
|
);
|
|
567
571
|
}
|
|
568
572
|
try {
|
|
573
|
+
const values = await opts.credentialVault.getAll(connectorId);
|
|
574
|
+
const shape = storedCredentialShape(connector.credential, values);
|
|
575
|
+
if (shape.state === "missing") {
|
|
576
|
+
return privateJson(
|
|
577
|
+
{
|
|
578
|
+
error:
|
|
579
|
+
rule.mode === "multiple"
|
|
580
|
+
? "configure the credentials before testing them"
|
|
581
|
+
: "configure the credential before testing it",
|
|
582
|
+
},
|
|
583
|
+
{ status: 409 },
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (shape.state === "mismatch") {
|
|
587
|
+
return privateJson({ error: shape.message }, { status: 409 });
|
|
588
|
+
}
|
|
589
|
+
const storedValues = values!;
|
|
569
590
|
const ctx = opts.registry.contextFor(connectorId, baseUrl);
|
|
570
591
|
let result;
|
|
571
592
|
if (rule.mode === "multiple") {
|
|
572
|
-
|
|
573
|
-
if (!values) {
|
|
574
|
-
return privateJson(
|
|
575
|
-
{ error: "configure the credentials before testing them" },
|
|
576
|
-
{ status: 409 },
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
result = await connector.testCredentials!(values, ctx);
|
|
593
|
+
result = await connector.testCredentials!(storedValues, ctx);
|
|
580
594
|
} else {
|
|
581
|
-
|
|
582
|
-
if (!value) {
|
|
583
|
-
return privateJson(
|
|
584
|
-
{ error: "configure the credential before testing it" },
|
|
585
|
-
{ status: 409 },
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
result = await connector.testCredential!(value, ctx);
|
|
595
|
+
result = await connector.testCredential!(storedValues.value, ctx);
|
|
589
596
|
}
|
|
590
597
|
// The operator just ran the very check the liveness sweep runs; record it
|
|
591
|
-
// so
|
|
598
|
+
// so cached status surfaces agree with what the operator page showed.
|
|
592
599
|
await opts.registry.recordCredentialHealth(connectorId, {
|
|
593
600
|
state: result.ok ? "ok" : "auth_required",
|
|
594
601
|
checkedAt: new Date().toISOString(),
|
|
@@ -658,12 +665,12 @@ interface McpScope {
|
|
|
658
665
|
|
|
659
666
|
/**
|
|
660
667
|
* Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
|
|
661
|
-
*
|
|
662
|
-
* caller-controlled newline or control character
|
|
663
|
-
* hand-rolled escape for U+2028/U+2029, which
|
|
664
|
-
* though a log reader treats them as line
|
|
665
|
-
*
|
|
666
|
-
* either.
|
|
668
|
+
* identity id, or OAuth callback connector id) for the operator log. Goes
|
|
669
|
+
* through JSON.stringify so a caller-controlled newline or control character
|
|
670
|
+
* cannot forge a log line, plus a hand-rolled escape for U+2028/U+2029, which
|
|
671
|
+
* JSON.stringify leaves raw even though a log reader treats them as line
|
|
672
|
+
* terminators. Truncated to a small shared cap (also the toolkit response's echo
|
|
673
|
+
* limit), so an oversized value cannot flood the log either.
|
|
667
674
|
*/
|
|
668
675
|
function loggableValue(requested: string): string {
|
|
669
676
|
const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
|
|
@@ -713,7 +720,7 @@ function identityLabel(actor: ActivityActor): string {
|
|
|
713
720
|
|
|
714
721
|
/**
|
|
715
722
|
* Resolve `?toolkit=<name>` into the registry view this connection may see,
|
|
716
|
-
* enforcing the caller's toolkit binding (
|
|
723
|
+
* enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
|
|
717
724
|
*
|
|
718
725
|
* For an UNBOUND identity (no binding configured — the pre-#37 shape):
|
|
719
726
|
*
|
|
@@ -905,35 +912,105 @@ async function serveMcp(
|
|
|
905
912
|
return transport.handleRequest(request);
|
|
906
913
|
}
|
|
907
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
917
|
+
* paths that would otherwise pay nothing.
|
|
918
|
+
*
|
|
919
|
+
* Identical bodies do not hide a connector id if the clock still sorts them.
|
|
920
|
+
* `KvOAuthProvider.verifyState` reads `oauth:state` before it can fail, so a
|
|
921
|
+
* configured id costs one storage round trip — on the Workers deployment shape
|
|
922
|
+
* that is a real KV read, tens of milliseconds cold — while an id that names
|
|
923
|
+
* nothing used to return having touched no I/O at all. That gap is an oracle:
|
|
924
|
+
* sample the two and a wordlist recovers the connector list the flat 400 was
|
|
925
|
+
* meant to withhold. So the zero-I/O refusals read the same key in the same
|
|
926
|
+
* `conn:<id>:` namespace, which for an unconfigured id is simply a miss.
|
|
927
|
+
*
|
|
928
|
+
* This is deliberately *not* a constant-time claim, and docs/connectors.md says
|
|
929
|
+
* so in prose: a hit and a miss are not identical in a KV store, and a connector
|
|
930
|
+
* shipping its own `verifyState` may do more or less work than one read. What it
|
|
931
|
+
* removes is the order-of-magnitude "no I/O versus a round trip" difference,
|
|
932
|
+
* which is the only part of the signal that makes enumeration cheap.
|
|
933
|
+
*
|
|
934
|
+
* A throwing read is swallowed: the refusal is the answer either way, and
|
|
935
|
+
* turning it into a 500 would hand back exactly the distinguishable response
|
|
936
|
+
* this whole path exists to deny.
|
|
937
|
+
*/
|
|
938
|
+
async function equalizeRefusalCost(context: ConnectorContext): Promise<void> {
|
|
939
|
+
try {
|
|
940
|
+
await context.storage.get("oauth:state");
|
|
941
|
+
} catch {
|
|
942
|
+
// Deliberately ignored — see above.
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
908
946
|
async function handleOAuthCallback(
|
|
909
947
|
url: URL,
|
|
910
948
|
registry: Registry,
|
|
911
949
|
baseUrl: string,
|
|
950
|
+
logger: Logger,
|
|
912
951
|
branding?: ConnectaBranding,
|
|
913
952
|
): Promise<Response> {
|
|
914
|
-
const id = url.pathname.slice("/oauth/callback/".length);
|
|
915
|
-
const connector = registry.getConnector(id);
|
|
916
|
-
if (!connector || !connector.finishAuth) {
|
|
917
|
-
return html(`Unknown connector "${id}".`, 404, branding);
|
|
918
|
-
}
|
|
919
953
|
const error = url.searchParams.get("error");
|
|
920
954
|
if (error) return html(`Authorization denied: ${error}`, 400, branding);
|
|
921
955
|
const code = url.searchParams.get("code");
|
|
922
956
|
if (!code) return html("Missing authorization code.", 400, branding);
|
|
957
|
+
const id = url.pathname.slice("/oauth/callback/".length);
|
|
958
|
+
const connector = registry.getConnector(id);
|
|
959
|
+
// Safe to build before we know the id names anything: `contextFor` is a pure
|
|
960
|
+
// constructor — a namespaced storage view over `conn:<id>:` and, only for a
|
|
961
|
+
// connector that declares one, a lazy credential accessor. It neither throws
|
|
962
|
+
// nor touches storage for an unknown id, which is what lets the refusals
|
|
963
|
+
// below borrow it to equalize their cost.
|
|
923
964
|
const context = registry.contextFor(id, baseUrl);
|
|
965
|
+
const refused = () =>
|
|
966
|
+
html(
|
|
967
|
+
"Authorization could not be completed. Re-run authorization from " +
|
|
968
|
+
"connecta and try again.",
|
|
969
|
+
400,
|
|
970
|
+
branding,
|
|
971
|
+
);
|
|
972
|
+
if (!connector || !connector.finishAuth) {
|
|
973
|
+
await equalizeRefusalCost(context);
|
|
974
|
+
return refused();
|
|
975
|
+
}
|
|
924
976
|
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
925
977
|
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
926
|
-
if (connector.verifyState) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
"
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
978
|
+
if (!connector.verifyState) {
|
|
979
|
+
await equalizeRefusalCost(context);
|
|
980
|
+
logger.warn(
|
|
981
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
982
|
+
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
983
|
+
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
984
|
+
"No authorization code was exchanged. Implement verifyState before " +
|
|
985
|
+
"trying again.",
|
|
986
|
+
);
|
|
987
|
+
return refused();
|
|
988
|
+
}
|
|
989
|
+
const state = url.searchParams.get("state");
|
|
990
|
+
let stateMatches: boolean;
|
|
991
|
+
try {
|
|
992
|
+
stateMatches = await connector.verifyState(state, context);
|
|
993
|
+
} catch (err) {
|
|
994
|
+
logger.warn(
|
|
995
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
996
|
+
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
997
|
+
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
998
|
+
"Re-run authorization from connecta and check the verifier if it " +
|
|
999
|
+
"fails again.",
|
|
1000
|
+
);
|
|
1001
|
+
return refused();
|
|
1002
|
+
}
|
|
1003
|
+
if (!stateMatches) {
|
|
1004
|
+
logger.warn(
|
|
1005
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
1006
|
+
`${loggableValue(id)} with 400: ` +
|
|
1007
|
+
(state === null
|
|
1008
|
+
? "the state parameter was missing"
|
|
1009
|
+
: "the state did not match the pending authorization flow") +
|
|
1010
|
+
". No authorization code was exchanged. Re-run authorization from " +
|
|
1011
|
+
"connecta and try again.",
|
|
1012
|
+
);
|
|
1013
|
+
return refused();
|
|
937
1014
|
}
|
|
938
1015
|
try {
|
|
939
1016
|
await connector.finishAuth(code, context);
|
|
@@ -1008,7 +1085,18 @@ export function createFetchHandler(
|
|
|
1008
1085
|
new URL(publicUrl).protocol === "https:" &&
|
|
1009
1086
|
url.protocol === "http:"
|
|
1010
1087
|
) {
|
|
1011
|
-
|
|
1088
|
+
// Canonicalize the legacy bookmark while upgrading it so an old /ui URL
|
|
1089
|
+
// reaches the new Connections entry point in one permanent redirect.
|
|
1090
|
+
const targetPath = path === "/ui" ? "/" : url.pathname;
|
|
1091
|
+
// Assign the path and query onto the configured URL instead of resolving
|
|
1092
|
+
// attacker-controlled text against it. A pathname beginning with `//`
|
|
1093
|
+
// (including a backslash form normalized by URL parsing) is an authority
|
|
1094
|
+
// when passed to `new URL(value, base)` and would otherwise replace the
|
|
1095
|
+
// deployment host.
|
|
1096
|
+
const target = new URL(publicUrl);
|
|
1097
|
+
target.pathname = targetPath;
|
|
1098
|
+
target.search = url.search;
|
|
1099
|
+
target.hash = "";
|
|
1012
1100
|
return withSecurityHeaders(
|
|
1013
1101
|
new Response(null, {
|
|
1014
1102
|
status: 308,
|
|
@@ -1067,7 +1155,13 @@ export function createFetchHandler(
|
|
|
1067
1155
|
}
|
|
1068
1156
|
|
|
1069
1157
|
if (path.startsWith("/oauth/callback/")) {
|
|
1070
|
-
return handleOAuthCallback(
|
|
1158
|
+
return handleOAuthCallback(
|
|
1159
|
+
url,
|
|
1160
|
+
registry,
|
|
1161
|
+
baseUrl,
|
|
1162
|
+
opts.logger,
|
|
1163
|
+
opts.branding,
|
|
1164
|
+
);
|
|
1071
1165
|
}
|
|
1072
1166
|
|
|
1073
1167
|
if (request.method === "GET" && path === "/favicon.svg") {
|
|
@@ -1091,7 +1185,23 @@ export function createFetchHandler(
|
|
|
1091
1185
|
}
|
|
1092
1186
|
|
|
1093
1187
|
if (path === "/ui") {
|
|
1094
|
-
|
|
1188
|
+
if (request.method !== "GET") {
|
|
1189
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1190
|
+
}
|
|
1191
|
+
const target = new URL(`/${url.search}`, baseUrl);
|
|
1192
|
+
return new Response(null, {
|
|
1193
|
+
status: 308,
|
|
1194
|
+
headers: { Location: target.toString() },
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const operatorPage = operatorPageForPath(path);
|
|
1199
|
+
if (operatorPage) {
|
|
1200
|
+
if (request.method !== "GET") {
|
|
1201
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1202
|
+
}
|
|
1203
|
+
// Open shell — carries no operator data; everything comes from the
|
|
1204
|
+
// authenticated /ui/* APIs after the browser establishes a session.
|
|
1095
1205
|
const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
|
|
1096
1206
|
const mcpUrl = new URL("/mcp", baseUrl).toString();
|
|
1097
1207
|
// Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
|
|
@@ -1101,16 +1211,19 @@ export function createFetchHandler(
|
|
|
1101
1211
|
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
1102
1212
|
// — only script execution, the XSS sink, is gated.
|
|
1103
1213
|
const nonce = uiScriptNonce();
|
|
1104
|
-
return new Response(
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
"
|
|
1111
|
-
|
|
1214
|
+
return new Response(
|
|
1215
|
+
renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage),
|
|
1216
|
+
{
|
|
1217
|
+
status: 200,
|
|
1218
|
+
headers: {
|
|
1219
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1220
|
+
"Content-Security-Policy":
|
|
1221
|
+
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
1222
|
+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
1223
|
+
"X-Content-Type-Options": "nosniff",
|
|
1224
|
+
},
|
|
1112
1225
|
},
|
|
1113
|
-
|
|
1226
|
+
);
|
|
1114
1227
|
}
|
|
1115
1228
|
|
|
1116
1229
|
if (path === "/ui/data") {
|
|
@@ -1122,16 +1235,24 @@ export function createFetchHandler(
|
|
|
1122
1235
|
// After the restriction check, not before: an identity that may not
|
|
1123
1236
|
// read this surface should not get to trigger background work from it.
|
|
1124
1237
|
sweepCredentials();
|
|
1238
|
+
const eligibleClerkOperator =
|
|
1239
|
+
authz.providerKind === "clerk" && Boolean(authz.userId);
|
|
1240
|
+
const credentialManagement = credentialManagementCapability({
|
|
1241
|
+
eligibleClerkOperator,
|
|
1242
|
+
hasCredentialSlots: registry
|
|
1243
|
+
.listConnectors()
|
|
1244
|
+
.some((connector) => Boolean(connector.credential)),
|
|
1245
|
+
hasCredentialVault: Boolean(opts.credentialVault),
|
|
1246
|
+
});
|
|
1125
1247
|
const data = await buildUiData(
|
|
1126
1248
|
registry,
|
|
1127
1249
|
baseUrl,
|
|
1128
1250
|
serverInfo,
|
|
1129
1251
|
// The static headless bearer may read connector health, but only a
|
|
1130
1252
|
// Clerk-authenticated operator receives credential metadata.
|
|
1131
|
-
|
|
1132
|
-
? opts.credentialVault
|
|
1133
|
-
: undefined,
|
|
1253
|
+
eligibleClerkOperator ? opts.credentialVault : undefined,
|
|
1134
1254
|
Boolean(opts.activity?.list),
|
|
1255
|
+
credentialManagement,
|
|
1135
1256
|
);
|
|
1136
1257
|
return privateJson(data);
|
|
1137
1258
|
}
|
package/src/toolkits.ts
CHANGED
|
@@ -129,7 +129,7 @@ function toolFilter(
|
|
|
129
129
|
* an allowlist is a scope the operator did not write, and a scope nobody wrote
|
|
130
130
|
* is not one an operator can reason about. (A definition scopes visibility only;
|
|
131
131
|
* WHICH identity may select it is the separate binding below — see the module
|
|
132
|
-
* header and
|
|
132
|
+
* header and docs/toolkits.md.) Tool names are checked only for connectors that expose
|
|
133
133
|
* `staticTools` (i.e. `api()`); a remote connector's catalog is fetched lazily
|
|
134
134
|
* over the network and is unknown at construction time.
|
|
135
135
|
*/
|
package/src/types.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface ConnectorCredentialFieldConfig {
|
|
|
63
63
|
name: string;
|
|
64
64
|
/** Short field label, e.g. "Account email". */
|
|
65
65
|
label: string;
|
|
66
|
-
/** Plain-language guidance shown in /
|
|
66
|
+
/** Plain-language guidance shown in /credentials. Never include the credential itself. */
|
|
67
67
|
description?: string;
|
|
68
68
|
/** Input placeholder, e.g. "you@example.com". */
|
|
69
69
|
placeholder?: string;
|
|
@@ -75,7 +75,7 @@ export interface ConnectorCredentialFieldConfig {
|
|
|
75
75
|
export interface ConnectorCredentialConfig {
|
|
76
76
|
/** Short group or field label, e.g. "API token" or "Service credentials". */
|
|
77
77
|
label: string;
|
|
78
|
-
/** Plain-language guidance shown in /
|
|
78
|
+
/** Plain-language guidance shown in /credentials. Never include the credential itself. */
|
|
79
79
|
description?: string;
|
|
80
80
|
/** Password-field placeholder, e.g. "Paste API token". */
|
|
81
81
|
placeholder?: string;
|
|
@@ -100,13 +100,14 @@ export interface ConnectorContext {
|
|
|
100
100
|
/**
|
|
101
101
|
* Read-only access to this connector's operator-managed credential. Present
|
|
102
102
|
* only when the connector declares `credential` and the deployment configures
|
|
103
|
-
* `
|
|
103
|
+
* `credentials.encryptionKey`.
|
|
104
104
|
*/
|
|
105
105
|
credential?: ConnectorCredentialAccess;
|
|
106
106
|
/**
|
|
107
107
|
* Identity shared by connector calls that belong to one inbound request.
|
|
108
108
|
* Connectors may use it to reuse request-safe resources within that request,
|
|
109
|
-
* but must never retain I/O resources beyond the scope's lifetime.
|
|
109
|
+
* but must never retain I/O resources beyond the scope's lifetime. For
|
|
110
|
+
* probe-only scopes the core owns, `Connector.closeScope` signals that end.
|
|
110
111
|
*
|
|
111
112
|
* Optional for custom/test contexts; the context object itself is the scope
|
|
112
113
|
* when omitted.
|
|
@@ -138,7 +139,7 @@ export interface Connector {
|
|
|
138
139
|
/**
|
|
139
140
|
* Max inline result size (bytes) for this connector's tools before
|
|
140
141
|
* call_tool/batch_call truncate and stash the full text for get_result
|
|
141
|
-
* paging. Overrides
|
|
142
|
+
* paging. Overrides `ConnectaConfig.calls.maxResultBytes`;
|
|
142
143
|
* omit to inherit it (which itself defaults to 50_000). Must be a whole
|
|
143
144
|
* number of bytes >= 1; anything else warns at startup and is ignored, so
|
|
144
145
|
* the connector inherits the deployment-wide cap.
|
|
@@ -152,14 +153,14 @@ export interface Connector {
|
|
|
152
153
|
* and imperative; it is read by agents, not operators.
|
|
153
154
|
*/
|
|
154
155
|
usageGuide?: string;
|
|
155
|
-
/** Optional operator-managed credential slot rendered
|
|
156
|
+
/** Optional operator-managed credential slot rendered on /credentials. */
|
|
156
157
|
credential?: ConnectorCredentialConfig;
|
|
157
|
-
/** Optional server-side check used by /
|
|
158
|
+
/** Optional server-side check used by /credentials' Test action. */
|
|
158
159
|
testCredential?(
|
|
159
160
|
value: string,
|
|
160
161
|
ctx: ConnectorContext,
|
|
161
162
|
): Promise<CredentialTestResult>;
|
|
162
|
-
/** Optional multi-field credential check used by /
|
|
163
|
+
/** Optional multi-field credential check used by /credentials' Test action. */
|
|
163
164
|
testCredentials?(
|
|
164
165
|
values: ConnectorCredentialValues,
|
|
165
166
|
ctx: ConnectorContext,
|
|
@@ -189,6 +190,18 @@ export interface Connector {
|
|
|
189
190
|
args: unknown,
|
|
190
191
|
ctx: ConnectorContext,
|
|
191
192
|
): Promise<unknown>;
|
|
193
|
+
/**
|
|
194
|
+
* Optional best-effort teardown for resources retained under
|
|
195
|
+
* `ctx.requestScope`. The core calls this at most once when a scope it created
|
|
196
|
+
* solely for probing ends, and never uses that scope again. Teardown gets a
|
|
197
|
+
* small, fixed best-effort completion window; a missing, rejected, or
|
|
198
|
+
* never-settling hook cannot change or hold open the operation's result
|
|
199
|
+
* beyond that bound.
|
|
200
|
+
*
|
|
201
|
+
* Per-request `/mcp` scopes are not closed through this hook: their
|
|
202
|
+
* request-local reuse remains in force until the request boundary.
|
|
203
|
+
*/
|
|
204
|
+
closeScope?(ctx: ConnectorContext): Promise<void>;
|
|
192
205
|
/** Optional connector-level health/auth status for list_connectors. */
|
|
193
206
|
status?(ctx: ConnectorContext): Promise<ConnectorStatus>;
|
|
194
207
|
/**
|
|
@@ -202,11 +215,11 @@ export interface Connector {
|
|
|
202
215
|
opts?: { force?: boolean },
|
|
203
216
|
): Promise<ConnectorStatus>;
|
|
204
217
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* complete consent with their own account.
|
|
218
|
+
* Verify the OAuth `state` returned to /oauth/callback/<id> against the value
|
|
219
|
+
* this connector generated when it started the flow. Required whenever
|
|
220
|
+
* `finishAuth` is present: the callback rejects before `finishAuth` when this
|
|
221
|
+
* hook is absent, throws, or returns false — otherwise anyone holding the
|
|
222
|
+
* pending URL could complete consent with their own account.
|
|
210
223
|
*/
|
|
211
224
|
verifyState?(state: string | null, ctx: ConnectorContext): Promise<boolean>;
|
|
212
225
|
/** Optional: complete a downstream OAuth flow (called by /oauth/callback/<id>). */
|
|
@@ -215,7 +228,8 @@ export interface Connector {
|
|
|
215
228
|
* Optional: serve a connector-owned HTTP route — for example a signed
|
|
216
229
|
* download link minted by one of the connector's tools. Called only after
|
|
217
230
|
* every built-in route misses, so a connector can never shadow `/mcp`,
|
|
218
|
-
* `/
|
|
231
|
+
* `/`, `/credentials`, `/activity`, `/health`, or the credential API. The
|
|
232
|
+
* first connector to return a
|
|
219
233
|
* Response wins, in registration order; return null to decline.
|
|
220
234
|
*
|
|
221
235
|
* These routes are PUBLIC: connecta applies no auth gate to them. A
|
|
@@ -254,9 +268,9 @@ export interface Executor {
|
|
|
254
268
|
|
|
255
269
|
/**
|
|
256
270
|
* Which toolkits one inbound identity may open — the membership half of the
|
|
257
|
-
* deployment=org / toolkit=team framing (
|
|
258
|
-
* engine: one identity → the toolkit names it may select, plus whether
|
|
259
|
-
* connect with no `?toolkit=` at all.
|
|
271
|
+
* deployment=org / toolkit=team framing (docs/toolkits.md). A mapping, never a
|
|
272
|
+
* policy engine: one identity → the toolkit names it may select, plus whether
|
|
273
|
+
* it may connect with no `?toolkit=` at all.
|
|
260
274
|
*
|
|
261
275
|
* An identity with NO binding is unbound and keeps the pre-binding behavior:
|
|
262
276
|
* any declared toolkit, or the full registry. A binding is enforced at connect
|
|
@@ -304,14 +318,14 @@ export type UiAuthConfig = {
|
|
|
304
318
|
kind: "clerk";
|
|
305
319
|
publishableKey: string;
|
|
306
320
|
/**
|
|
307
|
-
* Origin
|
|
321
|
+
* Origin the operator shell fetches its browser sign-in loader from. **Must be an absolute
|
|
308
322
|
* `https:` URL** — the value lands in a `<script src>`, so the gate is
|
|
309
323
|
* stricter than the branding href gate: no `http:`, no loopback exemption, and
|
|
310
324
|
* no root-relative form (a relative path is rejected, not resolved). The
|
|
311
325
|
* shipped `clerkAuth` adapter derives this from the publishable key and
|
|
312
326
|
* Clerk's Frontend API is always https, so nothing legitimate needs a
|
|
313
327
|
* carve-out. A value that fails the gate reaches neither the loader tag nor
|
|
314
|
-
* the page's inline auth config:
|
|
328
|
+
* the page's inline auth config: operator pages render without it and report
|
|
315
329
|
* that Clerk could not load, and `createConnecta` names the drop in a startup
|
|
316
330
|
* warning.
|
|
317
331
|
*/
|
|
@@ -322,7 +336,7 @@ export type UiAuthConfig = {
|
|
|
322
336
|
* this value is where Clerk *navigates* the operator's browser. An Account
|
|
323
337
|
* Portal address is always https, so the stricter gate costs nothing real: a
|
|
324
338
|
* value that fails it (a `javascript:`/`data:` payload, a cleartext `http:`
|
|
325
|
-
* address, a relative path) reaches no part of the page,
|
|
339
|
+
* address, a relative path) reaches no part of the page, the shell signs in
|
|
326
340
|
* through Clerk's default instead, and `createConnecta` names the drop in a
|
|
327
341
|
* startup warning.
|
|
328
342
|
*/
|
|
@@ -345,7 +359,7 @@ export interface ConnectaBranding {
|
|
|
345
359
|
ownerName?: string;
|
|
346
360
|
/** Optional link for the organization or owner label. */
|
|
347
361
|
ownerUrl?: string;
|
|
348
|
-
/**
|
|
362
|
+
/** Operator-page introduction and meta description. */
|
|
349
363
|
description?: string;
|
|
350
364
|
/**
|
|
351
365
|
* Browser tab title and page meta name. Defaults to
|
|
@@ -374,16 +388,16 @@ export interface ConnectaBranding {
|
|
|
374
388
|
export interface InboundAuth {
|
|
375
389
|
kind: string;
|
|
376
390
|
/**
|
|
377
|
-
* Optional browser sign-in configuration. When present,
|
|
391
|
+
* Optional browser sign-in configuration. When present, operator pages use it
|
|
378
392
|
* provider instead of asking the operator to paste a static bearer secret.
|
|
379
393
|
*/
|
|
380
394
|
uiAuth?: UiAuthConfig;
|
|
381
395
|
/**
|
|
382
|
-
* Optional toolkit binding for every identity this provider admits
|
|
383
|
-
* Declared statically so `createConnecta` can validate the
|
|
384
|
-
* `ConnectaConfig.toolkits` and throw on a typo — a binding
|
|
385
|
-
* not one an operator can reason about. An `authorize` result
|
|
386
|
-
* per identity with its own `toolkitBinding`.
|
|
396
|
+
* Optional toolkit binding for every identity this provider admits
|
|
397
|
+
* (docs/toolkits.md). Declared statically so `createConnecta` can validate the
|
|
398
|
+
* names against `ConnectaConfig.toolkits` and throw on a typo — a binding
|
|
399
|
+
* nobody wrote is not one an operator can reason about. An `authorize` result
|
|
400
|
+
* may narrow it per identity with its own `toolkitBinding`.
|
|
387
401
|
*/
|
|
388
402
|
toolkitBinding?: ToolkitBinding;
|
|
389
403
|
/** Serve/short-circuit .well-known + OPTIONS. Return null when not handled. */
|