@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/auth/clerk.ts
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
// Clerk as the OAuth 2.1 authorization server; connecta is the resource server.
|
|
2
|
-
// Single tenant, no tenant-tag requirement, optional gate() with
|
|
3
|
-
// caching.
|
|
2
|
+
// Single tenant, no tenant-tag requirement, optional allowedDomains/gate() with
|
|
3
|
+
// ~60s identity caching.
|
|
4
4
|
|
|
5
5
|
import { createClerkClient } from "@clerk/backend";
|
|
6
|
+
import {
|
|
7
|
+
resolveToolkitBinding,
|
|
8
|
+
type ToolkitBindingOptions,
|
|
9
|
+
} from "../toolkits.js";
|
|
6
10
|
import type { AuthResult, InboundAuth } from "../types.js";
|
|
7
11
|
|
|
8
12
|
type ClerkClient = ReturnType<typeof createClerkClient>;
|
|
9
13
|
|
|
10
|
-
export interface ClerkAuthOptions {
|
|
14
|
+
export interface ClerkAuthOptions extends ToolkitBindingOptions {
|
|
11
15
|
publishableKey: string;
|
|
12
16
|
secretKey: string;
|
|
13
17
|
/** Public base URL of this deployment. Defaults to the request origin. */
|
|
14
18
|
publicUrl?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Email domains this deployment admits, e.g. `["acme.com"]`. An
|
|
21
|
+
* authenticated user whose verified primary email is not on one of them is
|
|
22
|
+
* rejected exactly like a `gate` rejection. Matching is exact on the whole
|
|
23
|
+
* domain and case-insensitive: `acme.com` admits neither `evil-acme.com` nor
|
|
24
|
+
* `mail.acme.com` — spell a subdomain out to allow it. Entries must be ASCII
|
|
25
|
+
* (punycode for an internationalized domain) and are validated at
|
|
26
|
+
* construction. Absent ⇒ every authenticated user passes this check, as
|
|
27
|
+
* before the option existed. Governs Clerk sign-in only: a co-configured
|
|
28
|
+
* `bearerToken` has no email to read and is admitted without a domain check.
|
|
29
|
+
*/
|
|
30
|
+
allowedDomains?: readonly string[];
|
|
15
31
|
/** Optional allow-list hook. Return false to reject an authenticated user. */
|
|
16
32
|
gate?: (userId: string, clerk: ClerkClient) => boolean | Promise<boolean>;
|
|
17
33
|
/** Advertised scopes in protected-resource metadata. */
|
|
18
34
|
scopes?: string[];
|
|
19
|
-
/** Optional hosted Account Portal sign-in URL for `/ui`. */
|
|
35
|
+
/** Optional hosted Account Portal sign-in URL for `/ui`. Absolute https only. */
|
|
20
36
|
signInUrl?: string;
|
|
21
|
-
/** Optional hosted Account Portal sign-up URL for `/ui`. */
|
|
37
|
+
/** Optional hosted Account Portal sign-up URL for `/ui`. Absolute https only. */
|
|
22
38
|
signUpUrl?: string;
|
|
23
39
|
}
|
|
24
40
|
|
|
@@ -49,11 +65,134 @@ function fapiUrl(publishableKey: string): string {
|
|
|
49
65
|
const GATE_ALLOWED_TTL_MS = 60 * 1000;
|
|
50
66
|
const GATE_FORBIDDEN_TTL_MS = 30 * 1000;
|
|
51
67
|
|
|
68
|
+
/**
|
|
69
|
+
* One label of a domain: ASCII letters/digits, interior hyphens only, 63
|
|
70
|
+
* characters at most. ASCII-only is deliberate — an internationalized domain
|
|
71
|
+
* must be in its punycode (`xn--…`) form, so a Unicode confusable can neither be
|
|
72
|
+
* typed into the allowlist nor arrive in an email address and pass for a domain
|
|
73
|
+
* the operator cannot tell from theirs by eye.
|
|
74
|
+
*/
|
|
75
|
+
const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is this string a domain, before any case folding? Both sides of the
|
|
79
|
+
* comparison — the operator's allowlist entries and the domain read off a
|
|
80
|
+
* user's email — are checked against this one grammar, so neither side can be
|
|
81
|
+
* *repaired* into a match by the normalization that follows: `"acme.com\n"` and
|
|
82
|
+
* `" acme.com"` are malformed, not `acme.com`, and an `akme.com` spelled with a
|
|
83
|
+
* U+212A KELVIN SIGN is rejected here rather than folded to plain ASCII `k` by
|
|
84
|
+
* `toLowerCase`.
|
|
85
|
+
*/
|
|
86
|
+
function isDomain(domain: string): boolean {
|
|
87
|
+
return (
|
|
88
|
+
domain.length > 0 &&
|
|
89
|
+
domain.length <= 253 &&
|
|
90
|
+
domain.includes(".") &&
|
|
91
|
+
domain.split(".").every((label) => DOMAIN_LABEL_RE.test(label))
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate and lowercase `allowedDomains` at construction. Everything here
|
|
97
|
+
* throws rather than dropping the entry: an allowlist that does not say what
|
|
98
|
+
* its author meant is invisible until the day it admits the wrong caller.
|
|
99
|
+
*/
|
|
100
|
+
function normalizeAllowedDomains(
|
|
101
|
+
value: readonly string[] | undefined,
|
|
102
|
+
): ReadonlySet<string> | undefined {
|
|
103
|
+
if (value === undefined) return undefined;
|
|
104
|
+
if (!Array.isArray(value)) {
|
|
105
|
+
throw new Error("clerkAuth: `allowedDomains` must be an array of domains.");
|
|
106
|
+
}
|
|
107
|
+
if (value.length === 0) {
|
|
108
|
+
// Fail-closed, an empty list admits nobody and the deployment is dead on
|
|
109
|
+
// arrival; read as "no restriction", it is the one shape here that fails
|
|
110
|
+
// OPEN. Neither is what anyone meant to write.
|
|
111
|
+
throw new Error(
|
|
112
|
+
"clerkAuth: `allowedDomains` is empty. List at least one domain, or " +
|
|
113
|
+
"drop the option to admit every authenticated user.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const domains = new Set<string>();
|
|
117
|
+
for (const entry of value) {
|
|
118
|
+
if (typeof entry !== "string") {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`clerkAuth: \`allowedDomains\` entry ${JSON.stringify(entry)} is not a string.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
// Surrounding whitespace is the one thing forgiven, and only here: this is
|
|
124
|
+
// operator config read at construction, where a stray space is a typo the
|
|
125
|
+
// operator can see in the throw. Nothing is forgiven on the email side.
|
|
126
|
+
const domain = entry.trim();
|
|
127
|
+
if (!isDomain(domain)) {
|
|
128
|
+
const hint = domain.includes("@")
|
|
129
|
+
? " Write the domain alone, with no `@` and no local part."
|
|
130
|
+
: "";
|
|
131
|
+
throw new Error(
|
|
132
|
+
`clerkAuth: \`allowedDomains\` entry ${JSON.stringify(entry)} is not a ` +
|
|
133
|
+
`domain (expected something like "acme.com").${hint}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
domains.add(domain.toLowerCase());
|
|
137
|
+
}
|
|
138
|
+
return domains;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Bounded, escaped form of the denied domain for the operator log — the same
|
|
143
|
+
* treatment `src/server.ts` gives a rejected toolkit name. An email domain is
|
|
144
|
+
* caller-influenced (anyone who controls a mailbox controls its domain): the
|
|
145
|
+
* bound is what a 253-byte domain needs, and the escaping — JSON.stringify plus
|
|
146
|
+
* the hand-rolled U+2028/U+2029 pass it leaves raw — is defense in depth behind
|
|
147
|
+
* `isDomain`, which has already ruled out the newline that would forge a line.
|
|
148
|
+
*/
|
|
149
|
+
function loggableDomain(domain: string): string {
|
|
150
|
+
const bounded = domain.slice(0, 100);
|
|
151
|
+
const escaped = JSON.stringify(bounded).replace(
|
|
152
|
+
/[\u2028\u2029]/g,
|
|
153
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
|
|
154
|
+
);
|
|
155
|
+
return escaped + (bounded.length < domain.length ? " (truncated)" : "");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The domain of an email address, lowercased for comparison, or null when the
|
|
160
|
+
* address does not have exactly one well-formed domain to read.
|
|
161
|
+
*
|
|
162
|
+
* Nothing here repairs the input. The domain is validated as it arrived and
|
|
163
|
+
* only then lowercased, so `dev@ acme.com`, `dev@acme.com\n` and `dev@acme.com.`
|
|
164
|
+
* are malformed addresses that DENY, rather than whitespace-trimmed or
|
|
165
|
+
* dot-stripped into a match for `acme.com`. The split is on the last `@`, the
|
|
166
|
+
* part a mail system routes on, so this reads the same domain that would
|
|
167
|
+
* receive the mail. (Under exact set matching a first-`@` split could not fail
|
|
168
|
+
* open either — it would just read a domain nobody delivers to.)
|
|
169
|
+
*/
|
|
170
|
+
function emailDomain(email: string): string | null {
|
|
171
|
+
const at = email.lastIndexOf("@");
|
|
172
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
173
|
+
const domain = email.slice(at + 1);
|
|
174
|
+
return isDomain(domain) ? domain.toLowerCase() : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Clerk inbound auth.
|
|
179
|
+
*
|
|
180
|
+
* `allowedDomains` and `gate` decide WHO is admitted (both must pass);
|
|
181
|
+
* `toolkits` decides WHICH view the admitted user gets.
|
|
182
|
+
*
|
|
183
|
+
* `toolkits` binds every user this provider admits to those toolkits (§16). For
|
|
184
|
+
* a per-team split, configure one `clerkAuth(...)` per team — the same keys, a
|
|
185
|
+
* `gate` naming that team's users, and that team's `toolkits`. The server tries
|
|
186
|
+
* providers in order and the first that admits the user supplies the binding, so
|
|
187
|
+
* a user one gate rejects falls through to the next.
|
|
188
|
+
*/
|
|
52
189
|
export function clerkAuth(opts: ClerkAuthOptions): InboundAuth {
|
|
53
190
|
const clerk = createClerkClient({
|
|
54
191
|
secretKey: opts.secretKey,
|
|
55
192
|
publishableKey: opts.publishableKey,
|
|
56
193
|
});
|
|
194
|
+
const toolkitBinding = resolveToolkitBinding("clerkAuth", opts);
|
|
195
|
+
const allowedDomains = normalizeAllowedDomains(opts.allowedDomains);
|
|
57
196
|
const scopes = opts.scopes ?? ["openid", "profile", "email"];
|
|
58
197
|
const gateCache = new Map<string, { allowed: boolean; exp: number }>();
|
|
59
198
|
|
|
@@ -80,13 +219,70 @@ export function clerkAuth(opts: ClerkAuthOptions): InboundAuth {
|
|
|
80
219
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
81
220
|
);
|
|
82
221
|
|
|
222
|
+
/**
|
|
223
|
+
* The domain half of admission. Fails CLOSED on every uncertainty — no
|
|
224
|
+
* primary email, an unverified one, a malformed address, or the lookup
|
|
225
|
+
* itself failing — because "we could not tell" and "they belong here" must
|
|
226
|
+
* not be the same answer for a membership rule.
|
|
227
|
+
*/
|
|
228
|
+
const checkDomain = async (userId: string): Promise<boolean> => {
|
|
229
|
+
if (!allowedDomains) return true;
|
|
230
|
+
let email: string | undefined;
|
|
231
|
+
try {
|
|
232
|
+
const user = await clerk.users.getUser(userId);
|
|
233
|
+
const primary = user.emailAddresses?.find(
|
|
234
|
+
(address) => address.id === user.primaryEmailAddressId,
|
|
235
|
+
);
|
|
236
|
+
if (primary?.verification?.status === "verified") {
|
|
237
|
+
email = primary.emailAddress;
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.warn(
|
|
241
|
+
`[connecta] clerk email lookup failed for ${userId}: ${
|
|
242
|
+
error instanceof Error ? error.message : String(error)
|
|
243
|
+
} — denying`,
|
|
244
|
+
);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const domain = email ? emailDomain(email) : null;
|
|
248
|
+
if (!domain) {
|
|
249
|
+
// One line for three cases (no primary email, unverified, or an address
|
|
250
|
+
// with no readable domain) because the caller must not be able to tell
|
|
251
|
+
// them apart — but it must not claim the email is missing when it is
|
|
252
|
+
// there and malformed.
|
|
253
|
+
console.warn(
|
|
254
|
+
`[connecta] clerk user ${userId} has no verified primary email with a ` +
|
|
255
|
+
"well-formed domain — denying",
|
|
256
|
+
);
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if (!allowedDomains.has(domain)) {
|
|
260
|
+
// The domain, never the address: this is an operator log, not a place to
|
|
261
|
+
// spill the local part of someone's email on every denied request.
|
|
262
|
+
console.warn(
|
|
263
|
+
`[connecta] clerk user ${userId} denied: email domain ` +
|
|
264
|
+
`${loggableDomain(domain)} is not on allowedDomains`,
|
|
265
|
+
);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Is this authenticated user admitted? The domain allowlist and `gate` both
|
|
273
|
+
* have to say yes, and the allowlist runs first so an outsider never reaches
|
|
274
|
+
* operator gate code. One cached verdict covers both, so composing them costs
|
|
275
|
+
* no more Clerk calls than `gate` alone did.
|
|
276
|
+
*/
|
|
83
277
|
const checkGate = async (userId: string): Promise<boolean> => {
|
|
84
|
-
if (!opts.gate) return true;
|
|
278
|
+
if (!opts.gate && !allowedDomains) return true;
|
|
85
279
|
const hit = gateCache.get(userId);
|
|
86
280
|
if (hit && Date.now() < hit.exp) return hit.allowed;
|
|
87
281
|
let allowed = false;
|
|
88
282
|
try {
|
|
89
|
-
allowed =
|
|
283
|
+
allowed =
|
|
284
|
+
(await checkDomain(userId)) &&
|
|
285
|
+
(opts.gate ? await opts.gate(userId, clerk) : true);
|
|
90
286
|
} catch {
|
|
91
287
|
allowed = false;
|
|
92
288
|
}
|
|
@@ -101,6 +297,7 @@ export function clerkAuth(opts: ClerkAuthOptions): InboundAuth {
|
|
|
101
297
|
|
|
102
298
|
return {
|
|
103
299
|
kind: "clerk",
|
|
300
|
+
...(toolkitBinding ? { toolkitBinding } : {}),
|
|
104
301
|
uiAuth: {
|
|
105
302
|
kind: "clerk",
|
|
106
303
|
publishableKey: opts.publishableKey,
|
|
@@ -345,6 +345,15 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
345
345
|
};
|
|
346
346
|
|
|
347
347
|
if (opts.auth?.type === "oauth") {
|
|
348
|
+
// The credential liveness checks (issue #24) probe only connectors that
|
|
349
|
+
// actually hold a stored grant: with no tokens there is nothing whose
|
|
350
|
+
// liveness could have lapsed, and a `status()` probe would kick off DCR +
|
|
351
|
+
// consent on a timer for a connector nobody has authorized yet.
|
|
352
|
+
connector.hasStoredCredential = async (ctx) => {
|
|
353
|
+
const state = stateFor(ctx);
|
|
354
|
+
return (await getProvider(ctx, state).tokens()) !== undefined;
|
|
355
|
+
};
|
|
356
|
+
|
|
348
357
|
connector.verifyState = async (oauthState, ctx) => {
|
|
349
358
|
const state = stateFor(ctx);
|
|
350
359
|
return getProvider(ctx, state).verifyState(oauthState);
|