@fungi.computer/veil 0.4.0 → 0.6.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/README.md +48 -54
- package/dist/best-effort-cleanup.d.ts +1 -1
- package/dist/connections/cleanup.d.ts +21 -0
- package/dist/connections/cleanup.js +66 -0
- package/dist/connections/contract.d.ts +192 -7
- package/dist/connections/contract.js +44 -2
- package/dist/connections/core.d.ts +58 -5
- package/dist/connections/core.js +438 -111
- 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 +179 -0
- package/dist/connections/oauth-flow.d.ts +83 -0
- package/dist/connections/oauth-flow.js +330 -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 +43 -85
- package/dist/gatekeeper.js +138 -95
- package/dist/github-app.d.ts +44 -0
- package/dist/github-app.js +93 -0
- package/dist/index.d.ts +0 -241
- package/dist/index.js +0 -256
- package/dist/nango.d.ts +40 -15
- package/dist/nango.js +7 -242
- package/package.json +15 -9
- package/dist/nango-git.d.ts +0 -19
- package/dist/nango-git.js +0 -125
package/dist/connections/core.js
CHANGED
|
@@ -1,26 +1,15 @@
|
|
|
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, attemptTtlMs, callInputSchema, completeInputSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
|
|
7
6
|
import { admitRequest } from "./egress.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
});
|
|
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";
|
|
24
13
|
const grantRow = Schema.Struct({
|
|
25
14
|
connectionId: identifier,
|
|
26
15
|
agentId: identifier,
|
|
@@ -31,40 +20,19 @@ const grantRow = Schema.Struct({
|
|
|
31
20
|
});
|
|
32
21
|
const receiptRow = Schema.Struct({
|
|
33
22
|
fingerprint: Schema.String,
|
|
34
|
-
result: json(Schema.Struct({
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 }),
|
|
23
|
+
result: json(Schema.Struct({
|
|
24
|
+
connectionId: identifier,
|
|
25
|
+
authorizeUrl: Schema.optional(Schema.String),
|
|
26
|
+
attemptId: 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,25 @@ export function createConnections(options) {
|
|
|
101
72
|
throw new ConnectionsError("denied");
|
|
102
73
|
return row;
|
|
103
74
|
};
|
|
75
|
+
/** The origins a named provider's credential may reach, or nothing when this host does not hold it. */
|
|
76
|
+
const providerOrigins = (provider) => options.oauth?.providers?.[provider]?.apiOrigins ??
|
|
77
|
+
options.custodyProviders?.[provider]?.apiOrigins;
|
|
78
|
+
const assertProviderOrigin = (credential, target, refusal) => {
|
|
79
|
+
if (credential.kind !== "oauth" || credential.provider === undefined)
|
|
80
|
+
return;
|
|
81
|
+
const origins = providerOrigins(credential.provider);
|
|
82
|
+
// A provider this host no longer holds must be connected again.
|
|
83
|
+
if (origins === undefined)
|
|
84
|
+
throw new ConnectionsError("reconnect_required");
|
|
85
|
+
if (!origins.includes(new URL(target).origin))
|
|
86
|
+
throw new ConnectionsError(refusal);
|
|
87
|
+
};
|
|
88
|
+
/** One live connection per binding name, so an Agent's module name is unambiguous. */
|
|
89
|
+
const assertNameFree = (name, exceptId) => {
|
|
90
|
+
const taken = rows(count, "SELECT COUNT(*) AS n FROM veil_connections WHERE name = ? AND state != 'revoked' AND id != ?", name, exceptId ?? "")[0];
|
|
91
|
+
if (taken === undefined || taken.n > 0)
|
|
92
|
+
throw new ConnectionsError("conflict");
|
|
93
|
+
};
|
|
104
94
|
const grant = (id, agentId) => rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND agentId = ?", id, agentId)[0];
|
|
105
95
|
const address = (row) => ({
|
|
106
96
|
ownerId,
|
|
@@ -127,30 +117,18 @@ export function createConnections(options) {
|
|
|
127
117
|
}
|
|
128
118
|
return url.href;
|
|
129
119
|
};
|
|
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
|
-
}
|
|
120
|
+
const { cleanupPending, sweep, enqueueLocator, enqueueCleanup, markReconnect, retireAttempt, } = createCleanup({
|
|
121
|
+
ownerId,
|
|
122
|
+
custody: options.custody,
|
|
123
|
+
clock: options.clock,
|
|
124
|
+
sql,
|
|
125
|
+
transactionSync,
|
|
126
|
+
rows,
|
|
127
|
+
bump,
|
|
147
128
|
});
|
|
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
129
|
const fingerprint = (draft) => JSON.stringify([
|
|
153
130
|
draft.name,
|
|
131
|
+
draft.label ?? null,
|
|
154
132
|
draft.surface.kind,
|
|
155
133
|
"url" in draft.surface ? draft.surface.url : draft.surface.baseUrl,
|
|
156
134
|
draft.credential,
|
|
@@ -160,9 +138,29 @@ export function createConnections(options) {
|
|
|
160
138
|
const draft = yield* sync(() => {
|
|
161
139
|
authorize(actor, "manage");
|
|
162
140
|
const parsed = decode(draftSchema, input);
|
|
163
|
-
|
|
164
|
-
?
|
|
165
|
-
: parsed.
|
|
141
|
+
const wantsSecret = parsed.credential.kind === "static"
|
|
142
|
+
? "required"
|
|
143
|
+
: parsed.credential.kind === "oauth"
|
|
144
|
+
? "client"
|
|
145
|
+
: "never";
|
|
146
|
+
if ((wantsSecret === "required" && parsed.secret === undefined) ||
|
|
147
|
+
(wantsSecret === "never" && parsed.secret !== undefined) ||
|
|
148
|
+
(parsed.credential.kind === "oauth" &&
|
|
149
|
+
// An MCP server signs in at itself; a REST connection through a
|
|
150
|
+
// provider the operator registered, with the operator's client; or
|
|
151
|
+
// a custodian that runs the authorization (its integration id).
|
|
152
|
+
(parsed.credential.provider === undefined
|
|
153
|
+
? parsed.surface.kind !== "mcp" ||
|
|
154
|
+
(parsed.secret !== undefined &&
|
|
155
|
+
parsed.credential.clientId === undefined)
|
|
156
|
+
: parsed.secret !== undefined ||
|
|
157
|
+
parsed.credential.clientId !== undefined ||
|
|
158
|
+
(options.oauth?.providers?.[parsed.credential.provider] ===
|
|
159
|
+
undefined
|
|
160
|
+
? options.custody.connect === undefined ||
|
|
161
|
+
options.custodyProviders?.[parsed.credential.provider] ===
|
|
162
|
+
undefined
|
|
163
|
+
: parsed.surface.kind !== "openapi"))))
|
|
166
164
|
throw new ConnectionsError("invalid_input");
|
|
167
165
|
return parsed;
|
|
168
166
|
});
|
|
@@ -172,17 +170,38 @@ export function createConnections(options) {
|
|
|
172
170
|
return null;
|
|
173
171
|
if (receipt.fingerprint !== fingerprint(draft))
|
|
174
172
|
throw new ConnectionsError("conflict");
|
|
175
|
-
return receipt.result
|
|
173
|
+
return receipt.result;
|
|
176
174
|
});
|
|
177
175
|
if (replay !== null)
|
|
178
|
-
return {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
return {
|
|
177
|
+
connectionId: replay.connectionId,
|
|
178
|
+
authorizeUrl: replay.authorizeUrl ?? null,
|
|
179
|
+
attemptId: replay.attemptId ?? null,
|
|
180
|
+
};
|
|
181
|
+
yield* sync(() => {
|
|
182
|
+
assertNameFree(draft.name, null);
|
|
183
|
+
});
|
|
184
|
+
const endpoint = yield* sync(() => publicUrl(draft.surface.kind === "openapi"
|
|
185
|
+
? draft.surface.baseUrl
|
|
186
|
+
: draft.surface.url));
|
|
187
|
+
// A provider's credential is sent only to the origins its operator declared.
|
|
188
|
+
yield* sync(() => {
|
|
189
|
+
assertProviderOrigin(draft.credential, endpoint, "invalid_input");
|
|
190
|
+
});
|
|
182
191
|
const surface = draft.surface;
|
|
183
|
-
const
|
|
184
|
-
?
|
|
192
|
+
const decoded = surface.kind === "openapi"
|
|
193
|
+
? yield* io(() => decodeTable(surface.table))
|
|
185
194
|
: null;
|
|
195
|
+
if (draft.credential.kind === "oauth") {
|
|
196
|
+
const table = decoded === null
|
|
197
|
+
? null
|
|
198
|
+
: { table: decoded.operations, digest: decoded.digest };
|
|
199
|
+
const viaCustodian = draft.credential.provider !== undefined &&
|
|
200
|
+
options.oauth?.providers?.[draft.credential.provider] === undefined;
|
|
201
|
+
return yield* viaCustodian
|
|
202
|
+
? oauthFlow.beginCustody(actor, draft, draft.credential, endpoint, fingerprint(draft), table, draft.surface.kind)
|
|
203
|
+
: oauthFlow.begin(actor, draft, draft.credential, endpoint, fingerprint(draft), table);
|
|
204
|
+
}
|
|
186
205
|
const connectionId = options.ids();
|
|
187
206
|
const state = options.ids();
|
|
188
207
|
yield* sync(() => {
|
|
@@ -200,35 +219,139 @@ export function createConnections(options) {
|
|
|
200
219
|
locator,
|
|
201
220
|
state: "active",
|
|
202
221
|
authorityVersion: 1,
|
|
222
|
+
version: 1,
|
|
203
223
|
surface: {
|
|
204
224
|
kind: draft.surface.kind,
|
|
205
225
|
url: endpoint,
|
|
206
|
-
table,
|
|
226
|
+
table: decoded?.operations ?? null,
|
|
227
|
+
digest: decoded?.digest ?? null,
|
|
228
|
+
label: draft.label ?? draft.name,
|
|
207
229
|
},
|
|
208
230
|
};
|
|
209
231
|
return yield* sync(() => transactionSync(() => {
|
|
210
232
|
authorize(actor, "manage");
|
|
233
|
+
assertNameFree(stored.name, null);
|
|
211
234
|
const attempt = rows(count, "SELECT COUNT(*) AS n FROM veil_connection_attempts WHERE state = ? AND status = 'pending' AND expiresAt > ?", state, options.clock())[0];
|
|
212
235
|
if (attempt?.n !== 1)
|
|
213
236
|
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);
|
|
237
|
+
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
238
|
sql.exec("UPDATE veil_connection_attempts SET status = 'completed' WHERE state = ?", state);
|
|
216
239
|
sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", draft.requestId, fingerprint(draft), JSON.stringify({ connectionId }));
|
|
217
240
|
bump();
|
|
218
|
-
return { connectionId, authorizeUrl: null };
|
|
241
|
+
return { connectionId, authorizeUrl: null, attemptId: null };
|
|
219
242
|
})).pipe(Effect.tapError(() => sync(() => {
|
|
220
243
|
enqueueCleanup(stored);
|
|
221
244
|
})));
|
|
222
245
|
});
|
|
246
|
+
const oauthStore = {
|
|
247
|
+
connection,
|
|
248
|
+
recordPending: (input) => {
|
|
249
|
+
transactionSync(() => {
|
|
250
|
+
authorize(input.actor, "manage");
|
|
251
|
+
const stored = input.connection;
|
|
252
|
+
assertNameFree(stored.name, null);
|
|
253
|
+
sql.exec("INSERT INTO veil_connections VALUES (?, ?, ?, ?, NULL, 'pending', 1, 1)", stored.id, stored.name, JSON.stringify(stored.surface), JSON.stringify(stored.credential));
|
|
254
|
+
sql.exec("INSERT INTO veil_connection_attempts VALUES (?, ?, ?, 'pending', ?, ?, ?)", input.state, stored.id, input.actor, options.clock() + attemptTtlMs, input.attemptLocator, input.issuer);
|
|
255
|
+
sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", input.requestId, input.fingerprint, JSON.stringify({
|
|
256
|
+
connectionId: stored.id,
|
|
257
|
+
authorizeUrl: input.authorizeUrl,
|
|
258
|
+
attemptId: input.state,
|
|
259
|
+
}));
|
|
260
|
+
bump();
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
claimCustody: (state, principalId) => transactionSync(() => {
|
|
264
|
+
if (!state.startsWith(`${ownerId}.`))
|
|
265
|
+
throw new ConnectionsError("denied");
|
|
266
|
+
const attempt = rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE state = ?", state)[0];
|
|
267
|
+
// A browser attempt holds a sealed verifier; only a custodian's has none.
|
|
268
|
+
if (attempt?.status !== "pending" ||
|
|
269
|
+
attempt.pkceVerifierSealed !== null ||
|
|
270
|
+
attempt.actor !== principalId)
|
|
271
|
+
throw new ConnectionsError("denied");
|
|
272
|
+
if (attempt.expiresAt <= options.clock()) {
|
|
273
|
+
retireAttempt(attempt, "expired");
|
|
274
|
+
return { attempt, expired: true };
|
|
275
|
+
}
|
|
276
|
+
sql.exec("UPDATE veil_connection_attempts SET status = 'consumed' WHERE state = ?", state);
|
|
277
|
+
return { attempt, expired: false };
|
|
278
|
+
}),
|
|
279
|
+
claim: (actor, input) => transactionSync(() => {
|
|
280
|
+
authorize(actor, "manage");
|
|
281
|
+
const wanted = decode(completeInputSchema, input);
|
|
282
|
+
if (!wanted.state.startsWith(`${ownerId}.`))
|
|
283
|
+
throw new ConnectionsError("denied");
|
|
284
|
+
const attempt = rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE state = ?", wanted.state)[0];
|
|
285
|
+
if (attempt?.status !== "pending" ||
|
|
286
|
+
attempt.actor !== actor ||
|
|
287
|
+
attempt.pkceVerifierSealed === null)
|
|
288
|
+
throw new ConnectionsError("denied");
|
|
289
|
+
if (attempt.expiresAt <= options.clock()) {
|
|
290
|
+
retireAttempt(attempt, "expired");
|
|
291
|
+
return { attempt, wanted, expired: true };
|
|
292
|
+
}
|
|
293
|
+
sql.exec("UPDATE veil_connection_attempts SET status = 'consumed' WHERE state = ?", attempt.state);
|
|
294
|
+
return { attempt, wanted, expired: false };
|
|
295
|
+
}),
|
|
296
|
+
fail: (attempt) => {
|
|
297
|
+
transactionSync(() => {
|
|
298
|
+
retireAttempt(attempt, "failed");
|
|
299
|
+
});
|
|
300
|
+
},
|
|
301
|
+
activate: (row, attempt, locator) => transactionSync(() => {
|
|
302
|
+
const current = connection(row.id);
|
|
303
|
+
if (current.state !== "pending")
|
|
304
|
+
return false;
|
|
305
|
+
sql.exec("UPDATE veil_connections SET state = 'active', locator = ?, authorityVersion = authorityVersion + 1 WHERE id = ?", locator, row.id);
|
|
306
|
+
sql.exec("UPDATE veil_connection_attempts SET status = 'completed', pkceVerifierSealed = NULL WHERE state = ?", attempt.state);
|
|
307
|
+
if (attempt.pkceVerifierSealed !== null)
|
|
308
|
+
enqueueLocator(row.id, attempt.pkceVerifierSealed, row.credential);
|
|
309
|
+
bump();
|
|
310
|
+
return true;
|
|
311
|
+
}),
|
|
312
|
+
discard: (connectionId, locator, credential) => {
|
|
313
|
+
transactionSync(() => {
|
|
314
|
+
enqueueLocator(connectionId, locator, credential);
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
swap: (row, held, locator) => transactionSync(() => {
|
|
318
|
+
const current = connection(row.id);
|
|
319
|
+
if (current.state !== "active" || current.locator !== held)
|
|
320
|
+
return current.state;
|
|
321
|
+
sql.exec("UPDATE veil_connections SET locator = ? WHERE id = ?", locator, row.id);
|
|
322
|
+
enqueueLocator(row.id, held, row.credential);
|
|
323
|
+
return "applied";
|
|
324
|
+
}),
|
|
325
|
+
clientLocator: (issuer) => rows(registryRow, "SELECT locator FROM veil_oauth_clients WHERE issuer = ?", issuer)[0]?.locator,
|
|
326
|
+
saveClientLocator: (issuer, locator) => {
|
|
327
|
+
transactionSync(() => {
|
|
328
|
+
const old = rows(registryRow, "SELECT locator FROM veil_oauth_clients WHERE issuer = ?", issuer)[0];
|
|
329
|
+
sql.exec("INSERT OR REPLACE INTO veil_oauth_clients VALUES (?, ?)", issuer, locator);
|
|
330
|
+
if (old !== undefined)
|
|
331
|
+
enqueueLocator("oauth-clients", old.locator, { kind: "oauth" });
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
const oauthFlow = createOAuthFlow({
|
|
336
|
+
ownerId,
|
|
337
|
+
custody: options.custody,
|
|
338
|
+
oauth: options.oauth,
|
|
339
|
+
clock: options.clock,
|
|
340
|
+
ids: options.ids,
|
|
341
|
+
policy,
|
|
342
|
+
store: oauthStore,
|
|
343
|
+
sweep,
|
|
344
|
+
});
|
|
223
345
|
const grantTo = (actor, input) => sync(() => transactionSync(() => {
|
|
224
346
|
authorize(actor, "manage");
|
|
225
347
|
const wanted = decode(grantInputSchema, input);
|
|
226
348
|
const row = connection(wanted.connectionId);
|
|
227
349
|
if (row.state === "revoked")
|
|
228
350
|
throw new ConnectionsError("revoked");
|
|
229
|
-
if (wanted.tools
|
|
230
|
-
|
|
231
|
-
|
|
351
|
+
if (wanted.tools !== "all" &&
|
|
352
|
+
(Array.isArray(wanted.tools)
|
|
353
|
+
? row.surface.kind !== "mcp" && wanted.tools.length === 0
|
|
354
|
+
: row.surface.kind !== "openapi"))
|
|
232
355
|
throw new ConnectionsError("invalid_input");
|
|
233
356
|
const tools = JSON.stringify(wanted.tools);
|
|
234
357
|
const existing = grant(wanted.connectionId, wanted.agentId);
|
|
@@ -258,6 +381,8 @@ export function createConnections(options) {
|
|
|
258
381
|
const row = connection(decode(identifier, input.connectionId));
|
|
259
382
|
if (row.state === "revoked")
|
|
260
383
|
return;
|
|
384
|
+
for (const attempt of rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE connectionId = ? AND status IN ('pending', 'consumed')", row.id))
|
|
385
|
+
retireAttempt(attempt, "revoked");
|
|
261
386
|
sql.exec("UPDATE veil_connections SET state = 'revoked', locator = NULL, authorityVersion = authorityVersion + 1 WHERE id = ?", row.id);
|
|
262
387
|
sql.exec("UPDATE veil_connection_grants SET active = 0, version = version + 1 WHERE connectionId = ? AND active = 1", row.id);
|
|
263
388
|
enqueueCleanup(row);
|
|
@@ -280,19 +405,22 @@ export function createConnections(options) {
|
|
|
280
405
|
});
|
|
281
406
|
const connections = [];
|
|
282
407
|
for (const { g, row } of stored.granted) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
408
|
+
let surface = { kind: "mcp", url: row.surface.url };
|
|
409
|
+
if (row.surface.kind === "openapi") {
|
|
410
|
+
const decoded = yield* io(() => decodeTable(row.surface.table));
|
|
411
|
+
surface = {
|
|
286
412
|
kind: "openapi",
|
|
287
413
|
baseUrl: row.surface.url,
|
|
288
|
-
table:
|
|
289
|
-
|
|
414
|
+
table: decoded.operations,
|
|
415
|
+
digest: decoded.digest,
|
|
290
416
|
};
|
|
417
|
+
}
|
|
291
418
|
connections.push({
|
|
292
419
|
connectionId: row.id,
|
|
293
420
|
name: row.name,
|
|
294
421
|
surface,
|
|
295
422
|
tools: g.tools,
|
|
423
|
+
version: row.version,
|
|
296
424
|
authorityVersion: row.authorityVersion,
|
|
297
425
|
grantVersion: g.version,
|
|
298
426
|
});
|
|
@@ -307,17 +435,23 @@ export function createConnections(options) {
|
|
|
307
435
|
return rows(connectionRow, "SELECT * FROM veil_connections ORDER BY id").map((row) => ({
|
|
308
436
|
connectionId: row.id,
|
|
309
437
|
name: row.name,
|
|
438
|
+
label: row.surface.label ?? row.name,
|
|
310
439
|
surface: {
|
|
311
440
|
kind: row.surface.kind,
|
|
312
441
|
origin: new URL(row.surface.url).origin,
|
|
442
|
+
url: row.surface.url,
|
|
443
|
+
digest: row.surface.digest ?? null,
|
|
313
444
|
},
|
|
314
445
|
credentialKind: row.credential.kind,
|
|
446
|
+
credential: row.credential,
|
|
315
447
|
state: row.state,
|
|
448
|
+
version: row.version,
|
|
316
449
|
authorityVersion: row.authorityVersion,
|
|
317
450
|
grants: rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND active = 1", row.id).map((g) => ({
|
|
318
451
|
agentId: g.agentId,
|
|
319
452
|
tools: g.tools,
|
|
320
453
|
version: g.version,
|
|
454
|
+
grantor: g.grantor,
|
|
321
455
|
})),
|
|
322
456
|
}));
|
|
323
457
|
});
|
|
@@ -330,43 +464,236 @@ export function createConnections(options) {
|
|
|
330
464
|
held?.active !== 1 ||
|
|
331
465
|
held.version !== ref.grantVersion)
|
|
332
466
|
throw new ConnectionsError("revoked");
|
|
467
|
+
if (row.state === "pending")
|
|
468
|
+
throw new ConnectionsError("pending");
|
|
333
469
|
if (row.state === "reconnect_required")
|
|
334
470
|
throw new ConnectionsError("reconnect_required");
|
|
335
471
|
authorize(held.grantor, "grantor");
|
|
336
|
-
return { row, tools: held.tools };
|
|
472
|
+
return { row, tools: held.tools, grantor: held.grantor, agentId };
|
|
337
473
|
};
|
|
474
|
+
/**
|
|
475
|
+
* One request through every gate: the grant, the host's last live check, the
|
|
476
|
+
* credential at the owner, and the grant again on the way back.
|
|
477
|
+
*/
|
|
478
|
+
const deliver = (admit, prepare) => Effect.gen(function* () {
|
|
479
|
+
const { row, tools, grantor, agentId } = yield* sync(admit);
|
|
480
|
+
const { request, operation } = yield* prepare(row, tools);
|
|
481
|
+
const verdict = options.check;
|
|
482
|
+
if (verdict !== undefined &&
|
|
483
|
+
!(yield* io(() => verdict(grantor, { agentId, connectionId: row.id, tools }, operation))))
|
|
484
|
+
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
485
|
+
yield* sync(admit);
|
|
486
|
+
yield* sync(() => {
|
|
487
|
+
assertProviderOrigin(row.credential, request.url, "denied");
|
|
488
|
+
});
|
|
489
|
+
if (row.locator === null && row.credential.kind === "static")
|
|
490
|
+
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
491
|
+
const response = yield* // A custodian that runs the sign-in refreshes the token itself.
|
|
492
|
+
(row.credential.kind === "oauth" && options.custody.open !== undefined
|
|
493
|
+
? oauthFlow.send(row, request)
|
|
494
|
+
: options.custody.inject(address(row), row.locator ?? "", request)).pipe(Effect.tapError((error) => error.code === "reconnect_required"
|
|
495
|
+
? sync(() => {
|
|
496
|
+
markReconnect(row.id);
|
|
497
|
+
})
|
|
498
|
+
: Effect.void));
|
|
499
|
+
const stillCurrent = yield* sync(admit).pipe(Effect.result);
|
|
500
|
+
if (stillCurrent._tag === "Failure" ||
|
|
501
|
+
response.redirected ||
|
|
502
|
+
(response.status >= 300 && response.status < 400)) {
|
|
503
|
+
yield* io(() => response.body?.cancel() ?? Promise.resolve());
|
|
504
|
+
return yield* Effect.fail(stillCurrent._tag === "Failure"
|
|
505
|
+
? stillCurrent.failure
|
|
506
|
+
: new ConnectionsError("invalid_response"));
|
|
507
|
+
}
|
|
508
|
+
return response;
|
|
509
|
+
});
|
|
510
|
+
const fetchThrough = (admit, target, init) => Effect.runPromise(deliver(admit, (row, tools) => {
|
|
511
|
+
const { kind, url } = row.surface;
|
|
512
|
+
return kind === "openapi" && tools !== "all"
|
|
513
|
+
? // Effect and name grants are enforced per operation, by `call`.
|
|
514
|
+
Effect.fail(new ConnectionsError("denied"))
|
|
515
|
+
: io(() => admitRequest({ kind, url }, tools, new Request(target, init), policy)).pipe(Effect.map((request) => ({ request, operation: null })));
|
|
516
|
+
}));
|
|
338
517
|
const egress = (agentId, input) => {
|
|
339
518
|
const ref = decode(egressRefSchema, input);
|
|
340
|
-
return (target, init) =>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
519
|
+
return (target, init) => fetchThrough(() => admitted(agentId, ref), target, init);
|
|
520
|
+
};
|
|
521
|
+
/** A typed OpenAPI operation: built from the stored table, checked against its stored schemas. */
|
|
522
|
+
const callThrough = (admit, raw) => Effect.gen(function* () {
|
|
523
|
+
const wanted = yield* sync(() => decode(callInputSchema, raw));
|
|
524
|
+
let chosen;
|
|
525
|
+
const response = yield* deliver(admit, (row, tools) => Effect.gen(function* () {
|
|
526
|
+
if (row.surface.kind !== "openapi")
|
|
527
|
+
return yield* Effect.fail(new ConnectionsError("unsupported"));
|
|
528
|
+
const table = yield* io(() => decodeTable(row.surface.table));
|
|
529
|
+
chosen = table.operations.find((candidate) => candidate.name === wanted.operation);
|
|
530
|
+
if (chosen === undefined || !admits(tools, chosen))
|
|
344
531
|
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;
|
|
532
|
+
const picked = chosen;
|
|
533
|
+
const built = yield* io(() => operationRequest(row.surface.url, picked, wanted.input));
|
|
534
|
+
const request = yield* io(() => admitRequest({ kind: "openapi", url: row.surface.url }, "all", built, policy));
|
|
535
|
+
return {
|
|
536
|
+
request,
|
|
537
|
+
operation: { name: picked.name, effect: picked.effect },
|
|
538
|
+
};
|
|
360
539
|
}));
|
|
540
|
+
const operation = chosen;
|
|
541
|
+
if (operation === undefined)
|
|
542
|
+
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
543
|
+
const text = yield* io(() => boundedText(response));
|
|
544
|
+
// A grant ended while the body streamed: nothing read may be returned.
|
|
545
|
+
yield* sync(admit);
|
|
546
|
+
if (!response.ok)
|
|
547
|
+
return yield* Effect.fail(new ConnectionsError("invalid_response"));
|
|
548
|
+
return yield* io(() => operationOutput(operation, text));
|
|
549
|
+
});
|
|
550
|
+
const call = (agentId, input, raw) => {
|
|
551
|
+
const ref = decode(egressRefSchema, input);
|
|
552
|
+
return callThrough(() => admitted(agentId, ref), raw);
|
|
553
|
+
};
|
|
554
|
+
/** The owner as sender: no Agent grant, and a version they last saw, when they name one. */
|
|
555
|
+
const ownerAdmit = (actor, connectionId, expectedVersion) => {
|
|
556
|
+
authorize(actor, "manage");
|
|
557
|
+
const row = connection(connectionId);
|
|
558
|
+
if (row.state === "revoked")
|
|
559
|
+
throw new ConnectionsError("revoked");
|
|
560
|
+
if (expectedVersion !== null && row.version !== expectedVersion)
|
|
561
|
+
throw new ConnectionsError("conflict");
|
|
562
|
+
if (row.state !== "active")
|
|
563
|
+
throw new ConnectionsError("pending");
|
|
564
|
+
return {
|
|
565
|
+
row,
|
|
566
|
+
tools: "all",
|
|
567
|
+
grantor: actor,
|
|
568
|
+
agentId: `owner:${actor}`,
|
|
569
|
+
};
|
|
361
570
|
};
|
|
571
|
+
const ownerCall = (actor, at, raw) => callThrough(() => ownerAdmit(actor, at.connectionId, at.expectedVersion), raw);
|
|
572
|
+
/** The owner's own raw fetch to one connection, through the same gates. */
|
|
573
|
+
const ownerFetch = (actor, connectionId) => (target, init) => fetchThrough(() => ownerAdmit(actor, connectionId, null), target, init);
|
|
574
|
+
/** The tools an MCP connection offers, listed by its owner. */
|
|
575
|
+
const tools = (actor, connectionId) => Effect.gen(function* () {
|
|
576
|
+
const row = yield* sync(() => {
|
|
577
|
+
authorize(actor, "manage");
|
|
578
|
+
return connection(connectionId);
|
|
579
|
+
});
|
|
580
|
+
if (row.surface.kind !== "mcp")
|
|
581
|
+
return yield* Effect.fail(new ConnectionsError("unsupported"));
|
|
582
|
+
return yield* io(() => listMcpTools(row.surface.url, ownerFetch(actor, connectionId)));
|
|
583
|
+
});
|
|
584
|
+
/** The decoded operations of one OpenAPI connection, for its owner. */
|
|
585
|
+
const operations = (actor, id) => Effect.gen(function* () {
|
|
586
|
+
const row = yield* sync(() => {
|
|
587
|
+
authorize(actor, "manage");
|
|
588
|
+
return connection(id);
|
|
589
|
+
});
|
|
590
|
+
if (row.surface.kind !== "openapi")
|
|
591
|
+
return yield* Effect.fail(new ConnectionsError("unsupported"));
|
|
592
|
+
return yield* io(() => decodeTable(row.surface.table));
|
|
593
|
+
});
|
|
594
|
+
/**
|
|
595
|
+
* A custodian's signed completion can be lost. Ask it directly about each
|
|
596
|
+
* open sign-in it is running and settle the ones it finished; bounded, and
|
|
597
|
+
* harmless to repeat because the attempt is single use.
|
|
598
|
+
*/
|
|
599
|
+
const awaitingCustody = () => rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE status = 'pending' AND pkceVerifierSealed IS NULL AND issuer LIKE 'custody:%' AND expiresAt > ? ORDER BY expiresAt LIMIT 16", options.clock());
|
|
600
|
+
const reconcileAttempt = (attempt) => Effect.gen(function* () {
|
|
601
|
+
const find = options.custody.find;
|
|
602
|
+
if (find === undefined)
|
|
603
|
+
return null;
|
|
604
|
+
const row = yield* sync(() => connection(attempt.connectionId));
|
|
605
|
+
const found = yield* find(address(row), {
|
|
606
|
+
state: attempt.state,
|
|
607
|
+
actor: attempt.actor,
|
|
608
|
+
integrationId: (attempt.issuer ?? "").slice("custody:".length),
|
|
609
|
+
});
|
|
610
|
+
if (found.status === "ambiguous")
|
|
611
|
+
return yield* Effect.fail(new ConnectionsError("conflict"));
|
|
612
|
+
if (found.status === "unresolved")
|
|
613
|
+
return null;
|
|
614
|
+
const done = yield* oauthFlow.completeCustody({
|
|
615
|
+
state: attempt.state,
|
|
616
|
+
locator: found.locator,
|
|
617
|
+
principalId: attempt.actor,
|
|
618
|
+
integrationId: (attempt.issuer ?? "").slice("custody:".length),
|
|
619
|
+
});
|
|
620
|
+
return done.connectionId;
|
|
621
|
+
});
|
|
622
|
+
/** The connections a custodian finished that no webhook told us about. */
|
|
623
|
+
const reconcilePending = () => Effect.gen(function* () {
|
|
624
|
+
const settled = [];
|
|
625
|
+
for (const attempt of yield* sync(awaitingCustody)) {
|
|
626
|
+
const outcome = yield* reconcileAttempt(attempt).pipe(Effect.result);
|
|
627
|
+
if (outcome._tag === "Success" && outcome.success !== null)
|
|
628
|
+
settled.push(outcome.success);
|
|
629
|
+
}
|
|
630
|
+
return settled;
|
|
631
|
+
});
|
|
632
|
+
const update = createUpdate({
|
|
633
|
+
ownerId,
|
|
634
|
+
custody: options.custody,
|
|
635
|
+
store: {
|
|
636
|
+
authorize: (actor) => {
|
|
637
|
+
authorize(actor, "manage");
|
|
638
|
+
},
|
|
639
|
+
connection,
|
|
640
|
+
receipt: (requestId) => {
|
|
641
|
+
const found = rows(receiptRow, "SELECT fingerprint, result FROM veil_connection_receipts WHERE requestId = ?", requestId)[0];
|
|
642
|
+
return found === undefined
|
|
643
|
+
? null
|
|
644
|
+
: {
|
|
645
|
+
stamp: found.fingerprint,
|
|
646
|
+
connectionId: found.result.connectionId,
|
|
647
|
+
version: found.result.version ?? 0,
|
|
648
|
+
};
|
|
649
|
+
},
|
|
650
|
+
checkName: assertNameFree,
|
|
651
|
+
publicUrl,
|
|
652
|
+
commit: ({ actor, update: input, stamp, row, ...next }) => transactionSync(() => {
|
|
653
|
+
authorize(actor, "manage");
|
|
654
|
+
const current = connection(row.id);
|
|
655
|
+
if (current.version !== input.expectedVersion ||
|
|
656
|
+
current.state !== row.state)
|
|
657
|
+
throw new ConnectionsError("conflict");
|
|
658
|
+
if (input.name !== undefined)
|
|
659
|
+
assertNameFree(input.name, row.id);
|
|
660
|
+
const version = current.version + 1;
|
|
661
|
+
sql.exec("UPDATE veil_connections SET name = ?, surface = ?, credential = ?, locator = ?, version = ?, authorityVersion = authorityVersion + 1 WHERE id = ?", input.name ?? current.name, JSON.stringify({
|
|
662
|
+
...current.surface,
|
|
663
|
+
url: next.baseUrl,
|
|
664
|
+
table: next.table,
|
|
665
|
+
digest: next.digest,
|
|
666
|
+
label: input.label ?? current.surface.label ?? current.name,
|
|
667
|
+
}), JSON.stringify(next.credential), next.locator, version, row.id);
|
|
668
|
+
if (input.secret !== undefined && row.locator !== null)
|
|
669
|
+
enqueueLocator(row.id, row.locator, row.credential);
|
|
670
|
+
sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", input.requestId, stamp, JSON.stringify({ connectionId: row.id, version }));
|
|
671
|
+
bump();
|
|
672
|
+
return { connectionId: row.id, version };
|
|
673
|
+
}),
|
|
674
|
+
discard: (connectionId, locator, credential) => {
|
|
675
|
+
enqueueLocator(connectionId, locator, credential);
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
});
|
|
362
679
|
return {
|
|
363
680
|
begin,
|
|
681
|
+
complete: oauthFlow.complete,
|
|
682
|
+
completeCustody: oauthFlow.completeCustody,
|
|
364
683
|
grant: grantTo,
|
|
365
684
|
ungrant,
|
|
366
685
|
revoke,
|
|
367
686
|
projection,
|
|
368
687
|
list,
|
|
369
688
|
egress,
|
|
689
|
+
call,
|
|
690
|
+
ownerCall,
|
|
691
|
+
tools,
|
|
692
|
+
operations,
|
|
693
|
+
update,
|
|
370
694
|
sweep,
|
|
695
|
+
cleanupPending,
|
|
696
|
+
reconcilePending,
|
|
697
|
+
awaitingCustody: () => awaitingCustody().length,
|
|
371
698
|
};
|
|
372
699
|
}
|