@fungi.computer/veil 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connections/cleanup.d.ts +21 -0
- package/dist/connections/cleanup.js +66 -0
- package/dist/connections/contract.d.ts +153 -7
- package/dist/connections/contract.js +42 -2
- package/dist/connections/core.d.ts +55 -5
- package/dist/connections/core.js +365 -106
- package/dist/connections/discover.d.ts +3 -0
- package/dist/connections/discover.js +23 -0
- package/dist/connections/egress.js +3 -2
- package/dist/connections/index.d.ts +1 -0
- package/dist/connections/index.js +1 -0
- package/dist/connections/internal.d.ts +78 -0
- package/dist/connections/internal.js +66 -0
- package/dist/connections/mcp-agent.d.ts +37 -0
- package/dist/connections/mcp-agent.js +75 -0
- package/dist/connections/nango.d.ts +30 -0
- package/dist/connections/nango.js +136 -0
- package/dist/connections/oauth-flow.d.ts +81 -0
- package/dist/connections/oauth-flow.js +322 -0
- package/dist/connections/oauth-provider.d.ts +31 -0
- package/dist/connections/oauth-provider.js +96 -0
- package/dist/connections/oauth.d.ts +93 -0
- package/dist/connections/oauth.js +239 -0
- package/dist/connections/openapi.d.ts +36 -0
- package/dist/connections/openapi.js +120 -0
- package/dist/connections/sealed.js +23 -4
- package/dist/connections/source.d.ts +17 -0
- package/dist/connections/source.js +111 -0
- package/dist/connections/update.d.ts +41 -0
- package/dist/connections/update.js +81 -0
- package/dist/gatekeeper.d.ts +21 -1
- package/dist/gatekeeper.js +74 -12
- package/dist/nango.d.ts +41 -0
- package/dist/nango.js +6 -6
- package/package.json +11 -1
package/dist/connections/core.js
CHANGED
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect";
|
|
2
2
|
import * as Schema from "effect/Schema";
|
|
3
|
-
import { parseOpenApiTable } from "@fungi.computer/forage/openapi";
|
|
4
3
|
import { assertPublicHostName } from "../model-key-network.js";
|
|
5
4
|
import { createPublicEgressPolicy } from "../public-egress-policy.js";
|
|
6
|
-
import { ConnectionsError,
|
|
5
|
+
import { ConnectionsError, callInputSchema, completeInputSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
|
|
7
6
|
import { admitRequest } from "./egress.js";
|
|
7
|
+
import { admits, boundedText, decodeTable, operationOutput, operationRequest, } from "./openapi.js";
|
|
8
|
+
import { createCleanup } from "./cleanup.js";
|
|
9
|
+
import { attemptRow, connectionRow, count, decode, io, json, registryRow, sync, } from "./internal.js";
|
|
10
|
+
import { createOAuthFlow } from "./oauth-flow.js";
|
|
11
|
+
import { listMcpTools } from "./discover.js";
|
|
12
|
+
import { createUpdate } from "./update.js";
|
|
8
13
|
const attemptTtlMs = 10 * 60 * 1000;
|
|
9
|
-
const cleanupLimit = 5;
|
|
10
|
-
const json = (schema) => Schema.fromJsonString(schema);
|
|
11
|
-
const connectionRow = Schema.Struct({
|
|
12
|
-
id: identifier,
|
|
13
|
-
name: Schema.String,
|
|
14
|
-
surface: json(Schema.Struct({
|
|
15
|
-
kind: Schema.Literals(["mcp", "openapi"]),
|
|
16
|
-
url: Schema.String,
|
|
17
|
-
table: Schema.NullOr(Schema.Unknown),
|
|
18
|
-
})),
|
|
19
|
-
credential: json(credentialSchema),
|
|
20
|
-
locator: Schema.NullOr(Schema.String),
|
|
21
|
-
state: Schema.Literals(["active", "reconnect_required", "revoked"]),
|
|
22
|
-
authorityVersion: Schema.Number,
|
|
23
|
-
});
|
|
24
14
|
const grantRow = Schema.Struct({
|
|
25
15
|
connectionId: identifier,
|
|
26
16
|
agentId: identifier,
|
|
@@ -31,40 +21,18 @@ const grantRow = Schema.Struct({
|
|
|
31
21
|
});
|
|
32
22
|
const receiptRow = Schema.Struct({
|
|
33
23
|
fingerprint: Schema.String,
|
|
34
|
-
result: json(Schema.Struct({
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
locator: Schema.String,
|
|
40
|
-
credential: json(credentialSchema),
|
|
41
|
-
attempts: Schema.Number,
|
|
42
|
-
});
|
|
43
|
-
const count = Schema.Struct({ n: Schema.Number });
|
|
44
|
-
function decode(schema, input) {
|
|
45
|
-
try {
|
|
46
|
-
return Schema.decodeUnknownSync(schema)(input);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
throw new ConnectionsError("invalid_input");
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
const sync = (operation) => Effect.try({
|
|
53
|
-
try: operation,
|
|
54
|
-
catch: (cause) => cause instanceof ConnectionsError
|
|
55
|
-
? cause
|
|
56
|
-
: new ConnectionsError("unavailable", { cause }),
|
|
57
|
-
});
|
|
58
|
-
const io = (operation) => Effect.tryPromise({
|
|
59
|
-
try: operation,
|
|
60
|
-
catch: (cause) => cause instanceof ConnectionsError
|
|
61
|
-
? cause
|
|
62
|
-
: new ConnectionsError("unavailable", { cause }),
|
|
24
|
+
result: json(Schema.Struct({
|
|
25
|
+
connectionId: identifier,
|
|
26
|
+
authorizeUrl: Schema.optional(Schema.String),
|
|
27
|
+
version: Schema.optional(Schema.Number),
|
|
28
|
+
})),
|
|
63
29
|
});
|
|
64
30
|
/** One owner's connections: attempts, connections, grants, receipts and cleanup in its SQLite. */
|
|
65
31
|
export function createConnections(options) {
|
|
66
32
|
const ownerId = decode(identifier, options.ownerId);
|
|
67
|
-
const { sql
|
|
33
|
+
const { sql } = options.store;
|
|
34
|
+
// A Durable Object's storage must be called as a method, never detached.
|
|
35
|
+
const transactionSync = (body) => options.store.transactionSync(body);
|
|
68
36
|
const policy = createPublicEgressPolicy(options.egressPolicy);
|
|
69
37
|
sql.exec(`
|
|
70
38
|
CREATE TABLE IF NOT EXISTS veil_connection_attempts (
|
|
@@ -73,7 +41,8 @@ export function createConnections(options) {
|
|
|
73
41
|
pkceVerifierSealed TEXT, issuer TEXT) STRICT;
|
|
74
42
|
CREATE TABLE IF NOT EXISTS veil_connections (
|
|
75
43
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, surface TEXT NOT NULL, credential TEXT NOT NULL,
|
|
76
|
-
locator TEXT, state TEXT NOT NULL, authorityVersion INTEGER NOT NULL
|
|
44
|
+
locator TEXT, state TEXT NOT NULL, authorityVersion INTEGER NOT NULL,
|
|
45
|
+
version INTEGER NOT NULL) STRICT;
|
|
77
46
|
CREATE TABLE IF NOT EXISTS veil_connection_grants (
|
|
78
47
|
connectionId TEXT NOT NULL, agentId TEXT NOT NULL, tools TEXT NOT NULL,
|
|
79
48
|
grantor TEXT NOT NULL, version INTEGER NOT NULL, active INTEGER NOT NULL,
|
|
@@ -83,6 +52,8 @@ export function createConnections(options) {
|
|
|
83
52
|
CREATE TABLE IF NOT EXISTS veil_connection_cleanup (
|
|
84
53
|
id INTEGER PRIMARY KEY AUTOINCREMENT, connectionId TEXT NOT NULL, locator TEXT NOT NULL,
|
|
85
54
|
credential TEXT NOT NULL, attempts INTEGER NOT NULL) STRICT;
|
|
55
|
+
CREATE TABLE IF NOT EXISTS veil_oauth_clients (
|
|
56
|
+
issuer TEXT PRIMARY KEY, locator TEXT NOT NULL) STRICT;
|
|
86
57
|
CREATE TABLE IF NOT EXISTS veil_connection_epoch (id INTEGER PRIMARY KEY CHECK (id = 1), revision INTEGER NOT NULL) STRICT;
|
|
87
58
|
INSERT OR IGNORE INTO veil_connection_epoch VALUES (1, 1);`);
|
|
88
59
|
const rows = (schema, statement, ...bindings) => sql
|
|
@@ -101,6 +72,12 @@ export function createConnections(options) {
|
|
|
101
72
|
throw new ConnectionsError("denied");
|
|
102
73
|
return row;
|
|
103
74
|
};
|
|
75
|
+
/** One live connection per binding name, so an Agent's module name is unambiguous. */
|
|
76
|
+
const assertNameFree = (name, exceptId) => {
|
|
77
|
+
const taken = rows(count, "SELECT COUNT(*) AS n FROM veil_connections WHERE name = ? AND state != 'revoked' AND id != ?", name, exceptId ?? "")[0];
|
|
78
|
+
if (taken === undefined || taken.n > 0)
|
|
79
|
+
throw new ConnectionsError("conflict");
|
|
80
|
+
};
|
|
104
81
|
const grant = (id, agentId) => rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND agentId = ?", id, agentId)[0];
|
|
105
82
|
const address = (row) => ({
|
|
106
83
|
ownerId,
|
|
@@ -127,30 +104,18 @@ export function createConnections(options) {
|
|
|
127
104
|
}
|
|
128
105
|
return url.href;
|
|
129
106
|
};
|
|
130
|
-
const sweep
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}, job.locator)
|
|
139
|
-
.pipe(Effect.result);
|
|
140
|
-
yield* sync(() => {
|
|
141
|
-
if (outcome._tag === "Success")
|
|
142
|
-
sql.exec("DELETE FROM veil_connection_cleanup WHERE id = ?", job.id);
|
|
143
|
-
else
|
|
144
|
-
sql.exec("UPDATE veil_connection_cleanup SET attempts = attempts + 1 WHERE id = ?", job.id);
|
|
145
|
-
});
|
|
146
|
-
}
|
|
107
|
+
const { cleanupPending, sweep, enqueueLocator, enqueueCleanup, markReconnect, retireAttempt, } = createCleanup({
|
|
108
|
+
ownerId,
|
|
109
|
+
custody: options.custody,
|
|
110
|
+
clock: options.clock,
|
|
111
|
+
sql,
|
|
112
|
+
transactionSync,
|
|
113
|
+
rows,
|
|
114
|
+
bump,
|
|
147
115
|
});
|
|
148
|
-
const enqueueCleanup = (row) => {
|
|
149
|
-
if (row.locator !== null)
|
|
150
|
-
sql.exec("INSERT INTO veil_connection_cleanup (connectionId, locator, credential, attempts) VALUES (?, ?, ?, 0)", row.id, row.locator, JSON.stringify(row.credential));
|
|
151
|
-
};
|
|
152
116
|
const fingerprint = (draft) => JSON.stringify([
|
|
153
117
|
draft.name,
|
|
118
|
+
draft.label ?? null,
|
|
154
119
|
draft.surface.kind,
|
|
155
120
|
"url" in draft.surface ? draft.surface.url : draft.surface.baseUrl,
|
|
156
121
|
draft.credential,
|
|
@@ -160,9 +125,27 @@ export function createConnections(options) {
|
|
|
160
125
|
const draft = yield* sync(() => {
|
|
161
126
|
authorize(actor, "manage");
|
|
162
127
|
const parsed = decode(draftSchema, input);
|
|
163
|
-
|
|
164
|
-
?
|
|
165
|
-
: parsed.
|
|
128
|
+
const wantsSecret = parsed.credential.kind === "static"
|
|
129
|
+
? "required"
|
|
130
|
+
: parsed.credential.kind === "oauth"
|
|
131
|
+
? "client"
|
|
132
|
+
: "never";
|
|
133
|
+
if ((wantsSecret === "required" && parsed.secret === undefined) ||
|
|
134
|
+
(wantsSecret === "never" && parsed.secret !== undefined) ||
|
|
135
|
+
(parsed.credential.kind === "oauth" &&
|
|
136
|
+
// An MCP server signs in at itself; a REST connection through a
|
|
137
|
+
// provider the operator registered, with the operator's client; or
|
|
138
|
+
// a custodian that runs the authorization (its integration id).
|
|
139
|
+
(parsed.credential.provider === undefined
|
|
140
|
+
? parsed.surface.kind !== "mcp" ||
|
|
141
|
+
(parsed.secret !== undefined &&
|
|
142
|
+
parsed.credential.clientId === undefined)
|
|
143
|
+
: parsed.secret !== undefined ||
|
|
144
|
+
parsed.credential.clientId !== undefined ||
|
|
145
|
+
(options.oauth?.providers?.[parsed.credential.provider] ===
|
|
146
|
+
undefined
|
|
147
|
+
? options.custody.connect === undefined
|
|
148
|
+
: parsed.surface.kind !== "openapi"))))
|
|
166
149
|
throw new ConnectionsError("invalid_input");
|
|
167
150
|
return parsed;
|
|
168
151
|
});
|
|
@@ -172,17 +155,33 @@ export function createConnections(options) {
|
|
|
172
155
|
return null;
|
|
173
156
|
if (receipt.fingerprint !== fingerprint(draft))
|
|
174
157
|
throw new ConnectionsError("conflict");
|
|
175
|
-
return receipt.result
|
|
158
|
+
return receipt.result;
|
|
176
159
|
});
|
|
177
160
|
if (replay !== null)
|
|
178
|
-
return {
|
|
161
|
+
return {
|
|
162
|
+
connectionId: replay.connectionId,
|
|
163
|
+
authorizeUrl: replay.authorizeUrl ?? null,
|
|
164
|
+
};
|
|
165
|
+
yield* sync(() => {
|
|
166
|
+
assertNameFree(draft.name, null);
|
|
167
|
+
});
|
|
179
168
|
const endpoint = yield* sync(() => publicUrl(draft.surface.kind === "mcp"
|
|
180
169
|
? draft.surface.url
|
|
181
170
|
: draft.surface.baseUrl));
|
|
182
171
|
const surface = draft.surface;
|
|
183
|
-
const
|
|
184
|
-
?
|
|
172
|
+
const decoded = surface.kind === "openapi"
|
|
173
|
+
? yield* io(() => decodeTable(surface.table))
|
|
185
174
|
: null;
|
|
175
|
+
if (draft.credential.kind === "oauth") {
|
|
176
|
+
const table = decoded === null
|
|
177
|
+
? null
|
|
178
|
+
: { table: decoded.operations, digest: decoded.digest };
|
|
179
|
+
const viaCustodian = draft.credential.provider !== undefined &&
|
|
180
|
+
options.oauth?.providers?.[draft.credential.provider] === undefined;
|
|
181
|
+
return yield* viaCustodian
|
|
182
|
+
? oauthFlow.beginCustody(actor, draft, draft.credential, endpoint, fingerprint(draft), table)
|
|
183
|
+
: oauthFlow.begin(actor, draft, draft.credential, endpoint, fingerprint(draft), table);
|
|
184
|
+
}
|
|
186
185
|
const connectionId = options.ids();
|
|
187
186
|
const state = options.ids();
|
|
188
187
|
yield* sync(() => {
|
|
@@ -200,18 +199,22 @@ export function createConnections(options) {
|
|
|
200
199
|
locator,
|
|
201
200
|
state: "active",
|
|
202
201
|
authorityVersion: 1,
|
|
202
|
+
version: 1,
|
|
203
203
|
surface: {
|
|
204
204
|
kind: draft.surface.kind,
|
|
205
205
|
url: endpoint,
|
|
206
|
-
table,
|
|
206
|
+
table: decoded?.operations ?? null,
|
|
207
|
+
digest: decoded?.digest ?? null,
|
|
208
|
+
label: draft.label ?? draft.name,
|
|
207
209
|
},
|
|
208
210
|
};
|
|
209
211
|
return yield* sync(() => transactionSync(() => {
|
|
210
212
|
authorize(actor, "manage");
|
|
213
|
+
assertNameFree(stored.name, null);
|
|
211
214
|
const attempt = rows(count, "SELECT COUNT(*) AS n FROM veil_connection_attempts WHERE state = ? AND status = 'pending' AND expiresAt > ?", state, options.clock())[0];
|
|
212
215
|
if (attempt?.n !== 1)
|
|
213
216
|
throw new ConnectionsError("denied");
|
|
214
|
-
sql.exec("INSERT INTO veil_connections VALUES (?, ?, ?, ?, ?, 'active', 1)", stored.id, stored.name, JSON.stringify(stored.surface), JSON.stringify(stored.credential), stored.locator);
|
|
217
|
+
sql.exec("INSERT INTO veil_connections VALUES (?, ?, ?, ?, ?, 'active', 1, 1)", stored.id, stored.name, JSON.stringify(stored.surface), JSON.stringify(stored.credential), stored.locator);
|
|
215
218
|
sql.exec("UPDATE veil_connection_attempts SET status = 'completed' WHERE state = ?", state);
|
|
216
219
|
sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", draft.requestId, fingerprint(draft), JSON.stringify({ connectionId }));
|
|
217
220
|
bump();
|
|
@@ -220,15 +223,113 @@ export function createConnections(options) {
|
|
|
220
223
|
enqueueCleanup(stored);
|
|
221
224
|
})));
|
|
222
225
|
});
|
|
226
|
+
const oauthStore = {
|
|
227
|
+
connection,
|
|
228
|
+
recordPending: (input) => {
|
|
229
|
+
transactionSync(() => {
|
|
230
|
+
authorize(input.actor, "manage");
|
|
231
|
+
const stored = input.connection;
|
|
232
|
+
assertNameFree(stored.name, null);
|
|
233
|
+
sql.exec("INSERT INTO veil_connections VALUES (?, ?, ?, ?, NULL, 'pending', 1, 1)", stored.id, stored.name, JSON.stringify(stored.surface), JSON.stringify(stored.credential));
|
|
234
|
+
sql.exec("INSERT INTO veil_connection_attempts VALUES (?, ?, ?, 'pending', ?, ?, ?)", input.state, stored.id, input.actor, options.clock() + attemptTtlMs, input.attemptLocator, input.issuer);
|
|
235
|
+
sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", input.requestId, input.fingerprint, JSON.stringify({
|
|
236
|
+
connectionId: stored.id,
|
|
237
|
+
authorizeUrl: input.authorizeUrl,
|
|
238
|
+
}));
|
|
239
|
+
bump();
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
claimCustody: (state) => transactionSync(() => {
|
|
243
|
+
if (!state.startsWith(`${ownerId}.`))
|
|
244
|
+
throw new ConnectionsError("denied");
|
|
245
|
+
const attempt = rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE state = ?", state)[0];
|
|
246
|
+
// A browser attempt holds a sealed verifier; only a custodian's has none.
|
|
247
|
+
if (attempt?.status !== "pending" ||
|
|
248
|
+
attempt.pkceVerifierSealed !== null)
|
|
249
|
+
throw new ConnectionsError("denied");
|
|
250
|
+
if (attempt.expiresAt <= options.clock()) {
|
|
251
|
+
retireAttempt(attempt, "expired");
|
|
252
|
+
return { attempt, expired: true };
|
|
253
|
+
}
|
|
254
|
+
sql.exec("UPDATE veil_connection_attempts SET status = 'consumed' WHERE state = ?", state);
|
|
255
|
+
return { attempt, expired: false };
|
|
256
|
+
}),
|
|
257
|
+
claim: (actor, input) => transactionSync(() => {
|
|
258
|
+
authorize(actor, "manage");
|
|
259
|
+
const wanted = decode(completeInputSchema, input);
|
|
260
|
+
if (!wanted.state.startsWith(`${ownerId}.`))
|
|
261
|
+
throw new ConnectionsError("denied");
|
|
262
|
+
const attempt = rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE state = ?", wanted.state)[0];
|
|
263
|
+
if (attempt?.status !== "pending" ||
|
|
264
|
+
attempt.actor !== actor ||
|
|
265
|
+
attempt.pkceVerifierSealed === null)
|
|
266
|
+
throw new ConnectionsError("denied");
|
|
267
|
+
if (attempt.expiresAt <= options.clock()) {
|
|
268
|
+
retireAttempt(attempt, "expired");
|
|
269
|
+
return { attempt, wanted, expired: true };
|
|
270
|
+
}
|
|
271
|
+
sql.exec("UPDATE veil_connection_attempts SET status = 'consumed' WHERE state = ?", attempt.state);
|
|
272
|
+
return { attempt, wanted, expired: false };
|
|
273
|
+
}),
|
|
274
|
+
fail: (attempt) => {
|
|
275
|
+
transactionSync(() => {
|
|
276
|
+
retireAttempt(attempt, "failed");
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
activate: (row, attempt, locator) => transactionSync(() => {
|
|
280
|
+
const current = connection(row.id);
|
|
281
|
+
if (current.state !== "pending")
|
|
282
|
+
return false;
|
|
283
|
+
sql.exec("UPDATE veil_connections SET state = 'active', locator = ?, authorityVersion = authorityVersion + 1 WHERE id = ?", locator, row.id);
|
|
284
|
+
sql.exec("UPDATE veil_connection_attempts SET status = 'completed', pkceVerifierSealed = NULL WHERE state = ?", attempt.state);
|
|
285
|
+
if (attempt.pkceVerifierSealed !== null)
|
|
286
|
+
enqueueLocator(row.id, attempt.pkceVerifierSealed, row.credential);
|
|
287
|
+
bump();
|
|
288
|
+
return true;
|
|
289
|
+
}),
|
|
290
|
+
discard: (connectionId, locator, credential) => {
|
|
291
|
+
transactionSync(() => {
|
|
292
|
+
enqueueLocator(connectionId, locator, credential);
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
swap: (row, held, locator) => transactionSync(() => {
|
|
296
|
+
const current = connection(row.id);
|
|
297
|
+
if (current.state !== "active" || current.locator !== held)
|
|
298
|
+
return current.state;
|
|
299
|
+
sql.exec("UPDATE veil_connections SET locator = ? WHERE id = ?", locator, row.id);
|
|
300
|
+
enqueueLocator(row.id, held, row.credential);
|
|
301
|
+
return "applied";
|
|
302
|
+
}),
|
|
303
|
+
clientLocator: (issuer) => rows(registryRow, "SELECT locator FROM veil_oauth_clients WHERE issuer = ?", issuer)[0]?.locator,
|
|
304
|
+
saveClientLocator: (issuer, locator) => {
|
|
305
|
+
transactionSync(() => {
|
|
306
|
+
const old = rows(registryRow, "SELECT locator FROM veil_oauth_clients WHERE issuer = ?", issuer)[0];
|
|
307
|
+
sql.exec("INSERT OR REPLACE INTO veil_oauth_clients VALUES (?, ?)", issuer, locator);
|
|
308
|
+
if (old !== undefined)
|
|
309
|
+
enqueueLocator("oauth-clients", old.locator, { kind: "oauth" });
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
const oauthFlow = createOAuthFlow({
|
|
314
|
+
ownerId,
|
|
315
|
+
custody: options.custody,
|
|
316
|
+
oauth: options.oauth,
|
|
317
|
+
clock: options.clock,
|
|
318
|
+
ids: options.ids,
|
|
319
|
+
policy,
|
|
320
|
+
store: oauthStore,
|
|
321
|
+
sweep,
|
|
322
|
+
});
|
|
223
323
|
const grantTo = (actor, input) => sync(() => transactionSync(() => {
|
|
224
324
|
authorize(actor, "manage");
|
|
225
325
|
const wanted = decode(grantInputSchema, input);
|
|
226
326
|
const row = connection(wanted.connectionId);
|
|
227
327
|
if (row.state === "revoked")
|
|
228
328
|
throw new ConnectionsError("revoked");
|
|
229
|
-
if (wanted.tools
|
|
230
|
-
|
|
231
|
-
|
|
329
|
+
if (wanted.tools !== "all" &&
|
|
330
|
+
(Array.isArray(wanted.tools)
|
|
331
|
+
? row.surface.kind !== "mcp" && wanted.tools.length === 0
|
|
332
|
+
: row.surface.kind !== "openapi"))
|
|
232
333
|
throw new ConnectionsError("invalid_input");
|
|
233
334
|
const tools = JSON.stringify(wanted.tools);
|
|
234
335
|
const existing = grant(wanted.connectionId, wanted.agentId);
|
|
@@ -258,6 +359,8 @@ export function createConnections(options) {
|
|
|
258
359
|
const row = connection(decode(identifier, input.connectionId));
|
|
259
360
|
if (row.state === "revoked")
|
|
260
361
|
return;
|
|
362
|
+
for (const attempt of rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE connectionId = ? AND status IN ('pending', 'consumed')", row.id))
|
|
363
|
+
retireAttempt(attempt, "revoked");
|
|
261
364
|
sql.exec("UPDATE veil_connections SET state = 'revoked', locator = NULL, authorityVersion = authorityVersion + 1 WHERE id = ?", row.id);
|
|
262
365
|
sql.exec("UPDATE veil_connection_grants SET active = 0, version = version + 1 WHERE connectionId = ? AND active = 1", row.id);
|
|
263
366
|
enqueueCleanup(row);
|
|
@@ -280,19 +383,22 @@ export function createConnections(options) {
|
|
|
280
383
|
});
|
|
281
384
|
const connections = [];
|
|
282
385
|
for (const { g, row } of stored.granted) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
386
|
+
let surface = { kind: "mcp", url: row.surface.url };
|
|
387
|
+
if (row.surface.kind === "openapi") {
|
|
388
|
+
const decoded = yield* io(() => decodeTable(row.surface.table));
|
|
389
|
+
surface = {
|
|
286
390
|
kind: "openapi",
|
|
287
391
|
baseUrl: row.surface.url,
|
|
288
|
-
table:
|
|
289
|
-
|
|
392
|
+
table: decoded.operations,
|
|
393
|
+
digest: decoded.digest,
|
|
290
394
|
};
|
|
395
|
+
}
|
|
291
396
|
connections.push({
|
|
292
397
|
connectionId: row.id,
|
|
293
398
|
name: row.name,
|
|
294
399
|
surface,
|
|
295
400
|
tools: g.tools,
|
|
401
|
+
version: row.version,
|
|
296
402
|
authorityVersion: row.authorityVersion,
|
|
297
403
|
grantVersion: g.version,
|
|
298
404
|
});
|
|
@@ -307,17 +413,23 @@ export function createConnections(options) {
|
|
|
307
413
|
return rows(connectionRow, "SELECT * FROM veil_connections ORDER BY id").map((row) => ({
|
|
308
414
|
connectionId: row.id,
|
|
309
415
|
name: row.name,
|
|
416
|
+
label: row.surface.label ?? row.name,
|
|
310
417
|
surface: {
|
|
311
418
|
kind: row.surface.kind,
|
|
312
419
|
origin: new URL(row.surface.url).origin,
|
|
420
|
+
url: row.surface.url,
|
|
421
|
+
digest: row.surface.digest ?? null,
|
|
313
422
|
},
|
|
314
423
|
credentialKind: row.credential.kind,
|
|
424
|
+
credential: row.credential,
|
|
315
425
|
state: row.state,
|
|
426
|
+
version: row.version,
|
|
316
427
|
authorityVersion: row.authorityVersion,
|
|
317
428
|
grants: rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND active = 1", row.id).map((g) => ({
|
|
318
429
|
agentId: g.agentId,
|
|
319
430
|
tools: g.tools,
|
|
320
431
|
version: g.version,
|
|
432
|
+
grantor: g.grantor,
|
|
321
433
|
})),
|
|
322
434
|
}));
|
|
323
435
|
});
|
|
@@ -330,43 +442,190 @@ export function createConnections(options) {
|
|
|
330
442
|
held?.active !== 1 ||
|
|
331
443
|
held.version !== ref.grantVersion)
|
|
332
444
|
throw new ConnectionsError("revoked");
|
|
445
|
+
if (row.state === "pending")
|
|
446
|
+
throw new ConnectionsError("pending");
|
|
333
447
|
if (row.state === "reconnect_required")
|
|
334
448
|
throw new ConnectionsError("reconnect_required");
|
|
335
449
|
authorize(held.grantor, "grantor");
|
|
336
|
-
return { row, tools: held.tools };
|
|
450
|
+
return { row, tools: held.tools, grantor: held.grantor, agentId };
|
|
337
451
|
};
|
|
452
|
+
/**
|
|
453
|
+
* One request through every gate: the grant, the host's last live check, the
|
|
454
|
+
* credential at the owner, and the grant again on the way back.
|
|
455
|
+
*/
|
|
456
|
+
const deliver = (admit, prepare) => Effect.gen(function* () {
|
|
457
|
+
const { row, tools, grantor, agentId } = yield* sync(admit);
|
|
458
|
+
const { request, operation } = yield* prepare(row, tools);
|
|
459
|
+
const verdict = options.check;
|
|
460
|
+
if (verdict !== undefined &&
|
|
461
|
+
!(yield* io(() => verdict(grantor, { agentId, connectionId: row.id, tools }, operation))))
|
|
462
|
+
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
463
|
+
yield* sync(admit);
|
|
464
|
+
if (row.locator === null && row.credential.kind === "static")
|
|
465
|
+
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
466
|
+
const response = yield* // A custodian that runs the sign-in refreshes the token itself.
|
|
467
|
+
(row.credential.kind === "oauth" && options.custody.open !== undefined
|
|
468
|
+
? oauthFlow.send(row, request)
|
|
469
|
+
: options.custody.inject(address(row), row.locator ?? "", request)).pipe(Effect.tapError((error) => error.code === "reconnect_required"
|
|
470
|
+
? sync(() => {
|
|
471
|
+
markReconnect(row.id);
|
|
472
|
+
})
|
|
473
|
+
: Effect.void));
|
|
474
|
+
const stillCurrent = yield* sync(admit).pipe(Effect.result);
|
|
475
|
+
if (stillCurrent._tag === "Failure" ||
|
|
476
|
+
response.redirected ||
|
|
477
|
+
(response.status >= 300 && response.status < 400)) {
|
|
478
|
+
yield* io(() => response.body?.cancel() ?? Promise.resolve());
|
|
479
|
+
return yield* Effect.fail(stillCurrent._tag === "Failure"
|
|
480
|
+
? stillCurrent.failure
|
|
481
|
+
: new ConnectionsError("invalid_response"));
|
|
482
|
+
}
|
|
483
|
+
return response;
|
|
484
|
+
});
|
|
485
|
+
const fetchThrough = (admit, target, init) => Effect.runPromise(deliver(admit, (row, tools) => row.surface.kind === "openapi" && tools !== "all"
|
|
486
|
+
? // Effect and name grants are enforced per operation, by `call`.
|
|
487
|
+
Effect.fail(new ConnectionsError("denied"))
|
|
488
|
+
: io(() => admitRequest({ kind: row.surface.kind, url: row.surface.url }, tools, new Request(target, init), policy)).pipe(Effect.map((request) => ({ request, operation: null })))));
|
|
338
489
|
const egress = (agentId, input) => {
|
|
339
490
|
const ref = decode(egressRefSchema, input);
|
|
340
|
-
return (target, init) =>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
491
|
+
return (target, init) => fetchThrough(() => admitted(agentId, ref), target, init);
|
|
492
|
+
};
|
|
493
|
+
/** A typed OpenAPI operation: built from the stored table, checked against its stored schemas. */
|
|
494
|
+
const callThrough = (admit, raw) => Effect.gen(function* () {
|
|
495
|
+
const wanted = yield* sync(() => decode(callInputSchema, raw));
|
|
496
|
+
let chosen;
|
|
497
|
+
const response = yield* deliver(admit, (row, tools) => Effect.gen(function* () {
|
|
498
|
+
if (row.surface.kind !== "openapi")
|
|
499
|
+
return yield* Effect.fail(new ConnectionsError("unsupported"));
|
|
500
|
+
const table = yield* io(() => decodeTable(row.surface.table));
|
|
501
|
+
chosen = table.operations.find((candidate) => candidate.name === wanted.operation);
|
|
502
|
+
if (chosen === undefined || !admits(tools, chosen))
|
|
344
503
|
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
response.redirected ||
|
|
353
|
-
(response.status >= 300 && response.status < 400)) {
|
|
354
|
-
yield* io(() => response.body?.cancel() ?? Promise.resolve());
|
|
355
|
-
return yield* Effect.fail(stillCurrent._tag === "Failure"
|
|
356
|
-
? stillCurrent.failure
|
|
357
|
-
: new ConnectionsError("invalid_response"));
|
|
358
|
-
}
|
|
359
|
-
return response;
|
|
504
|
+
const picked = chosen;
|
|
505
|
+
const built = yield* io(() => operationRequest(row.surface.url, picked, wanted.input));
|
|
506
|
+
const request = yield* io(() => admitRequest({ kind: "openapi", url: row.surface.url }, "all", built, policy));
|
|
507
|
+
return {
|
|
508
|
+
request,
|
|
509
|
+
operation: { name: picked.name, effect: picked.effect },
|
|
510
|
+
};
|
|
360
511
|
}));
|
|
512
|
+
const operation = chosen;
|
|
513
|
+
if (operation === undefined)
|
|
514
|
+
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
515
|
+
const text = yield* io(() => boundedText(response));
|
|
516
|
+
// A grant ended while the body streamed: nothing read may be returned.
|
|
517
|
+
yield* sync(admit);
|
|
518
|
+
if (!response.ok)
|
|
519
|
+
return yield* Effect.fail(new ConnectionsError("invalid_response"));
|
|
520
|
+
return yield* io(() => operationOutput(operation, text));
|
|
521
|
+
});
|
|
522
|
+
const call = (agentId, input, raw) => {
|
|
523
|
+
const ref = decode(egressRefSchema, input);
|
|
524
|
+
return callThrough(() => admitted(agentId, ref), raw);
|
|
525
|
+
};
|
|
526
|
+
/** The owner as sender: no Agent grant, and a version they last saw, when they name one. */
|
|
527
|
+
const ownerAdmit = (actor, connectionId, expectedVersion) => {
|
|
528
|
+
authorize(actor, "manage");
|
|
529
|
+
const row = connection(connectionId);
|
|
530
|
+
if (row.state === "revoked")
|
|
531
|
+
throw new ConnectionsError("revoked");
|
|
532
|
+
if (expectedVersion !== null && row.version !== expectedVersion)
|
|
533
|
+
throw new ConnectionsError("conflict");
|
|
534
|
+
if (row.state !== "active")
|
|
535
|
+
throw new ConnectionsError("pending");
|
|
536
|
+
return {
|
|
537
|
+
row,
|
|
538
|
+
tools: "all",
|
|
539
|
+
grantor: actor,
|
|
540
|
+
agentId: `owner:${actor}`,
|
|
541
|
+
};
|
|
361
542
|
};
|
|
543
|
+
const ownerCall = (actor, at, raw) => callThrough(() => ownerAdmit(actor, at.connectionId, at.expectedVersion), raw);
|
|
544
|
+
/** The owner's own raw fetch to one connection, through the same gates. */
|
|
545
|
+
const ownerFetch = (actor, connectionId) => (target, init) => fetchThrough(() => ownerAdmit(actor, connectionId, null), target, init);
|
|
546
|
+
/** The tools an MCP connection offers, listed by its owner. */
|
|
547
|
+
const tools = (actor, connectionId) => Effect.gen(function* () {
|
|
548
|
+
const row = yield* sync(() => {
|
|
549
|
+
authorize(actor, "manage");
|
|
550
|
+
return connection(connectionId);
|
|
551
|
+
});
|
|
552
|
+
if (row.surface.kind !== "mcp")
|
|
553
|
+
return yield* Effect.fail(new ConnectionsError("unsupported"));
|
|
554
|
+
return yield* io(() => listMcpTools(row.surface.url, ownerFetch(actor, connectionId)));
|
|
555
|
+
});
|
|
556
|
+
/** The decoded operations of one OpenAPI connection, for its owner. */
|
|
557
|
+
const operations = (actor, id) => Effect.gen(function* () {
|
|
558
|
+
const row = yield* sync(() => {
|
|
559
|
+
authorize(actor, "manage");
|
|
560
|
+
return connection(id);
|
|
561
|
+
});
|
|
562
|
+
if (row.surface.kind !== "openapi")
|
|
563
|
+
return yield* Effect.fail(new ConnectionsError("unsupported"));
|
|
564
|
+
return yield* io(() => decodeTable(row.surface.table));
|
|
565
|
+
});
|
|
566
|
+
const update = createUpdate({
|
|
567
|
+
ownerId,
|
|
568
|
+
custody: options.custody,
|
|
569
|
+
store: {
|
|
570
|
+
authorize: (actor) => {
|
|
571
|
+
authorize(actor, "manage");
|
|
572
|
+
},
|
|
573
|
+
connection,
|
|
574
|
+
receipt: (requestId) => {
|
|
575
|
+
const found = rows(receiptRow, "SELECT fingerprint, result FROM veil_connection_receipts WHERE requestId = ?", requestId)[0];
|
|
576
|
+
return found === undefined
|
|
577
|
+
? null
|
|
578
|
+
: {
|
|
579
|
+
stamp: found.fingerprint,
|
|
580
|
+
connectionId: found.result.connectionId,
|
|
581
|
+
version: found.result.version ?? 0,
|
|
582
|
+
};
|
|
583
|
+
},
|
|
584
|
+
checkName: assertNameFree,
|
|
585
|
+
publicUrl,
|
|
586
|
+
commit: ({ actor, update: input, stamp, row, ...next }) => transactionSync(() => {
|
|
587
|
+
authorize(actor, "manage");
|
|
588
|
+
const current = connection(row.id);
|
|
589
|
+
if (current.version !== input.expectedVersion ||
|
|
590
|
+
current.state !== row.state)
|
|
591
|
+
throw new ConnectionsError("conflict");
|
|
592
|
+
if (input.name !== undefined)
|
|
593
|
+
assertNameFree(input.name, row.id);
|
|
594
|
+
const version = current.version + 1;
|
|
595
|
+
sql.exec("UPDATE veil_connections SET name = ?, surface = ?, credential = ?, locator = ?, version = ?, authorityVersion = authorityVersion + 1 WHERE id = ?", input.name ?? current.name, JSON.stringify({
|
|
596
|
+
...current.surface,
|
|
597
|
+
url: next.baseUrl,
|
|
598
|
+
table: next.table,
|
|
599
|
+
digest: next.digest,
|
|
600
|
+
label: input.label ?? current.surface.label ?? current.name,
|
|
601
|
+
}), JSON.stringify(next.credential), next.locator, version, row.id);
|
|
602
|
+
if (input.secret !== undefined && row.locator !== null)
|
|
603
|
+
enqueueLocator(row.id, row.locator, row.credential);
|
|
604
|
+
sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", input.requestId, stamp, JSON.stringify({ connectionId: row.id, version }));
|
|
605
|
+
bump();
|
|
606
|
+
return { connectionId: row.id, version };
|
|
607
|
+
}),
|
|
608
|
+
discard: (connectionId, locator, credential) => {
|
|
609
|
+
enqueueLocator(connectionId, locator, credential);
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
});
|
|
362
613
|
return {
|
|
363
614
|
begin,
|
|
615
|
+
complete: oauthFlow.complete,
|
|
616
|
+
completeCustody: oauthFlow.completeCustody,
|
|
364
617
|
grant: grantTo,
|
|
365
618
|
ungrant,
|
|
366
619
|
revoke,
|
|
367
620
|
projection,
|
|
368
621
|
list,
|
|
369
622
|
egress,
|
|
623
|
+
call,
|
|
624
|
+
ownerCall,
|
|
625
|
+
tools,
|
|
626
|
+
operations,
|
|
627
|
+
update,
|
|
370
628
|
sweep,
|
|
629
|
+
cleanupPending,
|
|
371
630
|
};
|
|
372
631
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const toolLimit = 1_024;
|
|
2
|
+
/** The tool names an MCP server offers, listed with the maintained SDK client. */
|
|
3
|
+
export async function listMcpTools(url, fetch) {
|
|
4
|
+
const sdk = await import("@modelcontextprotocol/client");
|
|
5
|
+
const client = new sdk.Client({ name: "fungi-connections", version: "1" });
|
|
6
|
+
await client.connect(new sdk.StreamableHTTPClientTransport(new URL(url), {
|
|
7
|
+
fetch,
|
|
8
|
+
requestInit: { redirect: "error" },
|
|
9
|
+
}));
|
|
10
|
+
try {
|
|
11
|
+
const names = [];
|
|
12
|
+
let cursor;
|
|
13
|
+
do {
|
|
14
|
+
const page = await client.listTools(cursor === undefined ? undefined : { cursor });
|
|
15
|
+
names.push(...page.tools.map((tool) => tool.name));
|
|
16
|
+
cursor = page.nextCursor;
|
|
17
|
+
} while (cursor !== undefined && names.length < toolLimit);
|
|
18
|
+
return names.slice(0, toolLimit);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await client.close();
|
|
22
|
+
}
|
|
23
|
+
}
|