@fungi.computer/veil 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +171 -2
- package/dist/best-effort-cleanup.d.ts +2 -0
- package/dist/best-effort-cleanup.js +5 -0
- package/dist/event-verifiers.d.ts +80 -0
- package/dist/event-verifiers.js +168 -0
- package/dist/gatekeeper.d.ts +202 -0
- package/dist/gatekeeper.js +231 -0
- package/dist/index.d.ts +254 -0
- package/dist/index.js +267 -0
- package/dist/model-credentials.d.ts +226 -0
- package/dist/model-credentials.js +133 -0
- package/dist/model-key-grants.d.ts +56 -0
- package/dist/model-key-grants.js +107 -0
- package/dist/model-key-network.d.ts +33 -0
- package/dist/model-key-network.js +202 -0
- package/dist/model-keys.d.ts +42 -0
- package/dist/model-keys.js +115 -0
- package/dist/nango-custody-http.d.ts +16 -0
- package/dist/nango-custody-http.js +93 -0
- package/dist/nango-git.d.ts +19 -0
- package/dist/nango-git.js +125 -0
- package/dist/nango-model-keys.d.ts +13 -0
- package/dist/nango-model-keys.js +48 -0
- package/dist/nango-world-seat.d.ts +14 -0
- package/dist/nango-world-seat.js +47 -0
- package/dist/nango.d.ts +16 -0
- package/dist/nango.js +300 -0
- package/dist/openapi-contract.d.ts +64 -0
- package/dist/openapi-contract.js +11 -0
- package/dist/openapi-effect.d.ts +9 -0
- package/dist/openapi-effect.js +284 -0
- package/dist/openapi-http.d.ts +2 -0
- package/dist/openapi-http.js +37 -0
- package/dist/openapi-secrets.d.ts +6 -0
- package/dist/openapi-secrets.js +80 -0
- package/dist/openapi.d.ts +5 -0
- package/dist/openapi.js +24 -0
- package/dist/owned-infrastructure.d.ts +13 -0
- package/dist/owned-infrastructure.js +17 -0
- package/dist/provider-egress.d.ts +51 -0
- package/dist/provider-egress.js +155 -0
- package/dist/public-egress-policy.d.ts +13 -0
- package/dist/public-egress-policy.js +101 -0
- package/dist/public-fetch.d.ts +90 -0
- package/dist/public-fetch.js +347 -0
- package/dist/webhook-trigger-secrets.d.ts +23 -0
- package/dist/webhook-trigger-secrets.js +53 -0
- package/dist/world-seat.d.ts +40 -0
- package/dist/world-seat.js +82 -0
- package/package.json +140 -3
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { VeilError } from "./index.js";
|
|
2
|
+
import { bestEffortCleanup } from "./best-effort-cleanup.js";
|
|
3
|
+
/** The four smart-HTTP exchanges this proxy admits for one repository, the
|
|
4
|
+
* same operation table as the Artifacts custodian. Receive-pack is admitted
|
|
5
|
+
* here; GitHub's own installation permission and branch rules decide it. */
|
|
6
|
+
function gitOperation(request, url, base) {
|
|
7
|
+
for (const service of ["git-upload-pack", "git-receive-pack"]) {
|
|
8
|
+
const push = service === "git-receive-pack";
|
|
9
|
+
if (request.method === "GET" &&
|
|
10
|
+
url.pathname === `${base}/info/refs` &&
|
|
11
|
+
url.search === `?service=${service}`)
|
|
12
|
+
return {
|
|
13
|
+
push,
|
|
14
|
+
requestType: null,
|
|
15
|
+
responseType: `application/x-${service}-advertisement`,
|
|
16
|
+
};
|
|
17
|
+
if (request.method === "POST" &&
|
|
18
|
+
url.pathname === `${base}/${service}` &&
|
|
19
|
+
url.search === "")
|
|
20
|
+
return {
|
|
21
|
+
push,
|
|
22
|
+
requestType: `application/x-${service}-request`,
|
|
23
|
+
responseType: `application/x-${service}-result`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** GitHub refusals keep a typed code; vendor bodies never cross this port. */
|
|
29
|
+
function upstreamRefusal(response) {
|
|
30
|
+
if (response.status === 429 ||
|
|
31
|
+
(response.status === 403 &&
|
|
32
|
+
response.headers.get("X-RateLimit-Remaining") === "0"))
|
|
33
|
+
return new VeilError("rate_limited");
|
|
34
|
+
// GitHub hides a repository outside the installation behind 404.
|
|
35
|
+
if (response.status === 404)
|
|
36
|
+
return new VeilError("repository_not_installed");
|
|
37
|
+
// A read-only installation token is refused write with 403.
|
|
38
|
+
if (response.status === 403)
|
|
39
|
+
return new VeilError("denied");
|
|
40
|
+
return new VeilError("unavailable");
|
|
41
|
+
}
|
|
42
|
+
/** The caller must obtain fresh Computer/repository authorization before each
|
|
43
|
+
* invocation. This adapter owns Nango control headers, not authorization state. */
|
|
44
|
+
export function createNangoGitCustodian(options) {
|
|
45
|
+
if (!URL.canParse(options.serverUrl))
|
|
46
|
+
throw new VeilError("invalid_input");
|
|
47
|
+
const server = new URL(options.serverUrl);
|
|
48
|
+
if (!options.apiKey ||
|
|
49
|
+
!options.integrationId ||
|
|
50
|
+
server.username ||
|
|
51
|
+
server.password ||
|
|
52
|
+
server.search ||
|
|
53
|
+
server.hash ||
|
|
54
|
+
server.pathname !== "/" ||
|
|
55
|
+
!["http:", "https:"].includes(server.protocol))
|
|
56
|
+
throw new VeilError("invalid_input");
|
|
57
|
+
return {
|
|
58
|
+
/** Fetch, clone and push for one authorized repository. */
|
|
59
|
+
async readGit(access, request) {
|
|
60
|
+
if (access.integrationId !== options.integrationId)
|
|
61
|
+
throw new VeilError("denied");
|
|
62
|
+
const url = new URL(request.url);
|
|
63
|
+
const parts = [access.repository.owner, access.repository.name];
|
|
64
|
+
if (!access.locator ||
|
|
65
|
+
parts.some((part) => !part || part === "." || part === ".."))
|
|
66
|
+
throw new VeilError("invalid_input");
|
|
67
|
+
const base = `/${parts.map(encodeURIComponent).join("/")}.git`;
|
|
68
|
+
const operation = gitOperation(request, url, base);
|
|
69
|
+
if (url.origin !== "https://github.com" ||
|
|
70
|
+
url.username ||
|
|
71
|
+
url.password ||
|
|
72
|
+
url.hash ||
|
|
73
|
+
operation === null)
|
|
74
|
+
throw new VeilError("denied");
|
|
75
|
+
const headers = new Headers({
|
|
76
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
77
|
+
"Provider-Config-Key": options.integrationId,
|
|
78
|
+
"Connection-Id": access.locator,
|
|
79
|
+
});
|
|
80
|
+
if (operation.requestType !== null) {
|
|
81
|
+
headers.set("Content-Type", operation.requestType);
|
|
82
|
+
headers.set("Nango-Proxy-Content-Type", operation.requestType);
|
|
83
|
+
}
|
|
84
|
+
const protocol = request.headers.get("Git-Protocol");
|
|
85
|
+
if (protocol !== null)
|
|
86
|
+
headers.set("Nango-Proxy-Git-Protocol", protocol);
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
request.signal.throwIfAborted();
|
|
90
|
+
response = await fetch(new URL(`/proxy${url.pathname}${url.search}`, server), {
|
|
91
|
+
method: request.method,
|
|
92
|
+
headers,
|
|
93
|
+
body: operation.requestType === null ? null : request.body,
|
|
94
|
+
signal: request.signal,
|
|
95
|
+
redirect: "manual",
|
|
96
|
+
// Native Node fetch requires this for a stream; Workers accepts it.
|
|
97
|
+
duplex: "half",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (cause) {
|
|
101
|
+
throw new VeilError(request.signal.aborted ? "cancelled" : "unavailable", cause);
|
|
102
|
+
}
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
await bestEffortCleanup("nango-git-response-body", () => response.body?.cancel() ?? Promise.resolve());
|
|
105
|
+
// Reads keep their retried `unavailable`; a push names its refusal.
|
|
106
|
+
throw operation.push
|
|
107
|
+
? upstreamRefusal(response)
|
|
108
|
+
: new VeilError("unavailable");
|
|
109
|
+
}
|
|
110
|
+
if (response.headers.get("Content-Type") !== operation.responseType) {
|
|
111
|
+
await bestEffortCleanup("nango-git-response-body", () => response.body?.cancel() ?? Promise.resolve());
|
|
112
|
+
throw new VeilError("invalid_response");
|
|
113
|
+
}
|
|
114
|
+
// A protected-branch or hook refusal is GitHub's own report-status
|
|
115
|
+
// inside this 200 body; the Computer's git prints it unchanged.
|
|
116
|
+
return new Response(response.body, {
|
|
117
|
+
status: response.status,
|
|
118
|
+
headers: {
|
|
119
|
+
"Content-Type": operation.responseType,
|
|
120
|
+
"Cache-Control": "no-store",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ModelKeyCustodian, type ModelKeyProvider } from "./model-keys.js";
|
|
2
|
+
/**
|
|
3
|
+
* Trusted host configuration. Each provider maps to a Nango API-key
|
|
4
|
+
* integration whose proxy attaches that provider's auth header.
|
|
5
|
+
* `openai-compatible` uses a bearer-token integration with a base override.
|
|
6
|
+
*/
|
|
7
|
+
export type NangoModelKeyOptions = Readonly<{
|
|
8
|
+
serverUrl: string;
|
|
9
|
+
apiKey: string;
|
|
10
|
+
integrations: Readonly<Partial<Record<ModelKeyProvider, string>>>;
|
|
11
|
+
}>;
|
|
12
|
+
/** Nango custody for Team model API keys: import once, proxy at egress, delete. */
|
|
13
|
+
export declare function createNangoModelKeyCustodian(options: NangoModelKeyOptions): ModelKeyCustodian;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { VeilError } from "./index.js";
|
|
2
|
+
import { createNangoCustodyHttp } from "./nango-custody-http.js";
|
|
3
|
+
import { modelKeyForwardedHeaders, modelKeyTarget, } from "./model-keys.js";
|
|
4
|
+
/** Nango custody for Team model API keys: import once, proxy at egress, delete. */
|
|
5
|
+
export function createNangoModelKeyCustodian(options) {
|
|
6
|
+
const http = createNangoCustodyHttp(options);
|
|
7
|
+
const integration = (provider) => {
|
|
8
|
+
const id = options.integrations[provider];
|
|
9
|
+
if (id === undefined || id.length === 0)
|
|
10
|
+
throw new VeilError("unavailable");
|
|
11
|
+
return id;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
async seal(input, signal) {
|
|
15
|
+
if (input.apiKey.length === 0 || input.apiKey.length > 16_384)
|
|
16
|
+
throw new VeilError("invalid_input");
|
|
17
|
+
await http.importCredential({
|
|
18
|
+
integrationId: integration(input.provider),
|
|
19
|
+
locator: input.locator,
|
|
20
|
+
teamId: input.teamId,
|
|
21
|
+
credential: input.apiKey,
|
|
22
|
+
}, signal);
|
|
23
|
+
},
|
|
24
|
+
async send(access, request) {
|
|
25
|
+
const target = modelKeyTarget(access, request);
|
|
26
|
+
const headers = new Headers();
|
|
27
|
+
for (const [name, value] of modelKeyForwardedHeaders(request))
|
|
28
|
+
headers.set(`Nango-Proxy-${name}`, value);
|
|
29
|
+
const contentType = request.headers.get("content-type");
|
|
30
|
+
if (contentType !== null)
|
|
31
|
+
headers.set("Content-Type", contentType);
|
|
32
|
+
headers.set("Provider-Config-Key", integration(access.provider));
|
|
33
|
+
headers.set("Connection-Id", access.locator);
|
|
34
|
+
headers.set("Base-Url-Override", target.origin);
|
|
35
|
+
headers.set("Retries", "0");
|
|
36
|
+
return await http.call(`/proxy${target.pathname}${target.search}`, request.signal, {
|
|
37
|
+
method: request.method,
|
|
38
|
+
headers,
|
|
39
|
+
body: request.method === "GET" ? null : request.body,
|
|
40
|
+
// Native Node fetch requires this for a stream; Workers accepts it.
|
|
41
|
+
duplex: "half",
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
async remove(access, signal) {
|
|
45
|
+
return http.removeConnection(access.locator, integration(access.provider), signal);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type WorldSeatCustodian } from "./world-seat.js";
|
|
2
|
+
/**
|
|
3
|
+
* Trusted host configuration: one Nango bearer-token integration whose proxy
|
|
4
|
+
* attaches the sealed token to the seat's own endpoint.
|
|
5
|
+
*/
|
|
6
|
+
export type NangoWorldSeatOptions = Readonly<{
|
|
7
|
+
serverUrl: string;
|
|
8
|
+
apiKey: string;
|
|
9
|
+
integrationId: string;
|
|
10
|
+
/** The host's explicit local/dev flag: admits a loopback seat endpoint. */
|
|
11
|
+
allowLocalDev?: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
/** Nango custody for world-seat tokens: import once, proxy at egress, delete. */
|
|
14
|
+
export declare function createNangoWorldSeatCustodian(options: NangoWorldSeatOptions): WorldSeatCustodian;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { VeilError } from "./index.js";
|
|
2
|
+
import { createNangoCustodyHttp } from "./nango-custody-http.js";
|
|
3
|
+
import { WORLD_SEAT_TOKEN_MAX, worldSeatEndpoint, worldSeatForwardedHeaders, worldSeatTarget, } from "./world-seat.js";
|
|
4
|
+
/** Nango custody for world-seat tokens: import once, proxy at egress, delete. */
|
|
5
|
+
export function createNangoWorldSeatCustodian(options) {
|
|
6
|
+
if (!options.integrationId)
|
|
7
|
+
throw new VeilError("invalid_input");
|
|
8
|
+
const http = createNangoCustodyHttp(options);
|
|
9
|
+
return {
|
|
10
|
+
async seal(input, signal) {
|
|
11
|
+
worldSeatEndpoint(input.endpoint, {
|
|
12
|
+
allowLocalDev: options.allowLocalDev === true,
|
|
13
|
+
});
|
|
14
|
+
if (input.token.length === 0 || input.token.length > WORLD_SEAT_TOKEN_MAX)
|
|
15
|
+
throw new VeilError("invalid_input");
|
|
16
|
+
await http.importCredential({
|
|
17
|
+
integrationId: options.integrationId,
|
|
18
|
+
locator: input.locator,
|
|
19
|
+
teamId: input.teamId,
|
|
20
|
+
credential: input.token,
|
|
21
|
+
}, signal);
|
|
22
|
+
},
|
|
23
|
+
async send(access, request) {
|
|
24
|
+
const target = worldSeatTarget(access, request);
|
|
25
|
+
const headers = new Headers();
|
|
26
|
+
for (const [name, value] of worldSeatForwardedHeaders(request))
|
|
27
|
+
headers.set(`Nango-Proxy-${name}`, value);
|
|
28
|
+
const contentType = request.headers.get("content-type");
|
|
29
|
+
if (contentType !== null)
|
|
30
|
+
headers.set("Content-Type", contentType);
|
|
31
|
+
headers.set("Provider-Config-Key", options.integrationId);
|
|
32
|
+
headers.set("Connection-Id", access.locator);
|
|
33
|
+
headers.set("Base-Url-Override", target.origin);
|
|
34
|
+
headers.set("Retries", "0");
|
|
35
|
+
return await http.call(`/proxy${target.pathname}`, request.signal, {
|
|
36
|
+
method: request.method,
|
|
37
|
+
headers,
|
|
38
|
+
body: request.method === "POST" ? request.body : null,
|
|
39
|
+
// Native Node fetch requires this for a stream; Workers accepts it.
|
|
40
|
+
duplex: "half",
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
async remove(access, signal) {
|
|
44
|
+
return http.removeConnection(access.locator, options.integrationId, signal);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/dist/nango.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CredentialCustodian } from "./index.js";
|
|
2
|
+
/** Trusted host configuration; never supplied by a model or Connect browser.
|
|
3
|
+
* The public Connect document and API URLs are independent of the internal
|
|
4
|
+
* serverUrl the Hub uses for custody. */
|
|
5
|
+
export type NangoCustodianOptions = Readonly<{
|
|
6
|
+
serverUrl: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
webhookSigningKey: string;
|
|
9
|
+
integrationId: string;
|
|
10
|
+
connectBaseUrl: string;
|
|
11
|
+
publicApiUrl: string;
|
|
12
|
+
/** Deadline for one custodian request and its body; a miss is `unavailable`. */
|
|
13
|
+
requestTimeoutMs?: number;
|
|
14
|
+
}>;
|
|
15
|
+
/** Pinned Nango Connect/connection REST contract and maintained raw-body verifier. */
|
|
16
|
+
export declare function createNangoCustodian(options: NangoCustodianOptions): CredentialCustodian;
|
package/dist/nango.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import * as Exit from "effect/Exit";
|
|
3
|
+
import { VeilError, } from "./index.js";
|
|
4
|
+
const text = Schema.NonEmptyString;
|
|
5
|
+
const tagsSchema = Schema.Struct({
|
|
6
|
+
veil_team_id: text,
|
|
7
|
+
veil_attempt_id: text,
|
|
8
|
+
veil_principal_id: text,
|
|
9
|
+
});
|
|
10
|
+
const sessionSchema = Schema.Struct({
|
|
11
|
+
data: Schema.Struct({
|
|
12
|
+
token: text,
|
|
13
|
+
connect_link: text,
|
|
14
|
+
expires_at: Schema.DateFromString,
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
const webhookEnvelopeSchema = Schema.Struct({
|
|
18
|
+
type: text,
|
|
19
|
+
operation: Schema.optional(text),
|
|
20
|
+
});
|
|
21
|
+
/** Nango auth webhook fields shared by creation and refresh notifications. */
|
|
22
|
+
const authWebhookSchema = Schema.Struct({
|
|
23
|
+
type: Schema.Literal("auth"),
|
|
24
|
+
operation: Schema.Literals(["creation", "refresh"]),
|
|
25
|
+
success: Schema.Boolean,
|
|
26
|
+
connectionId: text,
|
|
27
|
+
providerConfigKey: text,
|
|
28
|
+
tags: tagsSchema,
|
|
29
|
+
endUser: Schema.Struct({ endUserId: text, organizationId: text }),
|
|
30
|
+
});
|
|
31
|
+
/** GitHub's own unauthorized body, as relayed verbatim by the Nango proxy.
|
|
32
|
+
* Nango's own errors use an `error` envelope and remain unavailable. */
|
|
33
|
+
const providerUnauthorizedSchema = Schema.Struct({
|
|
34
|
+
message: text,
|
|
35
|
+
documentation_url: text,
|
|
36
|
+
});
|
|
37
|
+
const connectionSchema = Schema.Struct({
|
|
38
|
+
connection_id: text,
|
|
39
|
+
provider_config_key: text,
|
|
40
|
+
tags: tagsSchema,
|
|
41
|
+
end_user: Schema.Struct({
|
|
42
|
+
id: text,
|
|
43
|
+
organization: Schema.Struct({ id: text }),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
const connectionsSchema = Schema.Struct({
|
|
47
|
+
connections: Schema.Array(connectionSchema),
|
|
48
|
+
});
|
|
49
|
+
const deletedSchema = Schema.Struct({ success: Schema.Literal(true) });
|
|
50
|
+
const absentSchema = Schema.Struct({
|
|
51
|
+
error: Schema.Struct({ code: Schema.Literal("unknown_connection") }),
|
|
52
|
+
});
|
|
53
|
+
/** A custodian that stops answering is an outage, retried later, never a dead credential. */
|
|
54
|
+
const defaultRequestTimeoutMs = 10_000;
|
|
55
|
+
function publicUrl(value) {
|
|
56
|
+
let url;
|
|
57
|
+
try {
|
|
58
|
+
url = new URL(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new VeilError("invalid_input");
|
|
62
|
+
}
|
|
63
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") ||
|
|
64
|
+
url.username ||
|
|
65
|
+
url.password ||
|
|
66
|
+
url.search ||
|
|
67
|
+
url.hash)
|
|
68
|
+
throw new VeilError("invalid_input");
|
|
69
|
+
return url;
|
|
70
|
+
}
|
|
71
|
+
/** The Connect document handed to browsers: Nango's issued link, confined to
|
|
72
|
+
* the configured Connect UI and pinned to the configured public API. */
|
|
73
|
+
function connectDocument(session, base, api) {
|
|
74
|
+
let document;
|
|
75
|
+
try {
|
|
76
|
+
document = new URL(session.connectUrl);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new VeilError("invalid_response");
|
|
80
|
+
}
|
|
81
|
+
if (document.origin !== base.origin ||
|
|
82
|
+
document.pathname !== base.pathname ||
|
|
83
|
+
document.username ||
|
|
84
|
+
document.password ||
|
|
85
|
+
document.hash ||
|
|
86
|
+
document.searchParams.get("session_token") !== session.token)
|
|
87
|
+
throw new VeilError("invalid_response");
|
|
88
|
+
document.searchParams.set("apiURL", api.href);
|
|
89
|
+
return document.href;
|
|
90
|
+
}
|
|
91
|
+
/** Pinned Nango Connect/connection REST contract and maintained raw-body verifier. */
|
|
92
|
+
export function createNangoCustodian(options) {
|
|
93
|
+
let server;
|
|
94
|
+
try {
|
|
95
|
+
server = new URL(options.serverUrl);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new VeilError("invalid_input");
|
|
99
|
+
}
|
|
100
|
+
if (!options.apiKey ||
|
|
101
|
+
!options.webhookSigningKey ||
|
|
102
|
+
!options.integrationId ||
|
|
103
|
+
server.username ||
|
|
104
|
+
server.password ||
|
|
105
|
+
server.search ||
|
|
106
|
+
server.hash ||
|
|
107
|
+
server.pathname !== "/" ||
|
|
108
|
+
(server.protocol !== "https:" && server.protocol !== "http:"))
|
|
109
|
+
throw new VeilError("invalid_input");
|
|
110
|
+
const connectBase = publicUrl(options.connectBaseUrl);
|
|
111
|
+
if (!connectBase.pathname.endsWith("/"))
|
|
112
|
+
connectBase.pathname += "/";
|
|
113
|
+
const publicApi = publicUrl(options.publicApiUrl);
|
|
114
|
+
const decode = (schema, raw, code = "invalid_response") => {
|
|
115
|
+
try {
|
|
116
|
+
return Schema.decodeUnknownSync(Schema.fromJsonString(schema))(raw);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new VeilError(code);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? defaultRequestTimeoutMs;
|
|
123
|
+
const request = async (path, signal, init = {}) => {
|
|
124
|
+
try {
|
|
125
|
+
signal.throwIfAborted();
|
|
126
|
+
const headers = new Headers(init.headers);
|
|
127
|
+
headers.set("Authorization", `Bearer ${options.apiKey}`);
|
|
128
|
+
// The deadline also aborts the body stream that `body` reads next.
|
|
129
|
+
return await fetch(new URL(path, server), {
|
|
130
|
+
...init,
|
|
131
|
+
headers,
|
|
132
|
+
signal: AbortSignal.any([
|
|
133
|
+
signal,
|
|
134
|
+
AbortSignal.timeout(requestTimeoutMs),
|
|
135
|
+
]),
|
|
136
|
+
redirect: "manual",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new VeilError(signal.aborted ? "cancelled" : "unavailable");
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const body = async (response, signal) => {
|
|
144
|
+
try {
|
|
145
|
+
return await response.text();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new VeilError(signal.aborted ? "cancelled" : "unavailable");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const requireSuccess = (response) => {
|
|
152
|
+
if (!response.ok)
|
|
153
|
+
throw new VeilError("unavailable");
|
|
154
|
+
};
|
|
155
|
+
const tags = (teamId, attemptId, principalId) => ({
|
|
156
|
+
veil_team_id: teamId,
|
|
157
|
+
veil_attempt_id: attemptId,
|
|
158
|
+
veil_principal_id: principalId,
|
|
159
|
+
});
|
|
160
|
+
const completion = (row, integrationId, locator) => ({
|
|
161
|
+
teamId: row.veil_team_id,
|
|
162
|
+
attemptId: row.veil_attempt_id,
|
|
163
|
+
principalId: row.veil_principal_id,
|
|
164
|
+
integrationId,
|
|
165
|
+
locator,
|
|
166
|
+
});
|
|
167
|
+
/**
|
|
168
|
+
* Maintained HMAC over the raw body first; only then decode the operation.
|
|
169
|
+
* The Nango SDK (and its HTTP client) loads with the first webhook rather
|
|
170
|
+
* than with every Worker that hosts a custodian.
|
|
171
|
+
*/
|
|
172
|
+
const verifyWebhook = (rawBody, signature) => import("@nangohq/node").then(({ Nango }) => {
|
|
173
|
+
const verifier = new Nango({
|
|
174
|
+
host: server.origin,
|
|
175
|
+
apiKey: options.apiKey,
|
|
176
|
+
webhookSigningKey: options.webhookSigningKey,
|
|
177
|
+
});
|
|
178
|
+
if (!verifier.verifyIncomingWebhookRequest(rawBody, {
|
|
179
|
+
"X-Nango-Hmac-Sha256": signature,
|
|
180
|
+
}))
|
|
181
|
+
throw new VeilError("invalid_completion");
|
|
182
|
+
const envelope = decode(webhookEnvelopeSchema, rawBody, "invalid_completion");
|
|
183
|
+
if (envelope.type !== "auth" ||
|
|
184
|
+
(envelope.operation !== "creation" && envelope.operation !== "refresh"))
|
|
185
|
+
throw new VeilError("unsupported");
|
|
186
|
+
const row = decode(authWebhookSchema, rawBody, "invalid_completion");
|
|
187
|
+
// Handled: a successful creation, or a failed credential refresh.
|
|
188
|
+
if (row.success !== (row.operation === "creation"))
|
|
189
|
+
throw new VeilError("unsupported");
|
|
190
|
+
if (row.providerConfigKey !== options.integrationId ||
|
|
191
|
+
row.endUser.endUserId !== row.tags.veil_principal_id ||
|
|
192
|
+
row.endUser.organizationId !== row.tags.veil_team_id)
|
|
193
|
+
throw new VeilError("invalid_completion");
|
|
194
|
+
return {
|
|
195
|
+
operation: row.operation === "creation" ? "creation" : "refresh_failed",
|
|
196
|
+
completion: completion(row.tags, row.providerConfigKey, row.connectionId),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
async openSession(input, signal) {
|
|
201
|
+
if (input.integrationId !== options.integrationId)
|
|
202
|
+
throw new VeilError("denied");
|
|
203
|
+
const response = await request("/connect/sessions", signal, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "Content-Type": "application/json" },
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
allowed_integrations: [options.integrationId],
|
|
208
|
+
end_user: { id: input.principalId },
|
|
209
|
+
organization: { id: input.teamId },
|
|
210
|
+
tags: tags(input.teamId, input.attemptId, input.principalId),
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
requireSuccess(response);
|
|
214
|
+
const { data } = decode(sessionSchema, await body(response, signal));
|
|
215
|
+
return {
|
|
216
|
+
token: data.token,
|
|
217
|
+
connectUrl: connectDocument({ token: data.token, connectUrl: data.connect_link }, connectBase, publicApi),
|
|
218
|
+
expiresAt: data.expires_at.getTime(),
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
verifyWebhook,
|
|
222
|
+
async verifyCompletion(rawBody, signature) {
|
|
223
|
+
const event = await verifyWebhook(rawBody, signature);
|
|
224
|
+
if (event.operation !== "creation")
|
|
225
|
+
throw new VeilError("unsupported");
|
|
226
|
+
return event.completion;
|
|
227
|
+
},
|
|
228
|
+
async findCompletion(attempt, signal) {
|
|
229
|
+
if (attempt.integrationId !== options.integrationId)
|
|
230
|
+
throw new VeilError("denied");
|
|
231
|
+
const expected = tags(attempt.teamId, attempt.attemptId, attempt.actorUserId);
|
|
232
|
+
const query = new URLSearchParams({
|
|
233
|
+
integrationId: options.integrationId,
|
|
234
|
+
endUserId: attempt.actorUserId,
|
|
235
|
+
endUserOrganizationId: attempt.teamId,
|
|
236
|
+
limit: "2",
|
|
237
|
+
page: "0",
|
|
238
|
+
});
|
|
239
|
+
for (const [key, value] of Object.entries(expected))
|
|
240
|
+
query.set(`tags[${key}]`, value);
|
|
241
|
+
const response = await request(`/connections?${query.toString()}`, signal);
|
|
242
|
+
requireSuccess(response);
|
|
243
|
+
const { connections } = decode(connectionsSchema, await body(response, signal));
|
|
244
|
+
// Selected server filters before LIMIT; two rows are sufficient to prove ambiguity.
|
|
245
|
+
for (const row of connections) {
|
|
246
|
+
if (row.provider_config_key !== options.integrationId ||
|
|
247
|
+
row.end_user.id !== attempt.actorUserId ||
|
|
248
|
+
row.end_user.organization.id !== attempt.teamId ||
|
|
249
|
+
row.tags.veil_team_id !== attempt.teamId ||
|
|
250
|
+
row.tags.veil_attempt_id !== attempt.attemptId ||
|
|
251
|
+
row.tags.veil_principal_id !== attempt.actorUserId)
|
|
252
|
+
throw new VeilError("invalid_response");
|
|
253
|
+
}
|
|
254
|
+
if (connections.length === 0)
|
|
255
|
+
return { status: "unresolved" };
|
|
256
|
+
if (connections.length !== 1)
|
|
257
|
+
return { status: "ambiguous" };
|
|
258
|
+
const row = connections[0];
|
|
259
|
+
if (!row)
|
|
260
|
+
throw new VeilError("invalid_response");
|
|
261
|
+
return {
|
|
262
|
+
status: "verified",
|
|
263
|
+
completion: completion(row.tags, row.provider_config_key, row.connection_id),
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
async deleteConnection(locator, signal) {
|
|
267
|
+
const query = new URLSearchParams({
|
|
268
|
+
provider_config_key: options.integrationId,
|
|
269
|
+
});
|
|
270
|
+
const response = await request(`/connections/${encodeURIComponent(locator)}?${query.toString()}`, signal, { method: "DELETE" });
|
|
271
|
+
const raw = await body(response, signal);
|
|
272
|
+
// This pinned public endpoint reports absent as HTTP400, not a generic404.
|
|
273
|
+
if (response.status === 400) {
|
|
274
|
+
decode(absentSchema, raw);
|
|
275
|
+
return "absent";
|
|
276
|
+
}
|
|
277
|
+
requireSuccess(response);
|
|
278
|
+
decode(deletedSchema, raw);
|
|
279
|
+
return "deleted";
|
|
280
|
+
},
|
|
281
|
+
async readRepository(access, signal) {
|
|
282
|
+
// WHATWG URL normalizes dot segments, including percent-encoded dots.
|
|
283
|
+
if ([access.repository.owner, access.repository.name].some((part) => part === "." || part === ".."))
|
|
284
|
+
throw new VeilError("invalid_input");
|
|
285
|
+
const path = `/proxy/repos/${encodeURIComponent(access.repository.owner)}/${encodeURIComponent(access.repository.name)}`;
|
|
286
|
+
const response = await request(path, signal, {
|
|
287
|
+
headers: {
|
|
288
|
+
"Connection-Id": access.locator,
|
|
289
|
+
"Provider-Config-Key": options.integrationId,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
if (response.status === 401) {
|
|
293
|
+
const evidence = Schema.decodeUnknownExit(Schema.fromJsonString(providerUnauthorizedSchema))(await body(response, signal));
|
|
294
|
+
throw new VeilError(Exit.isSuccess(evidence) ? "reconnect_required" : "unavailable");
|
|
295
|
+
}
|
|
296
|
+
requireSuccess(response);
|
|
297
|
+
return await body(response, signal);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { HostResolver } from "./model-key-network.js";
|
|
2
|
+
import type { PublicEgressPolicy } from "./public-egress-policy.js";
|
|
3
|
+
import type { PublicConnect } from "./public-fetch.js";
|
|
4
|
+
/** Only the trusted Team owner supplies access and selects a stored operation. */
|
|
5
|
+
export type OpenApiAuth = Readonly<{
|
|
6
|
+
kind: "none";
|
|
7
|
+
}> | Readonly<{
|
|
8
|
+
kind: "api_key_header" | "api_key_query";
|
|
9
|
+
param: string;
|
|
10
|
+
}> | Readonly<{
|
|
11
|
+
kind: "bearer";
|
|
12
|
+
}> | Readonly<{
|
|
13
|
+
kind: "oauth";
|
|
14
|
+
provider: string;
|
|
15
|
+
locator: string;
|
|
16
|
+
}>;
|
|
17
|
+
export type OpenApiAccess = Readonly<{
|
|
18
|
+
teamId: string;
|
|
19
|
+
connectorId: string;
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
auth: OpenApiAuth;
|
|
22
|
+
}>;
|
|
23
|
+
export type OpenApiCredentialAddress = Readonly<{
|
|
24
|
+
teamId: string;
|
|
25
|
+
connectorId: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** The host persists ciphertext only. Never give this port to an Agent or browser. */
|
|
28
|
+
export type OpenApiCredentialStore = Readonly<{
|
|
29
|
+
read: (address: OpenApiCredentialAddress, signal: AbortSignal) => Promise<string | null>;
|
|
30
|
+
write: (address: OpenApiCredentialAddress, sealed: string, signal: AbortSignal) => Promise<void>;
|
|
31
|
+
remove: (address: OpenApiCredentialAddress, signal: AbortSignal) => Promise<"deleted" | "absent">;
|
|
32
|
+
}>;
|
|
33
|
+
/** Host-configured OAuth only: its provider fixes the upstream base and owns Nango proxying. */
|
|
34
|
+
export type OpenApiOAuthProvider = Readonly<{
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
send: (teamId: string, locator: string, request: Request) => Promise<Response>;
|
|
37
|
+
}>;
|
|
38
|
+
export type OpenApiCredentialKeyring = Readonly<{
|
|
39
|
+
activeKeyId: string;
|
|
40
|
+
keys: Readonly<Record<string, string>>;
|
|
41
|
+
}>;
|
|
42
|
+
export type OpenApiDoorOptions = Readonly<{
|
|
43
|
+
connect: PublicConnect;
|
|
44
|
+
resolve: HostResolver;
|
|
45
|
+
networkPolicy?: PublicEgressPolicy;
|
|
46
|
+
/** Dedicated 32-byte base64url keys; active key seals, retained keys open. */
|
|
47
|
+
custody?: Readonly<{
|
|
48
|
+
keys: OpenApiCredentialKeyring;
|
|
49
|
+
store: OpenApiCredentialStore;
|
|
50
|
+
}>;
|
|
51
|
+
oauthProviders?: Readonly<Record<string, OpenApiOAuthProvider>>;
|
|
52
|
+
}>;
|
|
53
|
+
export type OpenApiRefusal = "denied" | "invalid_input" | "invalid_response" | "too_large" | "timeout" | "cancelled" | "unavailable" | "credentials_missing" | "oauth_not_configured" | "upstream_status";
|
|
54
|
+
/** Fixed codes only: upstream bodies, URLs and credential bytes never enter errors. */
|
|
55
|
+
export declare class OpenApiError extends Error {
|
|
56
|
+
readonly code: OpenApiRefusal;
|
|
57
|
+
readonly status?: number | undefined;
|
|
58
|
+
constructor(code: OpenApiRefusal, status?: number | undefined);
|
|
59
|
+
}
|
|
60
|
+
export type OpenApiDoor = Readonly<{
|
|
61
|
+
seal: (access: OpenApiAccess, credential: string, signal: AbortSignal) => Promise<void>;
|
|
62
|
+
send: (access: OpenApiAccess, request: Request, declaredHeaders?: readonly string[]) => Promise<Response>;
|
|
63
|
+
remove: (address: OpenApiCredentialAddress, signal: AbortSignal) => Promise<"deleted" | "absent">;
|
|
64
|
+
}>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Fixed codes only: upstream bodies, URLs and credential bytes never enter errors. */
|
|
2
|
+
export class OpenApiError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
constructor(code, status) {
|
|
6
|
+
super(code);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = "OpenApiError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { OpenApiError, type OpenApiAccess, type OpenApiDoorOptions, type OpenApiCredentialAddress } from "./openapi-contract.js";
|
|
3
|
+
/** Canonical Effect policy; Promises appear only at socket, crypto and storage adapters. */
|
|
4
|
+
export type OpenApiEffects = Readonly<{
|
|
5
|
+
seal: (access: OpenApiAccess, credential: string) => Effect.Effect<void, OpenApiError>;
|
|
6
|
+
remove: (address: OpenApiCredentialAddress) => Effect.Effect<"deleted" | "absent", OpenApiError>;
|
|
7
|
+
send: (access: OpenApiAccess, request: Request, declaredHeaders?: readonly string[]) => Effect.Effect<Response, OpenApiError>;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function createOpenApiEffects(options: OpenApiDoorOptions): OpenApiEffects;
|