@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.
Files changed (43) hide show
  1. package/README.md +48 -54
  2. package/dist/best-effort-cleanup.d.ts +1 -1
  3. package/dist/connections/cleanup.d.ts +21 -0
  4. package/dist/connections/cleanup.js +66 -0
  5. package/dist/connections/contract.d.ts +192 -7
  6. package/dist/connections/contract.js +44 -2
  7. package/dist/connections/core.d.ts +58 -5
  8. package/dist/connections/core.js +438 -111
  9. package/dist/connections/discover.d.ts +3 -0
  10. package/dist/connections/discover.js +23 -0
  11. package/dist/connections/egress.js +3 -2
  12. package/dist/connections/index.d.ts +1 -0
  13. package/dist/connections/index.js +1 -0
  14. package/dist/connections/internal.d.ts +78 -0
  15. package/dist/connections/internal.js +66 -0
  16. package/dist/connections/mcp-agent.d.ts +37 -0
  17. package/dist/connections/mcp-agent.js +75 -0
  18. package/dist/connections/nango.d.ts +30 -0
  19. package/dist/connections/nango.js +179 -0
  20. package/dist/connections/oauth-flow.d.ts +83 -0
  21. package/dist/connections/oauth-flow.js +330 -0
  22. package/dist/connections/oauth-provider.d.ts +31 -0
  23. package/dist/connections/oauth-provider.js +96 -0
  24. package/dist/connections/oauth.d.ts +93 -0
  25. package/dist/connections/oauth.js +239 -0
  26. package/dist/connections/openapi.d.ts +36 -0
  27. package/dist/connections/openapi.js +120 -0
  28. package/dist/connections/sealed.js +23 -4
  29. package/dist/connections/source.d.ts +17 -0
  30. package/dist/connections/source.js +111 -0
  31. package/dist/connections/update.d.ts +41 -0
  32. package/dist/connections/update.js +81 -0
  33. package/dist/gatekeeper.d.ts +43 -85
  34. package/dist/gatekeeper.js +138 -95
  35. package/dist/github-app.d.ts +44 -0
  36. package/dist/github-app.js +93 -0
  37. package/dist/index.d.ts +0 -241
  38. package/dist/index.js +0 -256
  39. package/dist/nango.d.ts +40 -15
  40. package/dist/nango.js +7 -242
  41. package/package.json +15 -9
  42. package/dist/nango-git.d.ts +0 -19
  43. package/dist/nango-git.js +0 -125
@@ -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, credentialSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
5
+ import { ConnectionsError, attemptTtlMs, callInputSchema, completeInputSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
7
6
  import { admitRequest } from "./egress.js";
8
- 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
- });
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({ 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 }),
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, 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,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 = () => 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
- }
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
- if (parsed.credential.kind === "static"
164
- ? parsed.secret === undefined
165
- : parsed.secret !== undefined)
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.connectionId;
173
+ return receipt.result;
176
174
  });
177
175
  if (replay !== null)
178
- return { connectionId: replay, authorizeUrl: null };
179
- const endpoint = yield* sync(() => publicUrl(draft.surface.kind === "mcp"
180
- ? draft.surface.url
181
- : draft.surface.baseUrl));
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 table = surface.kind === "openapi"
184
- ? (yield* io(() => parseOpenApiTable(surface.table))).operations
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 === "all"
230
- ? false
231
- : row.surface.kind !== "mcp" && wanted.tools.length === 0)
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
- const surface = row.surface.kind === "mcp"
284
- ? { kind: "mcp", url: row.surface.url }
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: (yield* io(() => parseOpenApiTable(row.surface.table)))
289
- .operations,
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) => 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")
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 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;
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
  }