@fungi.computer/veil 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -54
- package/dist/best-effort-cleanup.d.ts +1 -1
- package/dist/connections/cleanup.d.ts +21 -0
- package/dist/connections/cleanup.js +66 -0
- package/dist/connections/contract.d.ts +192 -7
- package/dist/connections/contract.js +44 -2
- package/dist/connections/core.d.ts +58 -5
- package/dist/connections/core.js +438 -111
- package/dist/connections/discover.d.ts +3 -0
- package/dist/connections/discover.js +23 -0
- package/dist/connections/egress.js +3 -2
- package/dist/connections/index.d.ts +1 -0
- package/dist/connections/index.js +1 -0
- package/dist/connections/internal.d.ts +78 -0
- package/dist/connections/internal.js +66 -0
- package/dist/connections/mcp-agent.d.ts +37 -0
- package/dist/connections/mcp-agent.js +75 -0
- package/dist/connections/nango.d.ts +30 -0
- package/dist/connections/nango.js +179 -0
- package/dist/connections/oauth-flow.d.ts +83 -0
- package/dist/connections/oauth-flow.js +330 -0
- package/dist/connections/oauth-provider.d.ts +31 -0
- package/dist/connections/oauth-provider.js +96 -0
- package/dist/connections/oauth.d.ts +93 -0
- package/dist/connections/oauth.js +239 -0
- package/dist/connections/openapi.d.ts +36 -0
- package/dist/connections/openapi.js +120 -0
- package/dist/connections/sealed.js +23 -4
- package/dist/connections/source.d.ts +17 -0
- package/dist/connections/source.js +111 -0
- package/dist/connections/update.d.ts +41 -0
- package/dist/connections/update.js +81 -0
- package/dist/gatekeeper.d.ts +43 -85
- package/dist/gatekeeper.js +138 -95
- package/dist/github-app.d.ts +44 -0
- package/dist/github-app.js +93 -0
- package/dist/index.d.ts +0 -241
- package/dist/index.js +0 -256
- package/dist/nango.d.ts +40 -15
- package/dist/nango.js +7 -242
- package/package.json +15 -9
- package/dist/nango-git.d.ts +0 -19
- package/dist/nango-git.js +0 -125
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const toolLimit = 1_024;
|
|
2
|
+
/** The tool names an MCP server offers, listed with the maintained SDK client. */
|
|
3
|
+
export async function listMcpTools(url, fetch) {
|
|
4
|
+
const sdk = await import("@modelcontextprotocol/client");
|
|
5
|
+
const client = new sdk.Client({ name: "fungi-connections", version: "1" });
|
|
6
|
+
await client.connect(new sdk.StreamableHTTPClientTransport(new URL(url), {
|
|
7
|
+
fetch,
|
|
8
|
+
requestInit: { redirect: "error" },
|
|
9
|
+
}));
|
|
10
|
+
try {
|
|
11
|
+
const names = [];
|
|
12
|
+
let cursor;
|
|
13
|
+
do {
|
|
14
|
+
const page = await client.listTools(cursor === undefined ? undefined : { cursor });
|
|
15
|
+
names.push(...page.tools.map((tool) => tool.name));
|
|
16
|
+
cursor = page.nextCursor;
|
|
17
|
+
} while (cursor !== undefined && names.length < toolLimit);
|
|
18
|
+
return names.slice(0, toolLimit);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await client.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as Schema from "effect/Schema";
|
|
2
|
-
import { isJSONRPCRequest, parseJSONRPCMessage, } from "@modelcontextprotocol/client";
|
|
3
2
|
import { assertPublicHostName } from "../model-key-network.js";
|
|
4
3
|
import { ConnectionsError } from "./contract.js";
|
|
5
4
|
const bodyLimit = 256 * 1024;
|
|
@@ -30,8 +29,10 @@ export async function admitRequest(endpoint, tools, request, policy) {
|
|
|
30
29
|
if (request.body === null)
|
|
31
30
|
return request;
|
|
32
31
|
const bytes = await boundedBytes(request.clone());
|
|
33
|
-
if (endpoint.kind === "mcp" && tools
|
|
32
|
+
if (endpoint.kind === "mcp" && Array.isArray(tools)) {
|
|
34
33
|
let name = null;
|
|
34
|
+
// The SDK stays out of startup: it loads when a listed MCP grant first meets a body.
|
|
35
|
+
const { isJSONRPCRequest, parseJSONRPCMessage } = await import("@modelcontextprotocol/client");
|
|
35
36
|
try {
|
|
36
37
|
const message = parseJSONRPCMessage(jsonValue(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
37
38
|
if (isJSONRPCRequest(message) && message.method === "tools/call")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { ConnectionsError } from "./contract.js";
|
|
4
|
+
export declare const json: <S extends Schema.Top>(schema: S) => Schema.fromJsonString<S>;
|
|
5
|
+
export declare const connectionRow: Schema.Struct<{
|
|
6
|
+
readonly id: Schema.String;
|
|
7
|
+
readonly name: Schema.String;
|
|
8
|
+
readonly surface: Schema.fromJsonString<Schema.Struct<{
|
|
9
|
+
readonly kind: Schema.Literals<readonly ["mcp", "openapi"]>;
|
|
10
|
+
readonly url: Schema.String;
|
|
11
|
+
readonly table: Schema.NullOr<Schema.$Array<Schema.Unknown>>;
|
|
12
|
+
/** Digest of the decoded table, kept so listings need no decoding. */
|
|
13
|
+
readonly digest: Schema.optional<Schema.NullOr<Schema.String>>;
|
|
14
|
+
/** Display text for owners; the unique `name` is the binding. */
|
|
15
|
+
readonly label: Schema.optional<Schema.String>;
|
|
16
|
+
}>>;
|
|
17
|
+
readonly credential: Schema.fromJsonString<Schema.Union<readonly [Schema.Struct<{
|
|
18
|
+
readonly kind: Schema.Literal<"none">;
|
|
19
|
+
}>, Schema.Struct<{
|
|
20
|
+
readonly kind: Schema.Literal<"static">;
|
|
21
|
+
readonly placement: Schema.Union<readonly [Schema.Literal<"bearer">, Schema.Struct<{
|
|
22
|
+
readonly header: Schema.String;
|
|
23
|
+
}>, Schema.Struct<{
|
|
24
|
+
readonly query: Schema.String;
|
|
25
|
+
}>]>;
|
|
26
|
+
}>, Schema.Struct<{
|
|
27
|
+
readonly kind: Schema.Literal<"oauth">;
|
|
28
|
+
readonly scope: Schema.optional<Schema.String>;
|
|
29
|
+
readonly clientId: Schema.optional<Schema.String>;
|
|
30
|
+
readonly provider: Schema.optional<Schema.String>;
|
|
31
|
+
}>]>>;
|
|
32
|
+
readonly locator: Schema.NullOr<Schema.String>;
|
|
33
|
+
readonly state: Schema.Literals<readonly ["pending", "active", "reconnect_required", "revoked"]>;
|
|
34
|
+
readonly authorityVersion: Schema.Number;
|
|
35
|
+
readonly version: Schema.Number;
|
|
36
|
+
}>;
|
|
37
|
+
export type ConnectionRow = typeof connectionRow.Type;
|
|
38
|
+
export declare const attemptRow: Schema.Struct<{
|
|
39
|
+
readonly state: Schema.String;
|
|
40
|
+
readonly connectionId: Schema.String;
|
|
41
|
+
readonly actor: Schema.String;
|
|
42
|
+
readonly status: Schema.String;
|
|
43
|
+
readonly expiresAt: Schema.Number;
|
|
44
|
+
readonly pkceVerifierSealed: Schema.NullOr<Schema.String>;
|
|
45
|
+
/** The authorization server, or `custody:<integration>` when a custodian runs the sign-in. */
|
|
46
|
+
readonly issuer: Schema.NullOr<Schema.String>;
|
|
47
|
+
}>;
|
|
48
|
+
export type AttemptRow = typeof attemptRow.Type;
|
|
49
|
+
export declare const registryRow: Schema.Struct<{
|
|
50
|
+
readonly locator: Schema.String;
|
|
51
|
+
}>;
|
|
52
|
+
export declare function decode<S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"];
|
|
53
|
+
export declare const sync: <A>(operation: () => A) => Effect.Effect<A, ConnectionsError, never>;
|
|
54
|
+
export declare const io: <A>(operation: () => Promise<A>) => Effect.Effect<A, ConnectionsError, never>;
|
|
55
|
+
export declare const cleanupRow: Schema.Struct<{
|
|
56
|
+
readonly id: Schema.Number;
|
|
57
|
+
readonly connectionId: Schema.String;
|
|
58
|
+
readonly locator: Schema.String;
|
|
59
|
+
readonly credential: Schema.fromJsonString<Schema.Union<readonly [Schema.Struct<{
|
|
60
|
+
readonly kind: Schema.Literal<"none">;
|
|
61
|
+
}>, Schema.Struct<{
|
|
62
|
+
readonly kind: Schema.Literal<"static">;
|
|
63
|
+
readonly placement: Schema.Union<readonly [Schema.Literal<"bearer">, Schema.Struct<{
|
|
64
|
+
readonly header: Schema.String;
|
|
65
|
+
}>, Schema.Struct<{
|
|
66
|
+
readonly query: Schema.String;
|
|
67
|
+
}>]>;
|
|
68
|
+
}>, Schema.Struct<{
|
|
69
|
+
readonly kind: Schema.Literal<"oauth">;
|
|
70
|
+
readonly scope: Schema.optional<Schema.String>;
|
|
71
|
+
readonly clientId: Schema.optional<Schema.String>;
|
|
72
|
+
readonly provider: Schema.optional<Schema.String>;
|
|
73
|
+
}>]>>;
|
|
74
|
+
readonly attempts: Schema.Number;
|
|
75
|
+
}>;
|
|
76
|
+
export declare const count: Schema.Struct<{
|
|
77
|
+
readonly n: Schema.Number;
|
|
78
|
+
}>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { ConnectionsError, credentialSchema, identifier } from "./contract.js";
|
|
4
|
+
export const json = (schema) => Schema.fromJsonString(schema);
|
|
5
|
+
export const connectionRow = Schema.Struct({
|
|
6
|
+
id: identifier,
|
|
7
|
+
name: Schema.String,
|
|
8
|
+
surface: json(Schema.Struct({
|
|
9
|
+
kind: Schema.Literals(["mcp", "openapi"]),
|
|
10
|
+
url: Schema.String,
|
|
11
|
+
table: Schema.NullOr(Schema.Array(Schema.Unknown)),
|
|
12
|
+
/** Digest of the decoded table, kept so listings need no decoding. */
|
|
13
|
+
digest: Schema.optional(Schema.NullOr(Schema.String)),
|
|
14
|
+
/** Display text for owners; the unique `name` is the binding. */
|
|
15
|
+
label: Schema.optional(Schema.String),
|
|
16
|
+
})),
|
|
17
|
+
credential: json(credentialSchema),
|
|
18
|
+
locator: Schema.NullOr(Schema.String),
|
|
19
|
+
state: Schema.Literals([
|
|
20
|
+
"pending",
|
|
21
|
+
"active",
|
|
22
|
+
"reconnect_required",
|
|
23
|
+
"revoked",
|
|
24
|
+
]),
|
|
25
|
+
authorityVersion: Schema.Number,
|
|
26
|
+
version: Schema.Number,
|
|
27
|
+
});
|
|
28
|
+
export const attemptRow = Schema.Struct({
|
|
29
|
+
state: Schema.String,
|
|
30
|
+
connectionId: identifier,
|
|
31
|
+
actor: identifier,
|
|
32
|
+
status: Schema.String,
|
|
33
|
+
expiresAt: Schema.Number,
|
|
34
|
+
pkceVerifierSealed: Schema.NullOr(Schema.String),
|
|
35
|
+
/** The authorization server, or `custody:<integration>` when a custodian runs the sign-in. */
|
|
36
|
+
issuer: Schema.NullOr(Schema.String),
|
|
37
|
+
});
|
|
38
|
+
export const registryRow = Schema.Struct({ locator: Schema.String });
|
|
39
|
+
export function decode(schema, input) {
|
|
40
|
+
try {
|
|
41
|
+
return Schema.decodeUnknownSync(schema)(input);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new ConnectionsError("invalid_input");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export const sync = (operation) => Effect.try({
|
|
48
|
+
try: operation,
|
|
49
|
+
catch: (cause) => cause instanceof ConnectionsError
|
|
50
|
+
? cause
|
|
51
|
+
: new ConnectionsError("unavailable", { cause }),
|
|
52
|
+
});
|
|
53
|
+
export const io = (operation) => Effect.tryPromise({
|
|
54
|
+
try: operation,
|
|
55
|
+
catch: (cause) => cause instanceof ConnectionsError
|
|
56
|
+
? cause
|
|
57
|
+
: new ConnectionsError("unavailable", { cause }),
|
|
58
|
+
});
|
|
59
|
+
export const cleanupRow = Schema.Struct({
|
|
60
|
+
id: Schema.Number,
|
|
61
|
+
connectionId: identifier,
|
|
62
|
+
locator: Schema.String,
|
|
63
|
+
credential: json(credentialSchema),
|
|
64
|
+
attempts: Schema.Number,
|
|
65
|
+
});
|
|
66
|
+
export const count = Schema.Struct({ n: Schema.Number });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ConnectionProjection, type EgressRef, type NarrowFetch } from "./contract.js";
|
|
2
|
+
/** One Agent MCP request as plain data, so it can cross a Durable Object RPC boundary. */
|
|
3
|
+
export type McpRequestData = Readonly<{
|
|
4
|
+
url: string;
|
|
5
|
+
method: string;
|
|
6
|
+
headers: readonly (readonly [string, string])[];
|
|
7
|
+
body: ArrayBuffer | null;
|
|
8
|
+
}>;
|
|
9
|
+
/** A refusal crosses RPC as data: a stable code, never a message. */
|
|
10
|
+
export type McpRefusal = Readonly<{
|
|
11
|
+
error: string;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function mcpRequestData(request: Request): Promise<McpRequestData>;
|
|
14
|
+
/**
|
|
15
|
+
* Complete one Agent MCP request on the owner side, the only place a
|
|
16
|
+
* credential opens. A refusal comes back as a code, never a thrown message.
|
|
17
|
+
*/
|
|
18
|
+
export declare function answerMcpRequest(egress: (agentId: string, ref: EgressRef) => NarrowFetch, agentId: string, ref: EgressRef, data: McpRequestData): Promise<Response | McpRefusal>;
|
|
19
|
+
/**
|
|
20
|
+
* The Agent half of MCP: the servers a projection grants, and a fetch that is
|
|
21
|
+
* completed by the owner. Spread into the host's Knapsack MCP source; no
|
|
22
|
+
* header, token or locator is ever held on this side.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mcpAgentSource(input: {
|
|
25
|
+
read: () => Promise<ConnectionProjection>;
|
|
26
|
+
fetch: (ref: EgressRef, request: Request) => Promise<Response>;
|
|
27
|
+
}): {
|
|
28
|
+
read: () => Promise<{
|
|
29
|
+
version: string;
|
|
30
|
+
servers: {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
url: import("url").URL;
|
|
34
|
+
}[];
|
|
35
|
+
}>;
|
|
36
|
+
fetch: (url: string | URL, init?: RequestInit) => Promise<Response>;
|
|
37
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ConnectionsError, } from "./contract.js";
|
|
2
|
+
export async function mcpRequestData(request) {
|
|
3
|
+
return {
|
|
4
|
+
url: request.url,
|
|
5
|
+
method: request.method,
|
|
6
|
+
headers: [...request.headers],
|
|
7
|
+
body: request.body === null ? null : await request.arrayBuffer(),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Complete one Agent MCP request on the owner side, the only place a
|
|
12
|
+
* credential opens. A refusal comes back as a code, never a thrown message.
|
|
13
|
+
*/
|
|
14
|
+
export async function answerMcpRequest(egress, agentId, ref, data) {
|
|
15
|
+
try {
|
|
16
|
+
const init = {
|
|
17
|
+
method: data.method,
|
|
18
|
+
headers: data.headers.map(([name, value]) => [name, value]),
|
|
19
|
+
redirect: "error",
|
|
20
|
+
};
|
|
21
|
+
if (data.body !== null)
|
|
22
|
+
init.body = data.body;
|
|
23
|
+
return await egress(agentId, ref)(new Request(data.url, init));
|
|
24
|
+
}
|
|
25
|
+
catch (cause) {
|
|
26
|
+
if (cause instanceof ConnectionsError)
|
|
27
|
+
return { error: cause.code };
|
|
28
|
+
throw cause;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The Agent half of MCP: the servers a projection grants, and a fetch that is
|
|
33
|
+
* completed by the owner. Spread into the host's Knapsack MCP source; no
|
|
34
|
+
* header, token or locator is ever held on this side.
|
|
35
|
+
*/
|
|
36
|
+
export function mcpAgentSource(input) {
|
|
37
|
+
const refs = new Map();
|
|
38
|
+
return {
|
|
39
|
+
read: async () => {
|
|
40
|
+
const granted = await input.read();
|
|
41
|
+
refs.clear();
|
|
42
|
+
const servers = granted.connections.flatMap((connection) => {
|
|
43
|
+
if (connection.surface.kind !== "mcp")
|
|
44
|
+
return [];
|
|
45
|
+
refs.set(new URL(connection.surface.url).href, {
|
|
46
|
+
connectionId: connection.connectionId,
|
|
47
|
+
grantVersion: connection.grantVersion,
|
|
48
|
+
});
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
id: `mcp.${connection.connectionId}`,
|
|
52
|
+
name: connection.name,
|
|
53
|
+
url: new URL(connection.surface.url),
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
// Secret-free: ids, versions and endpoints.
|
|
59
|
+
version: JSON.stringify(granted.connections.map((c) => [
|
|
60
|
+
c.connectionId,
|
|
61
|
+
c.grantVersion,
|
|
62
|
+
c.authorityVersion,
|
|
63
|
+
])),
|
|
64
|
+
servers,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
fetch: (url, init) => {
|
|
68
|
+
const request = new Request(url, init);
|
|
69
|
+
const ref = refs.get(request.url);
|
|
70
|
+
return ref === undefined
|
|
71
|
+
? Promise.reject(new Error("MCP server is not connected"))
|
|
72
|
+
: input.fetch(ref, request);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type CustodyAdapter, type CustodyCompletion } from "./contract.js";
|
|
2
|
+
/**
|
|
3
|
+
* Trusted host configuration. `staticIntegrationId` is an API-key integration
|
|
4
|
+
* whose proxy attaches the credential an owner pasted; OAuth connections name
|
|
5
|
+
* their own integration when they begin. The Connect document and API URLs
|
|
6
|
+
* are public and independent of the internal `serverUrl`.
|
|
7
|
+
*/
|
|
8
|
+
export type NangoCustodyOptions = Readonly<{
|
|
9
|
+
serverUrl: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
webhookSigningKey: string;
|
|
12
|
+
staticIntegrationId: string;
|
|
13
|
+
connectBaseUrl: string;
|
|
14
|
+
publicApiUrl: string;
|
|
15
|
+
requestTimeoutMs?: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** A custodian that also verifies its signed completion for the host. */
|
|
18
|
+
export type ConnectionsCustodian = CustodyAdapter & Readonly<{
|
|
19
|
+
verifyCompletion: (rawBody: string, signature: string) => Promise<CustodyCompletion & Readonly<{
|
|
20
|
+
ownerId: string;
|
|
21
|
+
}>>;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Nango as a `CustodyAdapter`: `put` imports an API-key connection, `inject`
|
|
25
|
+
* sends through Nango's proxy (which attaches and refreshes the credential),
|
|
26
|
+
* `remove` deletes the connection, and `connect` opens a Connect session for
|
|
27
|
+
* an authorization Nango runs. `verifyCompletion` turns its signed webhook
|
|
28
|
+
* into a `CustodyCompletion` for the core.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createNangoCustody(options: NangoCustodyOptions): ConnectionsCustodian;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { authWebhookSchema, connectDocument, publicUrl, sessionSchema, tagsSchema, webhookEnvelopeSchema, } from "../nango.js";
|
|
4
|
+
import { createNangoCustodyHttp } from "../nango-custody-http.js";
|
|
5
|
+
import { VeilError } from "../index.js";
|
|
6
|
+
import { ConnectionsError, } from "./contract.js";
|
|
7
|
+
const defaultTimeoutMs = 10_000;
|
|
8
|
+
const decode = (schema, raw) => {
|
|
9
|
+
try {
|
|
10
|
+
return Schema.decodeUnknownSync(Schema.fromJsonString(schema))(raw);
|
|
11
|
+
}
|
|
12
|
+
catch (cause) {
|
|
13
|
+
throw new ConnectionsError("invalid_response", { cause });
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/** Nango says no body of ours is wrong; any failure to speak with it is an outage. */
|
|
17
|
+
const outage = (cause) => cause instanceof ConnectionsError
|
|
18
|
+
? cause
|
|
19
|
+
: cause instanceof VeilError
|
|
20
|
+
? new ConnectionsError(cause.code === "invalid_response"
|
|
21
|
+
? "invalid_response"
|
|
22
|
+
: "unavailable", { cause })
|
|
23
|
+
: new ConnectionsError("unavailable", { cause });
|
|
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
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Nango as a `CustodyAdapter`: `put` imports an API-key connection, `inject`
|
|
38
|
+
* sends through Nango's proxy (which attaches and refreshes the credential),
|
|
39
|
+
* `remove` deletes the connection, and `connect` opens a Connect session for
|
|
40
|
+
* an authorization Nango runs. `verifyCompletion` turns its signed webhook
|
|
41
|
+
* into a `CustodyCompletion` for the core.
|
|
42
|
+
*/
|
|
43
|
+
export function createNangoCustody(options) {
|
|
44
|
+
const http = createNangoCustodyHttp(options);
|
|
45
|
+
const connectBase = publicUrl(options.connectBaseUrl);
|
|
46
|
+
if (!connectBase.pathname.endsWith("/"))
|
|
47
|
+
connectBase.pathname += "/";
|
|
48
|
+
const publicApi = publicUrl(options.publicApiUrl);
|
|
49
|
+
const timeoutMs = options.requestTimeoutMs ?? defaultTimeoutMs;
|
|
50
|
+
const signal = () => AbortSignal.timeout(timeoutMs);
|
|
51
|
+
return {
|
|
52
|
+
put: (address, secret) => attempt(async () => {
|
|
53
|
+
if (address.credential.kind !== "static")
|
|
54
|
+
throw new ConnectionsError("unsupported");
|
|
55
|
+
await http.importCredential({
|
|
56
|
+
integrationId: options.staticIntegrationId,
|
|
57
|
+
locator: address.connectionId,
|
|
58
|
+
teamId: address.ownerId,
|
|
59
|
+
credential: secret,
|
|
60
|
+
}, signal());
|
|
61
|
+
return address.connectionId;
|
|
62
|
+
}),
|
|
63
|
+
inject: (address, locator, request) => attempt(async () => {
|
|
64
|
+
const target = new URL(request.url);
|
|
65
|
+
const headers = new Headers();
|
|
66
|
+
for (const [name, value] of request.headers)
|
|
67
|
+
headers.set(`Nango-Proxy-${name}`, value);
|
|
68
|
+
const contentType = request.headers.get("content-type");
|
|
69
|
+
if (contentType !== null)
|
|
70
|
+
headers.set("Content-Type", contentType);
|
|
71
|
+
headers.set("Provider-Config-Key", address.credential.kind === "oauth" &&
|
|
72
|
+
address.credential.provider !== undefined
|
|
73
|
+
? address.credential.provider
|
|
74
|
+
: options.staticIntegrationId);
|
|
75
|
+
headers.set("Connection-Id", locator);
|
|
76
|
+
headers.set("Base-Url-Override", target.origin);
|
|
77
|
+
headers.set("Retries", "0");
|
|
78
|
+
const response = await http.call(`/proxy${target.pathname}${target.search}`, request.signal, {
|
|
79
|
+
method: request.method,
|
|
80
|
+
headers,
|
|
81
|
+
body: request.method === "GET" ? null : request.body,
|
|
82
|
+
// Native Node fetch requires this for a stream; Workers accepts it.
|
|
83
|
+
duplex: "half",
|
|
84
|
+
});
|
|
85
|
+
// Nango's own refusal of a connection it no longer holds is not the provider's answer.
|
|
86
|
+
if (response.status === 404) {
|
|
87
|
+
await response.body?.cancel();
|
|
88
|
+
throw new ConnectionsError("reconnect_required");
|
|
89
|
+
}
|
|
90
|
+
return response;
|
|
91
|
+
}),
|
|
92
|
+
remove: (address, locator) => attempt(() => http.removeConnection(locator, address.credential.kind === "oauth" &&
|
|
93
|
+
address.credential.provider !== undefined
|
|
94
|
+
? address.credential.provider
|
|
95
|
+
: options.staticIntegrationId, signal())),
|
|
96
|
+
connect: (address, session) => attempt(async () => {
|
|
97
|
+
const response = await http.call("/connect/sessions", signal(), {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: new Headers({ "Content-Type": "application/json" }),
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
allowed_integrations: [session.integrationId],
|
|
102
|
+
end_user: { id: session.actor },
|
|
103
|
+
organization: { id: address.ownerId },
|
|
104
|
+
tags: {
|
|
105
|
+
veil_team_id: address.ownerId,
|
|
106
|
+
veil_attempt_id: session.state,
|
|
107
|
+
veil_principal_id: session.actor,
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok)
|
|
112
|
+
throw new ConnectionsError("unavailable");
|
|
113
|
+
const { data } = decode(sessionSchema, await response.text());
|
|
114
|
+
return {
|
|
115
|
+
connectUrl: connectDocument({ token: data.token, connectUrl: data.connect_link }, connectBase, publicApi),
|
|
116
|
+
expiresAt: data.expires_at.getTime(),
|
|
117
|
+
};
|
|
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
|
+
}),
|
|
150
|
+
verifyCompletion: async (rawBody, signature) => {
|
|
151
|
+
// Maintained HMAC over the raw body first; only then decode the event.
|
|
152
|
+
const { Nango } = await import("@nangohq/node");
|
|
153
|
+
const verifier = new Nango({
|
|
154
|
+
host: new URL(options.serverUrl).origin,
|
|
155
|
+
apiKey: options.apiKey,
|
|
156
|
+
webhookSigningKey: options.webhookSigningKey,
|
|
157
|
+
});
|
|
158
|
+
if (!verifier.verifyIncomingWebhookRequest(rawBody, {
|
|
159
|
+
"X-Nango-Hmac-Sha256": signature,
|
|
160
|
+
}))
|
|
161
|
+
throw new ConnectionsError("denied");
|
|
162
|
+
const envelope = decode(webhookEnvelopeSchema, rawBody);
|
|
163
|
+
if (envelope.type !== "auth" || envelope.operation !== "creation")
|
|
164
|
+
throw new ConnectionsError("unsupported");
|
|
165
|
+
const row = decode(authWebhookSchema, rawBody);
|
|
166
|
+
if (!row.success ||
|
|
167
|
+
row.endUser.endUserId !== row.tags.veil_principal_id ||
|
|
168
|
+
row.endUser.organizationId !== row.tags.veil_team_id)
|
|
169
|
+
throw new ConnectionsError("denied");
|
|
170
|
+
return {
|
|
171
|
+
ownerId: row.tags.veil_team_id,
|
|
172
|
+
state: row.tags.veil_attempt_id,
|
|
173
|
+
locator: row.connectionId,
|
|
174
|
+
principalId: row.tags.veil_principal_id,
|
|
175
|
+
integrationId: row.providerConfigKey,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import type { createPublicEgressPolicy } from "../public-egress-policy.js";
|
|
3
|
+
import { ConnectionsError, type CompleteInput, type CustodyCompletion, type ConnectionDraft, type ConnectionsOptions } from "./contract.js";
|
|
4
|
+
import { type AttemptRow, type ConnectionRow } from "./internal.js";
|
|
5
|
+
/**
|
|
6
|
+
* The narrow, transactional slice of the owner's SQLite the OAuth flow needs.
|
|
7
|
+
* Every method is one synchronous owner transaction; the flow never sees SQL.
|
|
8
|
+
*/
|
|
9
|
+
export type OAuthStore = Readonly<{
|
|
10
|
+
connection: (id: string) => ConnectionRow;
|
|
11
|
+
/** A pending connection, its attempt and the begin receipt, or nothing. */
|
|
12
|
+
recordPending: (input: {
|
|
13
|
+
actor: string;
|
|
14
|
+
connection: ConnectionRow;
|
|
15
|
+
state: string;
|
|
16
|
+
/** The sealed PKCE verifier; none when the custodian runs the authorization. */
|
|
17
|
+
attemptLocator: string | null;
|
|
18
|
+
issuer: string;
|
|
19
|
+
requestId: string;
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
authorizeUrl: string;
|
|
22
|
+
}) => void;
|
|
23
|
+
/** Consume a live attempt for its actor, or retire it when expired. */
|
|
24
|
+
claim: (actor: string, input: CompleteInput) => {
|
|
25
|
+
attempt: AttemptRow;
|
|
26
|
+
wanted: CompleteInput;
|
|
27
|
+
expired: boolean;
|
|
28
|
+
};
|
|
29
|
+
/** Consume a live attempt the custodian runs; expired ones retire. */
|
|
30
|
+
claimCustody: (state: string, principalId: string) => {
|
|
31
|
+
attempt: AttemptRow;
|
|
32
|
+
expired: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** The attempt failed: withdraw the pending connection and clean its verifier. */
|
|
35
|
+
fail: (attempt: AttemptRow) => void;
|
|
36
|
+
/** Pending becomes active with its token locator; false when it is no longer pending. */
|
|
37
|
+
activate: (row: ConnectionRow, attempt: AttemptRow, locator: string) => boolean;
|
|
38
|
+
/** Queue a sealed locator for deletion. */
|
|
39
|
+
discard: (connectionId: string, locator: string, credential: ConnectionRow["credential"]) => void;
|
|
40
|
+
/** Replace the live locator after a refresh, or report why it cannot. */
|
|
41
|
+
swap: (row: ConnectionRow, held: string, locator: string) => "applied" | ConnectionRow["state"];
|
|
42
|
+
clientLocator: (issuer: string) => string | undefined;
|
|
43
|
+
saveClientLocator: (issuer: string, locator: string) => void;
|
|
44
|
+
}>;
|
|
45
|
+
/** Discovery, sign-in, token refresh and the registered-client record for OAuth connections. */
|
|
46
|
+
export declare function createOAuthFlow(deps: {
|
|
47
|
+
ownerId: string;
|
|
48
|
+
custody: ConnectionsOptions["custody"];
|
|
49
|
+
oauth: ConnectionsOptions["oauth"];
|
|
50
|
+
clock: () => number;
|
|
51
|
+
ids: () => string;
|
|
52
|
+
policy: ReturnType<typeof createPublicEgressPolicy>;
|
|
53
|
+
store: OAuthStore;
|
|
54
|
+
sweep: () => Effect.Effect<void, ConnectionsError>;
|
|
55
|
+
}): {
|
|
56
|
+
begin: (actor: string, draft: ConnectionDraft, credential: Extract<ConnectionDraft["credential"], {
|
|
57
|
+
kind: "oauth";
|
|
58
|
+
}>, endpoint: string, fingerprint: string, openapi: Readonly<{
|
|
59
|
+
table: readonly unknown[];
|
|
60
|
+
digest: string;
|
|
61
|
+
}> | null) => Effect.Effect<{
|
|
62
|
+
connectionId: string;
|
|
63
|
+
authorizeUrl: string;
|
|
64
|
+
attemptId: string;
|
|
65
|
+
}, ConnectionsError, never>;
|
|
66
|
+
beginCustody: (actor: string, draft: ConnectionDraft, credential: Extract<ConnectionDraft["credential"], {
|
|
67
|
+
kind: "oauth";
|
|
68
|
+
}>, endpoint: string, fingerprint: string, openapi: Readonly<{
|
|
69
|
+
table: readonly unknown[];
|
|
70
|
+
digest: string;
|
|
71
|
+
}> | null, kind: "mcp" | "openapi") => Effect.Effect<{
|
|
72
|
+
connectionId: string;
|
|
73
|
+
authorizeUrl: string;
|
|
74
|
+
attemptId: string;
|
|
75
|
+
}, ConnectionsError, never>;
|
|
76
|
+
complete: (actor: string, input: CompleteInput) => Effect.Effect<{
|
|
77
|
+
connectionId: string;
|
|
78
|
+
}, ConnectionsError, never>;
|
|
79
|
+
completeCustody: (input: CustodyCompletion) => Effect.Effect<{
|
|
80
|
+
connectionId: string;
|
|
81
|
+
}, ConnectionsError, never>;
|
|
82
|
+
send: (row: ConnectionRow, request: Request) => Effect.Effect<Response, ConnectionsError, never>;
|
|
83
|
+
};
|