@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 +48 -54
- package/dist/best-effort-cleanup.d.ts +1 -1
- package/dist/connections/contract.d.ts +40 -1
- package/dist/connections/contract.js +2 -0
- package/dist/connections/core.d.ts +3 -0
- package/dist/connections/core.js +82 -14
- package/dist/connections/nango.js +44 -1
- package/dist/connections/oauth-flow.d.ts +4 -2
- package/dist/connections/oauth-flow.js +13 -5
- package/dist/gatekeeper.d.ts +29 -91
- package/dist/gatekeeper.js +86 -105
- package/dist/github-app.d.ts +44 -0
- package/dist/github-app.js +93 -0
- package/dist/index.d.ts +0 -241
- package/dist/index.js +0 -256
- package/dist/nango.d.ts +0 -16
- package/dist/nango.js +1 -236
- package/package.json +5 -9
- package/dist/nango-git.d.ts +0 -19
- package/dist/nango-git.js +0 -125
package/README.md
CHANGED
|
@@ -1,69 +1,63 @@
|
|
|
1
1
|
# Veil (`@fungi.computer/veil`)
|
|
2
2
|
|
|
3
|
-
This package keeps provider credentials behind trusted
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
completion
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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: "
|
|
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"
|
|
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
|
};
|
package/dist/connections/core.js
CHANGED
|
@@ -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 === "
|
|
169
|
-
? draft.surface.
|
|
170
|
-
: draft.surface.
|
|
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) =>
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
})));
|