@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.
Files changed (35) hide show
  1. package/dist/connections/cleanup.d.ts +21 -0
  2. package/dist/connections/cleanup.js +66 -0
  3. package/dist/connections/contract.d.ts +153 -7
  4. package/dist/connections/contract.js +42 -2
  5. package/dist/connections/core.d.ts +55 -5
  6. package/dist/connections/core.js +365 -106
  7. package/dist/connections/discover.d.ts +3 -0
  8. package/dist/connections/discover.js +23 -0
  9. package/dist/connections/egress.js +3 -2
  10. package/dist/connections/index.d.ts +1 -0
  11. package/dist/connections/index.js +1 -0
  12. package/dist/connections/internal.d.ts +78 -0
  13. package/dist/connections/internal.js +66 -0
  14. package/dist/connections/mcp-agent.d.ts +37 -0
  15. package/dist/connections/mcp-agent.js +75 -0
  16. package/dist/connections/nango.d.ts +30 -0
  17. package/dist/connections/nango.js +136 -0
  18. package/dist/connections/oauth-flow.d.ts +81 -0
  19. package/dist/connections/oauth-flow.js +322 -0
  20. package/dist/connections/oauth-provider.d.ts +31 -0
  21. package/dist/connections/oauth-provider.js +96 -0
  22. package/dist/connections/oauth.d.ts +93 -0
  23. package/dist/connections/oauth.js +239 -0
  24. package/dist/connections/openapi.d.ts +36 -0
  25. package/dist/connections/openapi.js +120 -0
  26. package/dist/connections/sealed.js +23 -4
  27. package/dist/connections/source.d.ts +17 -0
  28. package/dist/connections/source.js +111 -0
  29. package/dist/connections/update.d.ts +41 -0
  30. package/dist/connections/update.js +81 -0
  31. package/dist/gatekeeper.d.ts +21 -1
  32. package/dist/gatekeeper.js +74 -12
  33. package/dist/nango.d.ts +41 -0
  34. package/dist/nango.js +6 -6
  35. package/package.json +11 -1
@@ -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, credentialSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
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({ connectionId: identifier })),
35
- });
36
- const cleanupRow = Schema.Struct({
37
- id: Schema.Number,
38
- connectionId: identifier,
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, transactionSync } = options.store;
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) STRICT;
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 = () => Effect.gen(function* () {
131
- const due = yield* sync(() => rows(cleanupRow, "SELECT * FROM veil_connection_cleanup WHERE attempts < ?", cleanupLimit));
132
- for (const job of due) {
133
- const outcome = yield* options.custody
134
- .remove({
135
- ownerId,
136
- connectionId: job.connectionId,
137
- credential: job.credential,
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
- if (parsed.credential.kind === "static"
164
- ? parsed.secret === undefined
165
- : parsed.secret !== undefined)
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.connectionId;
158
+ return receipt.result;
176
159
  });
177
160
  if (replay !== null)
178
- return { connectionId: replay, authorizeUrl: null };
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 table = surface.kind === "openapi"
184
- ? (yield* io(() => parseOpenApiTable(surface.table))).operations
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 === "all"
230
- ? false
231
- : row.surface.kind !== "mcp" && wanted.tools.length === 0)
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
- const surface = row.surface.kind === "mcp"
284
- ? { kind: "mcp", url: row.surface.url }
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: (yield* io(() => parseOpenApiTable(row.surface.table)))
289
- .operations,
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) => Effect.runPromise(Effect.gen(function* () {
341
- const { row, tools } = yield* sync(() => admitted(agentId, ref));
342
- const request = yield* io(() => admitRequest({ kind: row.surface.kind, url: row.surface.url }, tools, new Request(target, init), policy));
343
- if (row.locator === null && row.credential.kind === "static")
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 response = yield* options.custody
346
- .inject(address(row), row.locator ?? "", request)
347
- .pipe(Effect.tapError((error) => error.code === "reconnect_required"
348
- ? sync(() => sql.exec("UPDATE veil_connections SET state = 'reconnect_required', authorityVersion = authorityVersion + 1 WHERE id = ? AND state = 'active'", row.id))
349
- : Effect.void));
350
- const stillCurrent = yield* sync(() => admitted(agentId, ref)).pipe(Effect.result);
351
- if (stillCurrent._tag === "Failure" ||
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,3 @@
1
+ import type { NarrowFetch } from "./contract.js";
2
+ /** The tool names an MCP server offers, listed with the maintained SDK client. */
3
+ export declare function listMcpTools(url: string, fetch: NarrowFetch): Promise<readonly string[]>;
@@ -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
+ }