@fungi.computer/veil 0.5.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 CHANGED
@@ -1,69 +1,63 @@
1
1
  # Veil (`@fungi.computer/veil`)
2
2
 
3
- This package keeps provider credentials behind trusted Team doors. It owns
4
- GitHub Connection custody, model-key grants and the Agent's anonymous public-web
5
- fetch. A guest receives an authorized operation, not the provider token or its
6
- private locator.
3
+ This package keeps provider credentials behind trusted owner doors. It owns the
4
+ shared connections core (MCP, OpenAPI and a repository's git endpoint),
5
+ model-key grants and the Agent's anonymous public-web fetch. A guest receives an
6
+ authorized operation, not the provider token or its private locator.
7
7
 
8
8
  ## Set up and check
9
9
 
10
- The host supplies Team Connection authority and a `CredentialCustodian` to
11
- `createGitHubConnections`. Model input cannot select a repository or private
12
- locator. The `@fungi.computer/veil/nango` adapter needs a configured Nango
13
- origin, API key, webhook signing key, integration ID and public Connect URLs;
14
- these are host secrets or configuration, never browser or model input. Without
15
- custody configuration, report unavailable rather than manufacturing a
16
- connection.
10
+ The host supplies the owner's SQLite, a `CustodyAdapter` and its live
11
+ authorization to `createConnections` (`@fungi.computer/veil/connections`).
12
+ Custody is `sealed` by default; Nango is the optional adapter
13
+ (`@fungi.computer/veil/connections/nango`). Model input cannot choose a
14
+ connection, repository or private locator. Without custody configuration, report
15
+ unavailable rather than manufacturing a connection.
17
16
 
18
17
  Run `pnpm --filter @fungi.computer/veil test` for the package's synthetic
19
- custody, signed-webhook and public-fetch checks. For an exercised connection
20
- lifecycle, start with `test/nango-git.test.ts`; a unit test does not prove a
18
+ custody, signed-webhook and public-fetch checks. A unit test does not prove a
21
19
  live Nango service or provider account.
22
20
 
23
- The root export in `src/index.ts` defines Team Connection receipts and
24
- `createGitHubConnections`. `package.json` lists the Nango, model-key and
25
- public-fetch subpaths used by trusted hosts; do not import private locators from
26
- these modules into guest code.
21
+ GitHub REST is an ordinary `openapi` connection with an `oauth` credential:
22
+ through an operator-registered provider on either custody adapter, or through a
23
+ Nango integration. A custodian-run integration is pinned by the host
24
+ (`configuredCustodyIntegrations`) to one surface kind and one origin, and the
25
+ core refuses it at `begin` and again before every send, so the credential Nango
26
+ holds never goes anywhere else. GitHub REST is pinned to `api.github.com`.
27
+
28
+ A Computer's git is not a connection in this core.
29
+ `@fungi.computer/veil/github-app` wraps Nango's `github-app` integration
30
+ (`NANGO_GITHUB_APP_INTEGRATION_ID`, never the same as
31
+ `NANGO_GITHUB_INTEGRATION_ID`): it opens the installation consent, verifies the
32
+ signed completion, finds a lost one, deletes a connection and reads the current
33
+ installation token. The Hub places that token on a request to `github.com` and
34
+ nowhere else.
27
35
 
28
36
  ## Nango adapter
29
37
 
30
- `@fungi.computer/veil/nango` exports `createNangoCustodian`. The trusted host
31
- supplies a Nango server origin, API key, separate webhook signing key, one
32
- integration ID, and the public Connect UI base and public API URL. These are
33
- host configuration, never browser or model input. `openSession` returns the
34
- Connect document browsers open: Nango's issued link, confined to the configured
35
- Connect UI and pinned to the public API with `apiURL`. Unconfigured hosts must
36
- report unavailable instead of substituting fake custody.
37
-
38
- The adapter uses `@nangohq/node@0.71.6` to verify the raw callback body and
39
- ordinary `fetch` with each invocation's AbortSignal for REST operations. It does
40
- not call credential-returning connection inspection. Connect sessions carry
41
- Team-created attempt/principal/Team tags and restrict the integration. Signed
42
- webhooks are verified before decoding; the adapter handles only a successful
43
- `creation` and a failed `refresh` (reconnect evidence) and refuses every other
44
- operation as `unsupported`. It also checks end-user and organization
45
- correlation; Team owns final activation and replay decisions. A verified
46
- completion the Team refuses (late, unknown, superseded, or failing activation)
47
- is deleted from custody, and the settled deletion is recorded.
48
-
49
- Reconciliation queries `/connections` with exact integration, end-user,
50
- organization and attempt tags and a limit of two. The selected server filters
51
- before applying that limit: zero matches remain unresolved, multiple matches are
52
- ambiguous, and every returned row is checked again before a single completion is
53
- accepted. It never selects the first candidate from an arbitrary page.
54
-
55
- The pinned public deletion endpoint returns `200 {success:true}` after deletion
56
- and `400 {error:{code:"unknown_connection"}}` when the selected connection is
57
- absent. Other responses, including generic 404 and `success:false`, do not
58
- settle cleanup. This mapping is specific to the selected server implementation,
59
- not a generic Nango error policy. Provider/proxy failures stay unavailable,
60
- except a proxied provider `401` carrying GitHub's own error body, which marks
61
- the Connection reconnect-required; Nango's own `401` envelope stays unavailable.
62
-
63
- The one proxy operation is `GET /proxy/repos/{owner}/{name}` with private Nango
64
- connection/integration headers. Veil's existing caller decodes credential-free
65
- metadata and verifies the repository ID. Cancellation stops waiting through the
66
- maintained transport; it does not prove upstream work stopped or rolled back.
38
+ `createNangoCustody` takes a Nango server origin, API key, a separate webhook
39
+ signing key, one API-key integration for pasted credentials, and the public
40
+ Connect UI base and public API URL. These are host configuration, never browser
41
+ or model input. `connect` returns the Connect document browsers open: Nango's
42
+ issued link, confined to the configured Connect UI and pinned to the public API
43
+ with `apiURL`. `verifyCompletion` verifies the raw callback body with
44
+ `@nangohq/node` before decoding it, and the core settles only an attempt the
45
+ owner began. Unconfigured hosts must report unavailable instead of substituting
46
+ fake custody.
47
+
48
+ `inject` sends through Nango's proxy, which attaches and refreshes the
49
+ credential; the pinned deletion endpoint returns `200 {success:true}` after
50
+ deletion and `400 {error:{code:"unknown_connection"}}` when the connection is
51
+ absent, and only those settle cleanup. A proxy `404` is the custodian no longer
52
+ holding the connection and marks it reconnect-required. `find` asks Nango for
53
+ the one connection that carries an attempt's state, actor and Team, so a sign-in
54
+ whose signed completion was lost still settles. Cancellation stops the caller
55
+ waiting; it does not prove upstream work stopped or rolled back.
56
+
57
+ `configuredComputerGitIntegration(env)` names the integration through which a
58
+ Computer's git signs in (`NANGO_GITHUB_APP_INTEGRATION_ID`);
59
+ `NANGO_GITHUB_INTEGRATION_ID` joins the integrations an owner may sign in
60
+ through for REST connections.
67
61
 
68
62
  ## Public-web door
69
63
 
@@ -1,2 +1,2 @@
1
1
  /** Report cleanup failures without exposing provider or transport details. */
2
- export declare function bestEffortCleanup(operation: "nango-git-response-body" | "public-fetch-socket", cleanup: () => Promise<void>): Promise<void>;
2
+ export declare function bestEffortCleanup(operation: "public-fetch-socket", cleanup: () => Promise<void>): Promise<void>;
@@ -1,13 +1,17 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Schema from "effect/Schema";
3
3
  import type { PublicEgressPolicy } from "../public-egress-policy.js";
4
- export type ConnectionsRefusal = "invalid_input" | "denied" | "revoked" | "conflict" | "unsupported" | "too_large" | "reconnect_required" | "pending" | "invalid_response" | "unavailable";
4
+ export type ConnectionsRefusal = "invalid_input" | "denied" | "revoked" | "conflict" | "unsupported" | "too_large" | "reconnect_required" | "pending" | "invalid_response"
5
+ /** The provider's rate limit refused the request; retry later. */
6
+ | "rate_limited" | "unavailable";
5
7
  /** Fixed codes only: upstream bodies, URLs and credential bytes never enter errors. */
6
8
  export declare class ConnectionsError extends Error {
7
9
  readonly code: ConnectionsRefusal;
8
10
  readonly name = "ConnectionsError";
9
11
  constructor(code: ConnectionsRefusal, options?: ErrorOptions);
10
12
  }
13
+ /** How long a sign-in attempt stays open before it expires. */
14
+ export declare const attemptTtlMs: number;
11
15
  export declare const identifier: Schema.String;
12
16
  declare const placementSchema: Schema.Union<readonly [Schema.Literal<"bearer">, Schema.Struct<{
13
17
  readonly header: Schema.String;
@@ -164,6 +168,16 @@ export type CustodyAdapter = Readonly<{
164
168
  * the page to send the owner to; it settles through a signed completion
165
169
  * (`complete`) carrying the `state` given here, not through our callback.
166
170
  */
171
+ /**
172
+ * Ask a custodian that ran an authorization whether it finished, when its
173
+ * signed completion never arrived. Only an exact match of the attempt's
174
+ * state, actor and integration counts; two matches are ambiguous.
175
+ */
176
+ find?: (address: CustodyAddress, attempt: Readonly<{
177
+ state: string;
178
+ actor: string;
179
+ integrationId: string;
180
+ }>) => Effect.Effect<CustodyLookup, ConnectionsError>;
167
181
  connect?: (address: CustodyAddress, attempt: Readonly<{
168
182
  state: string;
169
183
  actor: string;
@@ -173,6 +187,14 @@ export type CustodyAdapter = Readonly<{
173
187
  expiresAt: number;
174
188
  }>, ConnectionsError>;
175
189
  }>;
190
+ export type CustodyLookup = Readonly<{
191
+ status: "unresolved";
192
+ }> | Readonly<{
193
+ status: "ambiguous";
194
+ }> | Readonly<{
195
+ status: "found";
196
+ locator: string;
197
+ }>;
176
198
  /**
177
199
  * What a custodian proves after it ran an authorization: the attempt's state
178
200
  * and where it keeps the credential. The host verifies the custodian's
@@ -181,6 +203,8 @@ export type CustodyAdapter = Readonly<{
181
203
  export type CustodyCompletion = Readonly<{
182
204
  state: string;
183
205
  locator: string;
206
+ /** Who the custodian says signed in; it must be who began the attempt. */
207
+ principalId: string;
184
208
  /** The integration the custodian ran it through; it must be the one the attempt began with. */
185
209
  integrationId: string;
186
210
  }>;
@@ -210,6 +234,16 @@ export type StaticOAuthProvider = Readonly<{
210
234
  client_id: string;
211
235
  client_secret?: string | undefined;
212
236
  };
237
+ /** The https origins this provider's API serves; its token goes nowhere else. */
238
+ apiOrigins: readonly string[];
239
+ }>;
240
+ /**
241
+ * An integration a custodian runs the sign-in for, as the operator declared
242
+ * it: the https origins its credential may reach. The custodian attaches the
243
+ * credential to whatever origin it is handed, so the core pins it here.
244
+ */
245
+ export type CustodyProvider = Readonly<{
246
+ apiOrigins: readonly string[];
213
247
  }>;
214
248
  export type ConnectionsOptions = Readonly<{
215
249
  ownerId: string;
@@ -242,6 +276,11 @@ export type ConnectionsOptions = Readonly<{
242
276
  /** REST providers the operator registered an app with, by id. */
243
277
  providers?: Readonly<Record<string, StaticOAuthProvider>>;
244
278
  }> | undefined;
279
+ /**
280
+ * Integrations the custodian signs in through, by id. An OAuth provider id
281
+ * that is neither here nor in `oauth.providers` is refused.
282
+ */
283
+ custodyProviders?: Readonly<Record<string, CustodyProvider>> | undefined;
245
284
  clock: () => number;
246
285
  ids: () => string;
247
286
  }>;
@@ -9,6 +9,8 @@ export class ConnectionsError extends Error {
9
9
  this.code = code;
10
10
  }
11
11
  }
12
+ /** How long a sign-in attempt stays open before it expires. */
13
+ export const attemptTtlMs = 10 * 60 * 1000;
12
14
  export const identifier = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_.:-]{1,128}$/u)));
13
15
  const headerName = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9-]{1,64}$/u)));
14
16
  const queryName = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_.-]{1,64}$/u)));
@@ -5,6 +5,7 @@ export declare function createConnections(options: ConnectionsOptions): {
5
5
  begin: (actor: string, input: ConnectionDraft) => Effect.Effect<{
6
6
  connectionId: string;
7
7
  authorizeUrl: string | null;
8
+ attemptId: string | null;
8
9
  }, ConnectionsError, never>;
9
10
  complete: (actor: string, input: import("./contract.js").CompleteInput) => Effect.Effect<{
10
11
  connectionId: string;
@@ -95,4 +96,6 @@ export declare function createConnections(options: ConnectionsOptions): {
95
96
  }, ConnectionsError, never>;
96
97
  sweep: () => Effect.Effect<void, ConnectionsError, never>;
97
98
  cleanupPending: () => number;
99
+ reconcilePending: () => Effect.Effect<string[], ConnectionsError, never>;
100
+ awaitingCustody: () => number;
98
101
  };
@@ -2,7 +2,7 @@ import * as Effect from "effect/Effect";
2
2
  import * as Schema from "effect/Schema";
3
3
  import { assertPublicHostName } from "../model-key-network.js";
4
4
  import { createPublicEgressPolicy } from "../public-egress-policy.js";
5
- import { ConnectionsError, callInputSchema, completeInputSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
5
+ import { ConnectionsError, attemptTtlMs, callInputSchema, completeInputSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
6
6
  import { admitRequest } from "./egress.js";
7
7
  import { admits, boundedText, decodeTable, operationOutput, operationRequest, } from "./openapi.js";
8
8
  import { createCleanup } from "./cleanup.js";
@@ -10,7 +10,6 @@ import { attemptRow, connectionRow, count, decode, io, json, registryRow, sync,
10
10
  import { createOAuthFlow } from "./oauth-flow.js";
11
11
  import { listMcpTools } from "./discover.js";
12
12
  import { createUpdate } from "./update.js";
13
- const attemptTtlMs = 10 * 60 * 1000;
14
13
  const grantRow = Schema.Struct({
15
14
  connectionId: identifier,
16
15
  agentId: identifier,
@@ -24,6 +23,7 @@ const receiptRow = Schema.Struct({
24
23
  result: json(Schema.Struct({
25
24
  connectionId: identifier,
26
25
  authorizeUrl: Schema.optional(Schema.String),
26
+ attemptId: Schema.optional(Schema.String),
27
27
  version: Schema.optional(Schema.Number),
28
28
  })),
29
29
  });
@@ -72,6 +72,19 @@ export function createConnections(options) {
72
72
  throw new ConnectionsError("denied");
73
73
  return row;
74
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
+ };
75
88
  /** One live connection per binding name, so an Agent's module name is unambiguous. */
76
89
  const assertNameFree = (name, exceptId) => {
77
90
  const taken = rows(count, "SELECT COUNT(*) AS n FROM veil_connections WHERE name = ? AND state != 'revoked' AND id != ?", name, exceptId ?? "")[0];
@@ -144,7 +157,9 @@ export function createConnections(options) {
144
157
  parsed.credential.clientId !== undefined ||
145
158
  (options.oauth?.providers?.[parsed.credential.provider] ===
146
159
  undefined
147
- ? options.custody.connect === undefined
160
+ ? options.custody.connect === undefined ||
161
+ options.custodyProviders?.[parsed.credential.provider] ===
162
+ undefined
148
163
  : parsed.surface.kind !== "openapi"))))
149
164
  throw new ConnectionsError("invalid_input");
150
165
  return parsed;
@@ -161,13 +176,18 @@ export function createConnections(options) {
161
176
  return {
162
177
  connectionId: replay.connectionId,
163
178
  authorizeUrl: replay.authorizeUrl ?? null,
179
+ attemptId: replay.attemptId ?? null,
164
180
  };
165
181
  yield* sync(() => {
166
182
  assertNameFree(draft.name, null);
167
183
  });
168
- const endpoint = yield* sync(() => publicUrl(draft.surface.kind === "mcp"
169
- ? draft.surface.url
170
- : draft.surface.baseUrl));
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
+ });
171
191
  const surface = draft.surface;
172
192
  const decoded = surface.kind === "openapi"
173
193
  ? yield* io(() => decodeTable(surface.table))
@@ -179,7 +199,7 @@ export function createConnections(options) {
179
199
  const viaCustodian = draft.credential.provider !== undefined &&
180
200
  options.oauth?.providers?.[draft.credential.provider] === undefined;
181
201
  return yield* viaCustodian
182
- ? oauthFlow.beginCustody(actor, draft, draft.credential, endpoint, fingerprint(draft), table)
202
+ ? oauthFlow.beginCustody(actor, draft, draft.credential, endpoint, fingerprint(draft), table, draft.surface.kind)
183
203
  : oauthFlow.begin(actor, draft, draft.credential, endpoint, fingerprint(draft), table);
184
204
  }
185
205
  const connectionId = options.ids();
@@ -218,7 +238,7 @@ export function createConnections(options) {
218
238
  sql.exec("UPDATE veil_connection_attempts SET status = 'completed' WHERE state = ?", state);
219
239
  sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", draft.requestId, fingerprint(draft), JSON.stringify({ connectionId }));
220
240
  bump();
221
- return { connectionId, authorizeUrl: null };
241
+ return { connectionId, authorizeUrl: null, attemptId: null };
222
242
  })).pipe(Effect.tapError(() => sync(() => {
223
243
  enqueueCleanup(stored);
224
244
  })));
@@ -235,17 +255,19 @@ export function createConnections(options) {
235
255
  sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", input.requestId, input.fingerprint, JSON.stringify({
236
256
  connectionId: stored.id,
237
257
  authorizeUrl: input.authorizeUrl,
258
+ attemptId: input.state,
238
259
  }));
239
260
  bump();
240
261
  });
241
262
  },
242
- claimCustody: (state) => transactionSync(() => {
263
+ claimCustody: (state, principalId) => transactionSync(() => {
243
264
  if (!state.startsWith(`${ownerId}.`))
244
265
  throw new ConnectionsError("denied");
245
266
  const attempt = rows(attemptRow, "SELECT * FROM veil_connection_attempts WHERE state = ?", state)[0];
246
267
  // A browser attempt holds a sealed verifier; only a custodian's has none.
247
268
  if (attempt?.status !== "pending" ||
248
- attempt.pkceVerifierSealed !== null)
269
+ attempt.pkceVerifierSealed !== null ||
270
+ attempt.actor !== principalId)
249
271
  throw new ConnectionsError("denied");
250
272
  if (attempt.expiresAt <= options.clock()) {
251
273
  retireAttempt(attempt, "expired");
@@ -461,6 +483,9 @@ export function createConnections(options) {
461
483
  !(yield* io(() => verdict(grantor, { agentId, connectionId: row.id, tools }, operation))))
462
484
  return yield* Effect.fail(new ConnectionsError("denied"));
463
485
  yield* sync(admit);
486
+ yield* sync(() => {
487
+ assertProviderOrigin(row.credential, request.url, "denied");
488
+ });
464
489
  if (row.locator === null && row.credential.kind === "static")
465
490
  return yield* Effect.fail(new ConnectionsError("denied"));
466
491
  const response = yield* // A custodian that runs the sign-in refreshes the token itself.
@@ -482,10 +507,13 @@ export function createConnections(options) {
482
507
  }
483
508
  return response;
484
509
  });
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 })))));
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
+ }));
489
517
  const egress = (agentId, input) => {
490
518
  const ref = decode(egressRefSchema, input);
491
519
  return (target, init) => fetchThrough(() => admitted(agentId, ref), target, init);
@@ -563,6 +591,44 @@ export function createConnections(options) {
563
591
  return yield* Effect.fail(new ConnectionsError("unsupported"));
564
592
  return yield* io(() => decodeTable(row.surface.table));
565
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
+ });
566
632
  const update = createUpdate({
567
633
  ownerId,
568
634
  custody: options.custody,
@@ -627,5 +693,7 @@ export function createConnections(options) {
627
693
  update,
628
694
  sweep,
629
695
  cleanupPending,
696
+ reconcilePending,
697
+ awaitingCustody: () => awaitingCustody().length,
630
698
  };
631
699
  }
@@ -1,6 +1,6 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Schema from "effect/Schema";
3
- import { authWebhookSchema, connectDocument, publicUrl, sessionSchema, webhookEnvelopeSchema, } from "../nango.js";
3
+ import { authWebhookSchema, connectDocument, publicUrl, sessionSchema, tagsSchema, webhookEnvelopeSchema, } from "../nango.js";
4
4
  import { createNangoCustodyHttp } from "../nango-custody-http.js";
5
5
  import { VeilError } from "../index.js";
6
6
  import { ConnectionsError, } from "./contract.js";
@@ -22,6 +22,17 @@ const outage = (cause) => cause instanceof ConnectionsError
22
22
  : "unavailable", { cause })
23
23
  : new ConnectionsError("unavailable", { cause });
24
24
  const attempt = (body) => Effect.tryPromise({ try: body, catch: outage });
25
+ const connectionsSchema = Schema.Struct({
26
+ connections: Schema.Array(Schema.Struct({
27
+ connection_id: Schema.NonEmptyString,
28
+ provider_config_key: Schema.NonEmptyString,
29
+ tags: tagsSchema,
30
+ end_user: Schema.Struct({
31
+ id: Schema.NonEmptyString,
32
+ organization: Schema.Struct({ id: Schema.NonEmptyString }),
33
+ }),
34
+ })),
35
+ });
25
36
  /**
26
37
  * Nango as a `CustodyAdapter`: `put` imports an API-key connection, `inject`
27
38
  * sends through Nango's proxy (which attaches and refreshes the credential),
@@ -105,6 +116,37 @@ export function createNangoCustody(options) {
105
116
  expiresAt: data.expires_at.getTime(),
106
117
  };
107
118
  }),
119
+ find: (address, session) => attempt(async () => {
120
+ const query = new URLSearchParams({
121
+ integrationId: session.integrationId,
122
+ endUserId: session.actor,
123
+ endUserOrganizationId: address.ownerId,
124
+ limit: "2",
125
+ page: "0",
126
+ });
127
+ query.set("tags[veil_team_id]", address.ownerId);
128
+ query.set("tags[veil_attempt_id]", session.state);
129
+ query.set("tags[veil_principal_id]", session.actor);
130
+ const response = await http.call(`/connections?${query.toString()}`, signal(), {});
131
+ if (!response.ok)
132
+ throw new ConnectionsError("unavailable");
133
+ const { connections } = decode(connectionsSchema, await response.text());
134
+ // The server filters before its limit; two rows prove ambiguity.
135
+ for (const row of connections)
136
+ if (row.provider_config_key !== session.integrationId ||
137
+ row.end_user.id !== session.actor ||
138
+ row.end_user.organization.id !== address.ownerId ||
139
+ row.tags.veil_team_id !== address.ownerId ||
140
+ row.tags.veil_attempt_id !== session.state ||
141
+ row.tags.veil_principal_id !== session.actor)
142
+ throw new ConnectionsError("invalid_response");
143
+ const [only, second] = connections;
144
+ if (only === undefined)
145
+ return { status: "unresolved" };
146
+ if (second !== undefined)
147
+ return { status: "ambiguous" };
148
+ return { status: "found", locator: only.connection_id };
149
+ }),
108
150
  verifyCompletion: async (rawBody, signature) => {
109
151
  // Maintained HMAC over the raw body first; only then decode the event.
110
152
  const { Nango } = await import("@nangohq/node");
@@ -129,6 +171,7 @@ export function createNangoCustody(options) {
129
171
  ownerId: row.tags.veil_team_id,
130
172
  state: row.tags.veil_attempt_id,
131
173
  locator: row.connectionId,
174
+ principalId: row.tags.veil_principal_id,
132
175
  integrationId: row.providerConfigKey,
133
176
  };
134
177
  },
@@ -27,7 +27,7 @@ export type OAuthStore = Readonly<{
27
27
  expired: boolean;
28
28
  };
29
29
  /** Consume a live attempt the custodian runs; expired ones retire. */
30
- claimCustody: (state: string) => {
30
+ claimCustody: (state: string, principalId: string) => {
31
31
  attempt: AttemptRow;
32
32
  expired: boolean;
33
33
  };
@@ -61,15 +61,17 @@ export declare function createOAuthFlow(deps: {
61
61
  }> | null) => Effect.Effect<{
62
62
  connectionId: string;
63
63
  authorizeUrl: string;
64
+ attemptId: string;
64
65
  }, ConnectionsError, never>;
65
66
  beginCustody: (actor: string, draft: ConnectionDraft, credential: Extract<ConnectionDraft["credential"], {
66
67
  kind: "oauth";
67
68
  }>, endpoint: string, fingerprint: string, openapi: Readonly<{
68
69
  table: readonly unknown[];
69
70
  digest: string;
70
- }> | null) => Effect.Effect<{
71
+ }> | null, kind: "mcp" | "openapi") => Effect.Effect<{
71
72
  connectionId: string;
72
73
  authorizeUrl: string;
74
+ attemptId: string;
73
75
  }, ConnectionsError, never>;
74
76
  complete: (actor: string, input: CompleteInput) => Effect.Effect<{
75
77
  connectionId: string;
@@ -71,7 +71,7 @@ export function createOAuthFlow(deps) {
71
71
  catch: (cause) => new ConnectionsError("reconnect_required", { cause }),
72
72
  })));
73
73
  /** The custodian runs the authorization: we hold only the page to send the owner to. */
74
- const beginCustody = (actor, draft, credential, endpoint, fingerprint, openapi) => Effect.gen(function* () {
74
+ const beginCustody = (actor, draft, credential, endpoint, fingerprint, openapi, kind) => Effect.gen(function* () {
75
75
  const connect = custody.connect;
76
76
  const integrationId = credential.provider;
77
77
  if (connect === undefined || integrationId === undefined)
@@ -89,7 +89,7 @@ export function createOAuthFlow(deps) {
89
89
  authorityVersion: 1,
90
90
  version: 1,
91
91
  surface: {
92
- kind: openapi === null ? "mcp" : "openapi",
92
+ kind,
93
93
  url: endpoint,
94
94
  table: openapi?.table ?? null,
95
95
  digest: openapi?.digest ?? null,
@@ -107,7 +107,11 @@ export function createOAuthFlow(deps) {
107
107
  fingerprint,
108
108
  authorizeUrl: session.connectUrl,
109
109
  });
110
- return { connectionId, authorizeUrl: session.connectUrl };
110
+ return {
111
+ connectionId,
112
+ authorizeUrl: session.connectUrl,
113
+ attemptId: state,
114
+ };
111
115
  });
112
116
  });
113
117
  /**
@@ -116,7 +120,7 @@ export function createOAuthFlow(deps) {
116
120
  * connection is not live until this settles it.
117
121
  */
118
122
  const completeCustody = (input) => Effect.gen(function* () {
119
- const claimed = yield* sync(() => store.claimCustody(input.state));
123
+ const claimed = yield* sync(() => store.claimCustody(input.state, input.principalId));
120
124
  if (claimed.expired)
121
125
  return yield* Effect.fail(new ConnectionsError("denied"));
122
126
  if (claimed.attempt.issuer !== `custody:${input.integrationId}`)
@@ -193,7 +197,11 @@ export function createOAuthFlow(deps) {
193
197
  fingerprint,
194
198
  authorizeUrl: started.authorizeUrl,
195
199
  });
196
- return { connectionId, authorizeUrl: started.authorizeUrl };
200
+ return {
201
+ connectionId,
202
+ authorizeUrl: started.authorizeUrl,
203
+ attemptId: state,
204
+ };
197
205
  }).pipe(Effect.tapError(() => sync(() => {
198
206
  store.discard(connectionId, attemptLocator, credential);
199
207
  })));