Skip to content
669 linesCodeBlameRaw
1/**
2 * Connectors (docs.g1t.sh/guides/customize/, "Connectors"): MCP servers a
3 * workspace connects once, then gives to agents. One comes from the
4 * directory (@g1t/contracts connectors.ts, the servers tools publish),
5 * from a plugin's `.mcp.json`, or from an address an owner types. It
6 * signs in the way the server asks:
7 *
8 * - `oauth`: the browser. g1t finds the server's authorization server
9 * (RFC 9728, then RFC 8414), registers itself as a client (RFC 7591)
10 * when the server allows it, and sends the person there with PKCE. The
11 * tokens come back here and are refreshed before they expire.
12 * - `header`: a key an owner pastes, sent as a header's value.
13 * - `none`: the server is open.
14 *
15 * Secrets (the key, the tokens, the client registration) are sealed with
16 * AES-GCM under `CONNECTORS_KEY`, bound to the connector's id, and never
17 * returned. Where a connector reaches (`connector_grants`) works like a
18 * skill's attachments: every agent, a team's, or one. An agent's turn gets
19 * the connectors that reach it in the same shape as its own MCP servers,
20 * so levels, Ask-first cards and audit are unchanged (abilities.ts).
21 *
22 * Pure apart from the sealing key and `fetch`, so it is tested on SQLite
23 * with fakes.
24 */
25import type { FailureCode, Result } from "@g1t/contracts";
26import { MAX_MCP_TOOLS, type McpServer, type McpTool, checkMcpName, checkMcpUrl } from "../../../packages/contracts/src/abilities.ts";
27import { CONNECTORS, type Connector } from "../../../packages/contracts/src/connectors.ts";
28import { MAX_CONNECTORS, type AgentConnector, type ConnectorAuth, type ConnectorFrom, type ConnectorGrant, type ConnectorInput, type ConnectorState, type WorkspaceConnector } from "../../../packages/contracts/src/customize.ts";
29import { newId } from "../../../packages/contracts/src/ids.ts";
30import type { SkillScope } from "../../../packages/contracts/src/skill-library.ts";
31import { type Fetch, listMcpTools } from "./mcp-client.ts";
32
33const ok = <T>(value: T): Result<T> => ({ ok: true, value });
34const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
35
36export type ConnectorRow = {
37 id: string;
38 workspace_id: string;
39 name: string;
40 title: string;
41 url: string;
42 category: string | null;
43 about: string | null;
44 auth: string;
45 header_name: string | null;
46 secrets: string | null;
47 signed_in_as: string | null;
48 signed_in_at: string | null;
49 pending: string | null;
50 pending_at: string | null;
51 from_json: string;
52 tools: string;
53 checked_at: string | null;
54 problem: string | null;
55 added_by: string;
56 added_at: string;
57 updated_at: string;
58 removed_at: string | null;
59};
60
61export type GrantRow = { id: string; workspace_id: string; connector_id: string; scope: SkillScope; target: string; granted_by: string; granted_at: string };
62
63/** What a connector keeps sealed. */
64type Secrets = {
65 header?: string;
66 oauth?: {
67 client_id: string;
68 client_secret?: string;
69 access_token: string;
70 refresh_token?: string;
71 /** Unix seconds. */
72 expires_at?: number;
73 token_endpoint: string;
74 resource?: string;
75 scope?: string;
76 };
77};
78
79/** An OAuth sign-in under way. */
80type Pending = {
81 state: string;
82 verifier: string;
83 client_id: string;
84 client_secret?: string;
85 token_endpoint: string;
86 redirect_uri: string;
87 resource: string;
88 scope?: string;
89};
90
91function json<T>(text: string | null, fallback: T): T {
92 if (!text) return fallback;
93 try {
94 return JSON.parse(text) as T;
95 } catch {
96 return fallback;
97 }
98}
99
100// ── Sealing ──────────────────────────────────────────────────────────────
101
102/** The sealing key from `CONNECTORS_KEY` (64 hex characters), or null when none is set: then nothing secret can be kept. */
103export async function sealingKey(hex: string | undefined): Promise<CryptoKey | null> {
104 if (!hex || !/^[0-9a-fA-F]{64}$/.test(hex.trim())) return null;
105 const bytes = new Uint8Array(hex.trim().match(/.{2}/g)!.map((b) => parseInt(b, 16)));
106 return crypto.subtle.importKey("raw", bytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
107}
108
109const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes));
110const unb64 = (text: string): Uint8Array => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
111
112/** `value` sealed for `id`: base64 of the nonce and the ciphertext. */
113export async function seal(key: CryptoKey, id: string, value: unknown): Promise<string> {
114 const iv = crypto.getRandomValues(new Uint8Array(12));
115 const data = new TextEncoder().encode(JSON.stringify(value));
116 const out = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(id) }, key, data));
117 const joined = new Uint8Array(iv.length + out.length);
118 joined.set(iv);
119 joined.set(out, iv.length);
120 return b64(joined);
121}
122
123/** What was sealed for `id`, or null when it can't be opened (another key, another id, damaged). */
124export async function open<T>(key: CryptoKey, id: string, sealed: string | null): Promise<T | null> {
125 if (!sealed) return null;
126 try {
127 const bytes = unb64(sealed);
128 const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.subarray(0, 12), additionalData: new TextEncoder().encode(id) }, key, bytes.subarray(12));
129 return JSON.parse(new TextDecoder().decode(plain)) as T;
130 } catch {
131 return null;
132 }
133}
134
135// ── Rows ─────────────────────────────────────────────────────────────────
136
137export type Deps = {
138 key: CryptoKey | null;
139 fetchFn?: Fetch;
140 /** The site's address, for the OAuth redirect and the client's name. */
141 site: string;
142 now?: Date;
143};
144
145const at = (deps: Deps) => (deps.now ?? new Date()).toISOString();
146
147export async function listConnectors(db: D1Database, workspaceId: string): Promise<ConnectorRow[]> {
148 const { results } = await db.prepare("SELECT * FROM connectors WHERE workspace_id = ? AND removed_at IS NULL ORDER BY title COLLATE NOCASE").bind(workspaceId).all<ConnectorRow>();
149 return results;
150}
151
152export async function connectorById(db: D1Database, workspaceId: string, id: string): Promise<ConnectorRow | null> {
153 return db.prepare("SELECT * FROM connectors WHERE workspace_id = ? AND id = ? AND removed_at IS NULL").bind(workspaceId, id).first<ConnectorRow>();
154}
155
156async function grantsOf(db: D1Database, connectorIds: string[]): Promise<GrantRow[]> {
157 if (!connectorIds.length) return [];
158 const { results } = await db.prepare("SELECT * FROM connector_grants WHERE connector_id IN (SELECT value FROM json_each(?)) ORDER BY CASE scope WHEN 'workspace' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, granted_at").bind(JSON.stringify(connectorIds)).all<GrantRow>();
159 return results;
160}
161
162/** Whether the connector can be used now. */
163export function stateOf(row: ConnectorRow): ConnectorState {
164 if (row.auth === "oauth" && !row.signed_in_at) return "needs_sign_in";
165 if (row.auth === "header" && !row.secrets) return "needs_key";
166 if (row.problem) return "problem";
167 return "connected";
168}
169
170type Labels = { agents: Map<string, { handle: string; display_name: string }>; teams: Map<string, string> };
171
172function grantOut(g: GrantRow, labels: Labels): ConnectorGrant {
173 const agent = g.scope === "agent" ? labels.agents.get(g.target) : null;
174 const target = g.scope === "agent" ? (agent?.handle ?? null) : g.scope === "team" ? g.target : null;
175 const label = g.scope === "workspace" ? "Every agent" : g.scope === "agent" ? (agent ? `@${agent.handle}` : "An archived agent") : (labels.teams.get(g.target) ?? g.target);
176 return { id: g.id, scope: g.scope, target, label, granted_by: g.granted_by, granted_at: g.granted_at };
177}
178
179export function connectorOut(row: ConnectorRow, grants: GrantRow[], labels: Labels): WorkspaceConnector {
180 return {
181 id: row.id,
182 name: row.name,
183 title: row.title,
184 url: row.url,
185 category: (row.category as WorkspaceConnector["category"]) ?? null,
186 about: row.about,
187 auth: row.auth as ConnectorAuth,
188 state: stateOf(row),
189 problem: row.problem,
190 from: json<ConnectorFrom>(row.from_json, { kind: "address" }),
191 tools: json<McpTool[]>(row.tools, []),
192 checked_at: row.checked_at,
193 header_name: row.header_name,
194 signed_in_as: row.signed_in_as,
195 signed_in_at: row.signed_in_at,
196 grants: grants.map((g) => grantOut(g, labels)),
197 added_by: row.added_by,
198 added_at: row.added_at,
199 };
200}
201
202/** Every connector as the page lists them. */
203export async function connectorsOut(db: D1Database, workspaceId: string, labels: Labels): Promise<WorkspaceConnector[]> {
204 const rows = await listConnectors(db, workspaceId);
205 const grants = await grantsOf(
206 db,
207 rows.map((r) => r.id),
208 );
209 return rows.map((row) => connectorOut(row, grants.filter((g) => g.connector_id === row.id), labels));
210}
211
212// ── Headers and tools ────────────────────────────────────────────────────
213
214/** The headers that sign a call in, refreshing an OAuth token first when it is about to expire. Null when the connector can't be used. */
215export async function headersFor(db: D1Database, deps: Deps, row: ConnectorRow): Promise<Record<string, string> | null> {
216 if (row.auth === "none") return {};
217 if (!deps.key) return null;
218 const secrets = await open<Secrets>(deps.key, row.id, row.secrets);
219 if (!secrets) return null;
220 if (row.auth === "header") return secrets.header && row.header_name ? { [row.header_name]: secrets.header } : null;
221 const oauth = secrets.oauth;
222 if (!oauth) return null;
223 const now = Math.floor((deps.now ?? new Date()).getTime() / 1000);
224 if (oauth.expires_at && oauth.expires_at - 60 < now) {
225 if (!oauth.refresh_token) return null;
226 const refreshed = await tokenRequest(deps.fetchFn ?? ((u, i) => fetch(u, i)), oauth.token_endpoint, {
227 grant_type: "refresh_token",
228 refresh_token: oauth.refresh_token,
229 client_id: oauth.client_id,
230 ...(oauth.client_secret ? { client_secret: oauth.client_secret } : {}),
231 ...(oauth.resource ? { resource: oauth.resource } : {}),
232 });
233 if (!refreshed.ok) {
234 await db.prepare("UPDATE connectors SET problem = ?, updated_at = ? WHERE id = ?").bind(`Its sign-in couldn't be renewed: ${refreshed.message} Sign in again.`, at(deps), row.id).run();
235 return null;
236 }
237 const fresh: Secrets = { ...secrets, oauth: { ...oauth, access_token: refreshed.access_token, refresh_token: refreshed.refresh_token ?? oauth.refresh_token, expires_at: refreshed.expires_in ? now + refreshed.expires_in : undefined } };
238 await db.prepare("UPDATE connectors SET secrets = ?, updated_at = ? WHERE id = ?").bind(await seal(deps.key, row.id, fresh), at(deps), row.id).run();
239 return { Authorization: `Bearer ${fresh.oauth!.access_token}` };
240 }
241 return { Authorization: `Bearer ${oauth.access_token}` };
242}
243
244/** Lists the server's tools with its headers and keeps them; the problem, if any, is kept too. */
245export async function refreshTools(db: D1Database, deps: Deps, row: ConnectorRow): Promise<ConnectorRow> {
246 const headers = await headersFor(db, deps, row);
247 const when = at(deps);
248 if (headers === null) {
249 const problem = row.auth === "oauth" ? (row.signed_in_at ? "Its sign-in can't be used. Sign in again." : null) : row.auth === "header" ? null : "Its secrets can't be opened (is CONNECTORS_KEY set?).";
250 await db.prepare("UPDATE connectors SET problem = ?, checked_at = ?, updated_at = ? WHERE id = ?").bind(problem, when, when, row.id).run();
251 return { ...row, problem, checked_at: when, updated_at: when };
252 }
253 const listed = await listMcpTools(row.url, deps.fetchFn, headers);
254 const tools = listed.ok ? JSON.stringify(listed.tools) : row.tools;
255 const problem = listed.ok ? null : listed.message;
256 await db.prepare("UPDATE connectors SET tools = ?, checked_at = ?, problem = ?, updated_at = ? WHERE id = ?").bind(tools, when, problem, when, row.id).run();
257 return { ...row, tools, checked_at: when, problem, updated_at: when };
258}
259
260// ── Adding and removing ──────────────────────────────────────────────────
261
262/** A name for a connector from a title: lowercase, hyphens, 2 to 32. */
263function nameFrom(text: string): string {
264 const name = text
265 .toLowerCase()
266 .replace(/[^a-z0-9]+/g, "-")
267 .replace(/^-+|-+$/g, "")
268 .slice(0, 32)
269 .replace(/-+$/, "");
270 return name.length >= 2 ? name : `${name}x`.slice(0, 2);
271}
272
273/** Adds a connector; a plugin's or an address's needs an owner to sign in or paste a key next, unless the server is open. */
274export async function addConnector(
275 db: D1Database,
276 deps: Deps,
277 workspaceId: string,
278 input: ConnectorInput,
279 by: string,
280 pluginServer?: { title: string; url: string } | null,
281): Promise<Result<ConnectorRow>> {
282 const count = await db.prepare("SELECT COUNT(*) AS n FROM connectors WHERE workspace_id = ? AND removed_at IS NULL").bind(workspaceId).first<{ n: number }>();
283 if ((count?.n ?? 0) >= MAX_CONNECTORS) return fail("limit", `A workspace keeps at most ${MAX_CONNECTORS} connectors.`);
284 let title: string;
285 let url: string;
286 let name: string;
287 let category: string | null = null;
288 let about: string | null = null;
289 let auth: ConnectorAuth = input.auth;
290 let headerName: string | null = input.header?.name?.trim() || null;
291 const from = input.from;
292 if (from.kind === "directory") {
293 const entry: Connector | undefined = CONNECTORS.find((c) => c.id === from.id && c.mcp);
294 if (!entry?.mcp) return fail("not_found", "There is no such connector in the directory.");
295 title = entry.name;
296 url = entry.mcp.url;
297 name = nameFrom(entry.id.replace(/-mcp$/, ""));
298 category = entry.category;
299 about = entry.description;
300 auth = entry.mcp.auth;
301 if (auth === "header") headerName = headerName ?? entry.mcp.header ?? "Authorization";
302 } else if (from.kind === "plugin") {
303 if (!pluginServer) return fail("not_found", "That plugin names no such server.");
304 title = pluginServer.title;
305 url = pluginServer.url;
306 name = nameFrom(from.server);
307 about = `From the ${from.ref} plugin.`;
308 } else {
309 const checkedName = checkMcpName(from.name);
310 if (!checkedName.ok) return fail("invalid", checkedName.message);
311 title = from.name.trim();
312 url = from.url;
313 name = checkedName.value;
314 }
315 const checkedUrl = checkMcpUrl(url);
316 if (!checkedUrl.ok) return fail("invalid", checkedUrl.message);
317 if (auth === "header" && !headerName) headerName = "Authorization";
318 if (auth === "header" && input.header?.value && !deps.key) return fail("invalid", "Keys can't be kept here: CONNECTORS_KEY isn't set on the agents service.");
319 const now = at(deps);
320 const id = newId("con", (deps.now ?? new Date()).getTime());
321 const secrets = auth === "header" && input.header?.value && deps.key ? await seal(deps.key, id, { header: input.header.value.trim() } satisfies Secrets) : null;
322 const row: ConnectorRow = {
323 id,
324 workspace_id: workspaceId,
325 name,
326 title,
327 url: checkedUrl.value,
328 category,
329 about,
330 auth,
331 header_name: auth === "header" ? headerName : null,
332 secrets,
333 signed_in_as: null,
334 signed_in_at: null,
335 pending: null,
336 pending_at: null,
337 from_json: JSON.stringify(from),
338 tools: "[]",
339 checked_at: null,
340 problem: null,
341 added_by: by,
342 added_at: now,
343 updated_at: now,
344 removed_at: null,
345 };
346 // Two connectors with the same name: the second gets a number.
347 for (let n = 2; n < 50; n++) {
348 try {
349 await db
350 .prepare(
351 `INSERT INTO connectors (id, workspace_id, name, title, url, category, about, auth, header_name, secrets, from_json, tools, added_by, added_at, updated_at)
352 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, ?)`,
353 )
354 .bind(row.id, row.workspace_id, row.name, row.title, row.url, row.category, row.about, row.auth, row.header_name, row.secrets, row.from_json, row.added_by, row.added_at, row.updated_at)
355 .run();
356 break;
357 } catch (error) {
358 if (!String(error).includes("UNIQUE")) throw error;
359 row.name = `${name.slice(0, 29)}-${n}`;
360 if (n === 49) return fail("conflict", `The workspace already has a connector called ${name}.`);
361 }
362 }
363 // An open server, or one with its key: its tools can be listed now.
364 if (auth === "none" || (auth === "header" && secrets)) return ok(await refreshTools(db, deps, row));
365 return ok(row);
366}
367
368/** Puts a key on a header connector (or replaces it), then lists its tools. */
369export async function setKey(db: D1Database, deps: Deps, row: ConnectorRow, headerName: string | null, value: string): Promise<Result<ConnectorRow>> {
370 if (row.auth !== "header") return fail("invalid", "This connector doesn't take a key.");
371 if (!deps.key) return fail("invalid", "Keys can't be kept here: CONNECTORS_KEY isn't set on the agents service.");
372 const key = value.trim();
373 if (!key || key.length > 4000) return fail("invalid", "Paste the key.");
374 const header = headerName?.trim() || row.header_name || "Authorization";
375 if (!/^[A-Za-z0-9-]{1,64}$/.test(header)) return fail("invalid", "A header's name is letters, digits and hyphens.");
376 const sealed = await seal(deps.key, row.id, { header: key } satisfies Secrets);
377 const when = at(deps);
378 await db.prepare("UPDATE connectors SET secrets = ?, header_name = ?, problem = NULL, updated_at = ? WHERE id = ?").bind(sealed, header, when, row.id).run();
379 return ok(await refreshTools(db, deps, { ...row, secrets: sealed, header_name: header, problem: null }));
380}
381
382/** Removes a connector and its grants; the row is kept. */
383export async function removeConnector(db: D1Database, deps: Deps, workspaceId: string, id: string): Promise<Result<null>> {
384 const row = await connectorById(db, workspaceId, id);
385 if (!row) return fail("not_found", "There is no such connector.");
386 const when = at(deps);
387 await db.batch([
388 db.prepare("UPDATE connectors SET removed_at = ?, secrets = NULL, pending = NULL, updated_at = ? WHERE id = ?").bind(when, when, id),
389 db.prepare("DELETE FROM connector_grants WHERE connector_id = ?").bind(id),
390 ]);
391 return ok(null);
392}
393
394/** Removes every connector a plugin brought. */
395export async function removePluginConnectors(db: D1Database, deps: Deps, workspaceId: string, pluginRef: string): Promise<number> {
396 const rows = await listConnectors(db, workspaceId);
397 let n = 0;
398 for (const row of rows) {
399 const from = json<ConnectorFrom>(row.from_json, { kind: "address" });
400 if (from.kind === "plugin" && from.ref === pluginRef) {
401 await removeConnector(db, deps, workspaceId, row.id);
402 n++;
403 }
404 }
405 return n;
406}
407
408// ── OAuth ────────────────────────────────────────────────────────────────
409
410type Metadata = { authorization_endpoint: string; token_endpoint: string; registration_endpoint?: string; scopes_supported?: string[]; code_challenge_methods_supported?: string[] };
411
412async function getJson(fetchFn: Fetch, url: string): Promise<{ status: number; json: Record<string, unknown> | null; headers: Headers }> {
413 try {
414 const response = await fetchFn(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
415 const text = await response.text().catch(() => "");
416 let parsed: Record<string, unknown> | null = null;
417 try {
418 parsed = text ? (JSON.parse(text) as Record<string, unknown>) : null;
419 } catch {
420 parsed = null;
421 }
422 return { status: response.status, json: parsed, headers: response.headers };
423 } catch {
424 return { status: 0, json: null, headers: new Headers() };
425 }
426}
427
428/**
429 * The authorization server for an MCP server: from the 401's
430 * `WWW-Authenticate` resource metadata, from the protected-resource
431 * document at the server's host, or the host itself; then its metadata.
432 */
433export async function discover(fetchFn: Fetch, url: string): Promise<{ ok: true; metadata: Metadata; issuer: string } | { ok: false; message: string }> {
434 const server = new URL(url);
435 const candidates: string[] = [];
436 // 1. The server itself says where (RFC 9728).
437 try {
438 const probe = await fetchFn(url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }), signal: AbortSignal.timeout(15_000) });
439 const challenge = probe.headers.get("www-authenticate") ?? "";
440 const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge)?.[1];
441 if (metadataUrl) candidates.push(metadataUrl);
442 } catch {
443 // The probe is a hint, not a requirement.
444 }
445 const path = server.pathname.replace(/\/$/, "");
446 candidates.push(`${server.origin}/.well-known/oauth-protected-resource${path}`, `${server.origin}/.well-known/oauth-protected-resource`);
447 let issuers: string[] = [];
448 for (const candidate of candidates) {
449 const read = await getJson(fetchFn, candidate);
450 const servers = read.json?.authorization_servers;
451 if (read.status === 200 && Array.isArray(servers) && servers.length) {
452 issuers = servers.filter((s): s is string => typeof s === "string");
453 break;
454 }
455 }
456 if (!issuers.length) issuers = [server.origin];
457 // 2. The authorization server's metadata (RFC 8414, then OpenID).
458 for (const issuer of issuers) {
459 const base = new URL(issuer);
460 const issuerPath = base.pathname.replace(/\/$/, "");
461 for (const candidate of [`${base.origin}/.well-known/oauth-authorization-server${issuerPath}`, `${base.origin}${issuerPath}/.well-known/oauth-authorization-server`, `${base.origin}/.well-known/openid-configuration${issuerPath}`, `${base.origin}${issuerPath}/.well-known/openid-configuration`]) {
462 const read = await getJson(fetchFn, candidate);
463 const m = read.json;
464 if (read.status === 200 && m && typeof m.authorization_endpoint === "string" && typeof m.token_endpoint === "string") {
465 return {
466 ok: true,
467 issuer,
468 metadata: {
469 authorization_endpoint: m.authorization_endpoint,
470 token_endpoint: m.token_endpoint,
471 ...(typeof m.registration_endpoint === "string" ? { registration_endpoint: m.registration_endpoint } : {}),
472 ...(Array.isArray(m.scopes_supported) ? { scopes_supported: m.scopes_supported.filter((s): s is string => typeof s === "string") } : {}),
473 ...(Array.isArray(m.code_challenge_methods_supported) ? { code_challenge_methods_supported: m.code_challenge_methods_supported.filter((s): s is string => typeof s === "string") } : {}),
474 },
475 };
476 }
477 }
478 }
479 return { ok: false, message: "The server doesn't say how to sign in (no OAuth metadata was found). If it takes a key instead, add it with a key." };
480}
481
482/** Registers g1t as a client (RFC 7591), when the server allows it. */
483async function register(fetchFn: Fetch, endpoint: string, redirectUri: string, site: string): Promise<{ ok: true; client_id: string; client_secret?: string } | { ok: false; message: string }> {
484 try {
485 const response = await fetchFn(endpoint, {
486 method: "POST",
487 headers: { "content-type": "application/json", accept: "application/json" },
488 body: JSON.stringify({ client_name: "g1t", client_uri: site, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }),
489 signal: AbortSignal.timeout(15_000),
490 });
491 const body = (await response.json().catch(() => null)) as { client_id?: string; client_secret?: string; error_description?: string; error?: string } | null;
492 if (!response.ok || !body?.client_id) return { ok: false, message: `The server refused to register g1t as a client${body?.error_description ? ` (${body.error_description})` : body?.error ? ` (${body.error})` : ""}.` };
493 return { ok: true, client_id: body.client_id, ...(body.client_secret ? { client_secret: body.client_secret } : {}) };
494 } catch {
495 return { ok: false, message: "The server's client registration couldn't be reached." };
496 }
497}
498
499async function tokenRequest(fetchFn: Fetch, endpoint: string, form: Record<string, string>): Promise<{ ok: true; access_token: string; refresh_token?: string; expires_in?: number; scope?: string } | { ok: false; message: string }> {
500 try {
501 const response = await fetchFn(endpoint, {
502 method: "POST",
503 headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
504 body: new URLSearchParams(form).toString(),
505 signal: AbortSignal.timeout(15_000),
506 });
507 const body = (await response.json().catch(() => null)) as { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string; error?: string; error_description?: string } | null;
508 if (!response.ok || !body?.access_token) return { ok: false, message: `${body?.error_description ?? body?.error ?? `the server answered ${response.status}`}.` };
509 return { ok: true, access_token: body.access_token, ...(body.refresh_token ? { refresh_token: body.refresh_token } : {}), ...(typeof body.expires_in === "number" ? { expires_in: body.expires_in } : {}), ...(body.scope ? { scope: body.scope } : {}) };
510 } catch {
511 return { ok: false, message: "the server couldn't be reached." };
512 }
513}
514
515const random = (bytes: number): string =>
516 b64(crypto.getRandomValues(new Uint8Array(bytes)))
517 .replace(/\+/g, "-")
518 .replace(/\//g, "_")
519 .replace(/=+$/, "");
520
521async function s256(verifier: string): Promise<string> {
522 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
523 return b64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
524}
525
526/** Where the browser goes to sign a connector in; the sign-in's state is kept sealed on the row until it completes. */
527export async function startSignIn(db: D1Database, deps: Deps, row: ConnectorRow, redirectUri: string): Promise<Result<{ url: string }>> {
528 if (row.auth !== "oauth") return fail("invalid", "This connector doesn't sign in with a browser.");
529 if (!deps.key) return fail("invalid", "Sign-ins can't be kept here: CONNECTORS_KEY isn't set on the agents service.");
530 const fetchFn = deps.fetchFn ?? ((u, i) => fetch(u, i));
531 const found = await discover(fetchFn, row.url);
532 if (!found.ok) return fail("invalid", found.message);
533 if (found.metadata.code_challenge_methods_supported && !found.metadata.code_challenge_methods_supported.includes("S256")) return fail("invalid", "The server doesn't support PKCE (S256), which g1t requires.");
534 if (!found.metadata.registration_endpoint) return fail("invalid", "The server doesn't register clients on its own, and g1t has no client registered with it. If it takes a key instead, add it with a key.");
535 const client = await register(fetchFn, found.metadata.registration_endpoint, redirectUri, deps.site);
536 if (!client.ok) return fail("invalid", client.message);
537 const verifier = random(48);
538 const state = `${row.id}.${random(24)}`;
539 const scope = found.metadata.scopes_supported?.length ? found.metadata.scopes_supported.join(" ") : undefined;
540 const pending: Pending = { state, verifier, client_id: client.client_id, ...(client.client_secret ? { client_secret: client.client_secret } : {}), token_endpoint: found.metadata.token_endpoint, redirect_uri: redirectUri, resource: row.url, ...(scope ? { scope } : {}) };
541 await db.prepare("UPDATE connectors SET pending = ?, pending_at = ?, updated_at = ? WHERE id = ?").bind(await seal(deps.key, row.id, pending), at(deps), at(deps), row.id).run();
542 const url = new URL(found.metadata.authorization_endpoint);
543 url.searchParams.set("response_type", "code");
544 url.searchParams.set("client_id", client.client_id);
545 url.searchParams.set("redirect_uri", redirectUri);
546 url.searchParams.set("state", state);
547 url.searchParams.set("code_challenge", await s256(verifier));
548 url.searchParams.set("code_challenge_method", "S256");
549 url.searchParams.set("resource", row.url);
550 if (scope) url.searchParams.set("scope", scope);
551 return ok({ url: url.toString() });
552}
553
554/** The connector a returning sign-in belongs to: the state's first part is its id. */
555export function connectorOfState(state: string): string | null {
556 const id = state.split(".")[0] ?? "";
557 return /^con_[a-z0-9]+$/.test(id) ? id : null;
558}
559
560/** Completes a sign-in with the code the browser brought back, keeps the tokens, and lists the server's tools. */
561export async function finishSignIn(db: D1Database, deps: Deps, row: ConnectorRow, state: string, code: string, by: string): Promise<Result<ConnectorRow>> {
562 if (!deps.key) return fail("invalid", "CONNECTORS_KEY isn't set on the agents service.");
563 const pending = await open<Pending>(deps.key, row.id, row.pending);
564 if (!pending || pending.state !== state) return fail("invalid", "This sign-in isn't the one that was started. Start it again.");
565 if (row.pending_at && (deps.now ?? new Date()).getTime() - Date.parse(row.pending_at) > 15 * 60 * 1000) return fail("invalid", "This sign-in took too long. Start it again.");
566 const fetchFn = deps.fetchFn ?? ((u, i) => fetch(u, i));
567 const token = await tokenRequest(fetchFn, pending.token_endpoint, {
568 grant_type: "authorization_code",
569 code,
570 redirect_uri: pending.redirect_uri,
571 client_id: pending.client_id,
572 code_verifier: pending.verifier,
573 resource: pending.resource,
574 ...(pending.client_secret ? { client_secret: pending.client_secret } : {}),
575 });
576 if (!token.ok) return fail("invalid", `The server didn't give g1t a token: ${token.message}`);
577 const now = Math.floor((deps.now ?? new Date()).getTime() / 1000);
578 const secrets: Secrets = {
579 oauth: {
580 client_id: pending.client_id,
581 ...(pending.client_secret ? { client_secret: pending.client_secret } : {}),
582 access_token: token.access_token,
583 ...(token.refresh_token ? { refresh_token: token.refresh_token } : {}),
584 ...(token.expires_in ? { expires_at: now + token.expires_in } : {}),
585 token_endpoint: pending.token_endpoint,
586 resource: pending.resource,
587 ...(token.scope ?? pending.scope ? { scope: token.scope ?? pending.scope } : {}),
588 },
589 };
590 const when = at(deps);
591 const sealed = await seal(deps.key, row.id, secrets);
592 await db.prepare("UPDATE connectors SET secrets = ?, pending = NULL, pending_at = NULL, signed_in_as = ?, signed_in_at = ?, problem = NULL, updated_at = ? WHERE id = ?").bind(sealed, by, when, when, row.id).run();
593 return ok(await refreshTools(db, deps, { ...row, secrets: sealed, pending: null, pending_at: null, signed_in_as: by, signed_in_at: when, problem: null }));
594}
595
596/** Forgets a sign-in: the tokens go, and the connector needs a sign-in again. */
597export async function signOut(db: D1Database, deps: Deps, row: ConnectorRow): Promise<Result<ConnectorRow>> {
598 if (row.auth !== "oauth") return fail("invalid", "This connector doesn't sign in with a browser.");
599 const when = at(deps);
600 await db.prepare("UPDATE connectors SET secrets = NULL, pending = NULL, pending_at = NULL, signed_in_as = NULL, signed_in_at = NULL, problem = NULL, updated_at = ? WHERE id = ?").bind(when, row.id).run();
601 return ok({ ...row, secrets: null, pending: null, pending_at: null, signed_in_as: null, signed_in_at: null, problem: null, updated_at: when });
602}
603
604// ── Grants ───────────────────────────────────────────────────────────────
605
606/** Gives a connector to every agent, a team's, or one. The same grant twice is one. */
607export async function grant(db: D1Database, deps: Deps, workspaceId: string, connectorId: string, scope: SkillScope, target: string, by: string): Promise<Result<GrantRow>> {
608 if (!["workspace", "team", "agent"].includes(scope)) return fail("invalid", "Give it to every agent, a team, or one agent.");
609 if (scope !== "workspace" && !target) return fail("invalid", "Say which team or agent.");
610 const row: GrantRow = { id: newId("cgr", (deps.now ?? new Date()).getTime()), workspace_id: workspaceId, connector_id: connectorId, scope, target: scope === "workspace" ? "" : target, granted_by: by, granted_at: at(deps) };
611 const inserted = await db
612 .prepare("INSERT INTO connector_grants (id, workspace_id, connector_id, scope, target, granted_by, granted_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING id")
613 .bind(row.id, row.workspace_id, row.connector_id, row.scope, row.target, row.granted_by, row.granted_at)
614 .first<{ id: string }>();
615 if (!inserted) {
616 const existing = await db.prepare("SELECT * FROM connector_grants WHERE connector_id = ? AND scope = ? AND target = ?").bind(connectorId, scope, row.target).first<GrantRow>();
617 return existing ? ok(existing) : fail("conflict", "It is already given there.");
618 }
619 return ok(row);
620}
621
622export async function revoke(db: D1Database, workspaceId: string, grantId: string): Promise<Result<null>> {
623 const done = await db.prepare("DELETE FROM connector_grants WHERE id = ? AND workspace_id = ?").bind(grantId, workspaceId).run();
624 return done.meta.changes ? ok(null) : fail("not_found", "There is no such grant.");
625}
626
627/** The connectors that reach one agent, with how each reaches it (the closest grant wins), connected or not. A switched-off plugin's connectors don't reach anyone. */
628export async function reaching(db: D1Database, workspaceId: string, agentId: string, teams: readonly string[]): Promise<{ row: ConnectorRow; via: SkillScope }[]> {
629 const { results } = await db
630 .prepare(
631 `SELECT c.*, g.scope AS via FROM connector_grants g
632 JOIN connectors c ON c.id = g.connector_id AND c.removed_at IS NULL
633 LEFT JOIN plugin_installs p ON p.workspace_id = c.workspace_id AND p.ref = json_extract(c.from_json, '$.ref') AND p.uninstalled_at IS NULL
634 WHERE g.workspace_id = ?1
635 AND (json_extract(c.from_json, '$.kind') <> 'plugin' OR p.enabled = 1)
636 AND (g.scope = 'workspace' OR (g.scope = 'agent' AND g.target = ?2) OR (g.scope = 'team' AND g.target IN (SELECT value FROM json_each(?3))))
637 ORDER BY c.title COLLATE NOCASE LIMIT 200`,
638 )
639 .bind(workspaceId, agentId, JSON.stringify(teams))
640 .all<ConnectorRow & { via: SkillScope }>();
641 const order: Record<SkillScope, number> = { agent: 0, team: 1, workspace: 2 };
642 const best = new Map<string, { row: ConnectorRow; via: SkillScope }>();
643 for (const found of results) {
644 const { via, ...row } = found;
645 const have = best.get(row.id);
646 if (!have || order[via] < order[have.via]) best.set(row.id, { row, via });
647 }
648 return [...best.values()];
649}
650
651/** A connector as an agent's turn gets it: the same shape as its own MCP servers, with the connector's id. */
652export function asServer(row: ConnectorRow): McpServer {
653 return { id: row.id, name: row.name, url: row.url, tools: json<McpTool[]>(row.tools, []).slice(0, MAX_MCP_TOOLS), added_by: row.added_by, added_at: row.added_at, checked_at: row.checked_at, problem: row.problem };
654}
655
656/** What an agent's Abilities tab lists for connectors: those that reach it, and its own servers. */
657export function agentConnectorsOut(reach: { row: ConnectorRow; via: SkillScope }[], own: McpServer[], labels: { teamName: (slug: string) => string }): AgentConnector[] {
658 const out: AgentConnector[] = reach.map(({ row, via }) => ({
659 id: row.id,
660 name: row.name,
661 title: row.title,
662 state: stateOf(row),
663 via,
664 via_label: via === "workspace" ? "Every agent" : via === "team" ? `Team ${labels.teamName(row.name)}` : "This agent",
665 tools: json<McpTool[]>(row.tools, []).length,
666 }));
667 for (const server of own) out.push({ id: server.id, name: server.name, title: server.name, state: server.problem ? "problem" : "connected", via: "agent-only", via_label: "Only this agent", tools: server.tools.length });
668 return out;
669}