@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,81 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { ConnectionsError, updateInputSchema, } from "./contract.js";
|
|
3
|
+
import { decode, io, sync } from "./internal.js";
|
|
4
|
+
import { decodeTable } from "./openapi.js";
|
|
5
|
+
/** Owner edits guarded by the version the owner last saw; replays are idempotent. */
|
|
6
|
+
export function createUpdate(deps) {
|
|
7
|
+
const { store } = deps;
|
|
8
|
+
return (actor, raw) => Effect.gen(function* () {
|
|
9
|
+
const input = yield* sync(() => {
|
|
10
|
+
store.authorize(actor);
|
|
11
|
+
const parsed = decode(updateInputSchema, raw);
|
|
12
|
+
if (parsed.credential?.kind === "oauth" ||
|
|
13
|
+
(parsed.credential?.kind === "static") !==
|
|
14
|
+
(parsed.secret !== undefined))
|
|
15
|
+
throw new ConnectionsError(parsed.credential === undefined ? "invalid_input" : "unsupported");
|
|
16
|
+
return parsed;
|
|
17
|
+
});
|
|
18
|
+
const stamp = JSON.stringify([
|
|
19
|
+
"update",
|
|
20
|
+
input.connectionId,
|
|
21
|
+
input.expectedVersion,
|
|
22
|
+
input.name ?? null,
|
|
23
|
+
input.label ?? null,
|
|
24
|
+
input.baseUrl ?? null,
|
|
25
|
+
input.table === undefined ? null : JSON.stringify(input.table),
|
|
26
|
+
input.credential ?? null,
|
|
27
|
+
input.secret !== undefined,
|
|
28
|
+
]);
|
|
29
|
+
const replay = yield* sync(() => {
|
|
30
|
+
const receipt = store.receipt(input.requestId);
|
|
31
|
+
if (receipt !== null && receipt.stamp !== stamp)
|
|
32
|
+
throw new ConnectionsError("conflict");
|
|
33
|
+
return receipt;
|
|
34
|
+
});
|
|
35
|
+
if (replay !== null)
|
|
36
|
+
return { connectionId: replay.connectionId, version: replay.version };
|
|
37
|
+
const row = yield* sync(() => {
|
|
38
|
+
const found = store.connection(input.connectionId);
|
|
39
|
+
if (found.version !== input.expectedVersion)
|
|
40
|
+
throw new ConnectionsError("conflict");
|
|
41
|
+
if (found.state === "revoked" || found.state === "pending")
|
|
42
|
+
throw new ConnectionsError(found.state === "revoked" ? "revoked" : "pending");
|
|
43
|
+
if ((input.table !== undefined || input.baseUrl !== undefined) &&
|
|
44
|
+
found.surface.kind !== "openapi")
|
|
45
|
+
throw new ConnectionsError("invalid_input");
|
|
46
|
+
// A new destination must not inherit a credential sealed for the old one.
|
|
47
|
+
if (input.baseUrl !== undefined &&
|
|
48
|
+
input.credential === undefined &&
|
|
49
|
+
found.credential.kind !== "none" &&
|
|
50
|
+
new URL(input.baseUrl).href !== found.surface.url)
|
|
51
|
+
throw new ConnectionsError("invalid_input");
|
|
52
|
+
if (input.name !== undefined)
|
|
53
|
+
store.checkName(input.name, found.id);
|
|
54
|
+
return found;
|
|
55
|
+
});
|
|
56
|
+
const baseUrl = yield* sync(() => input.baseUrl === undefined
|
|
57
|
+
? row.surface.url
|
|
58
|
+
: store.publicUrl(input.baseUrl));
|
|
59
|
+
const next = input.table === undefined
|
|
60
|
+
? null
|
|
61
|
+
: yield* io(() => decodeTable(input.table));
|
|
62
|
+
const credential = input.credential ?? row.credential;
|
|
63
|
+
const locator = input.secret === undefined
|
|
64
|
+
? row.locator
|
|
65
|
+
: yield* deps.custody.put({ ownerId: deps.ownerId, connectionId: row.id, credential }, input.secret);
|
|
66
|
+
return yield* sync(() => store.commit({
|
|
67
|
+
actor,
|
|
68
|
+
update: input,
|
|
69
|
+
stamp,
|
|
70
|
+
row,
|
|
71
|
+
baseUrl,
|
|
72
|
+
table: next?.operations ?? row.surface.table,
|
|
73
|
+
digest: next?.digest ?? row.surface.digest ?? null,
|
|
74
|
+
credential,
|
|
75
|
+
locator,
|
|
76
|
+
})).pipe(Effect.tapError(() => sync(() => {
|
|
77
|
+
if (input.secret !== undefined && locator !== null)
|
|
78
|
+
store.discard(row.id, locator, credential);
|
|
79
|
+
})));
|
|
80
|
+
});
|
|
81
|
+
}
|
package/dist/gatekeeper.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { type OpenApiCredentialStore, type OpenApiOAuthProvider, type OpenApiDoorOptions } from "./openapi.js";
|
|
1
|
+
import { type OpenApiCredentialKeyring, type OpenApiCredentialStore, type OpenApiOAuthProvider, type OpenApiDoorOptions } from "./openapi.js";
|
|
2
2
|
import { type OpenApiEffects } from "./openapi-effect.js";
|
|
3
3
|
import type { PublicConnect } from "./public-fetch.js";
|
|
4
|
+
import { type CustodyProvider } from "./connections/contract.js";
|
|
5
|
+
import { type GitHubAppCustody } from "./github-app.js";
|
|
6
|
+
import { type ConnectionsCustodian } from "./connections/nango.js";
|
|
4
7
|
import { type WorldSeatCustodian } from "./world-seat.js";
|
|
5
8
|
import type { ModelKeyCustodian } from "./model-keys.js";
|
|
6
9
|
import { createModelKeyEgress, type ModelKeyGrants } from "./model-key-grants.js";
|
|
7
10
|
import { providerFetch, type ProviderEgress, type ProviderKeyBindings, type ProviderRoute } from "./provider-egress.js";
|
|
8
|
-
import * as Schema from "effect/Schema";
|
|
9
11
|
/**
|
|
10
12
|
* The Gatekeeper: the one place the Hub's Nango key, webhook signing key,
|
|
11
13
|
* provider/model-key integration ids, and platform provider keys (Camel, the
|
|
@@ -21,6 +23,7 @@ import * as Schema from "effect/Schema";
|
|
|
21
23
|
*/
|
|
22
24
|
export { providerFetch };
|
|
23
25
|
export type { ProviderEgress, ProviderRoute };
|
|
26
|
+
export type { ConnectionsCustodian };
|
|
24
27
|
/** Private Worker bindings, never Team storage or public response fields. */
|
|
25
28
|
export type VeilBindings = ProviderKeyBindings & Readonly<{
|
|
26
29
|
WEBHOOK_TRIGGER_MASTER_KEY?: string;
|
|
@@ -30,8 +33,18 @@ export type VeilBindings = ProviderKeyBindings & Readonly<{
|
|
|
30
33
|
NANGO_SERVER_URL?: string;
|
|
31
34
|
NANGO_API_KEY?: string;
|
|
32
35
|
NANGO_WEBHOOK_SIGNING_KEY?: string;
|
|
36
|
+
/** The GitHub REST sign-in integration; it joins the connections sign-in list. */
|
|
33
37
|
NANGO_GITHUB_INTEGRATION_ID?: string;
|
|
34
|
-
|
|
38
|
+
/** The Nango `github-app` integration that hands a Computer its installation token. */
|
|
39
|
+
NANGO_GITHUB_APP_INTEGRATION_ID?: string;
|
|
40
|
+
/** API-key integration that holds pasted credentials for Team connections. */
|
|
41
|
+
NANGO_CONNECTIONS_INTEGRATION_ID?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Nango integrations an owner may sign in through for connections, as a
|
|
44
|
+
* JSON object from integration id to `{ "apiOrigins": ["https://…"] }`:
|
|
45
|
+
* the only origins that integration's credential is sent to.
|
|
46
|
+
*/
|
|
47
|
+
NANGO_CONNECTIONS_OAUTH_INTEGRATIONS?: string;
|
|
35
48
|
/** Public Connect UI base that Nango's issued links must stay under. */
|
|
36
49
|
NANGO_CONNECT_BASE_URL?: string;
|
|
37
50
|
/** Public Nango API origin the Connect UI in the browser must call. */
|
|
@@ -51,15 +64,25 @@ export type VeilBindings = ProviderKeyBindings & Readonly<{
|
|
|
51
64
|
*/
|
|
52
65
|
HUB_LOCAL_MODE?: string;
|
|
53
66
|
}>;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Optional Nango custody for Team connections. A Hub with none of the Nango
|
|
69
|
+
* bindings keeps sealed custody and still connects OAuth MCP and OpenAPI.
|
|
70
|
+
*/
|
|
71
|
+
export declare function configuredConnectionsCustody(env: VeilBindings): ConnectionsCustodian | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* The Nango integrations an owner may sign in through for connections, each
|
|
74
|
+
* pinned to the origins its credential may reach. Absent or malformed means
|
|
75
|
+
* none: a sign-in through an undeclared integration is refused, never guessed.
|
|
76
|
+
*/
|
|
77
|
+
export declare function configuredConnectionsCustodyProviders(env: VeilBindings): Readonly<Record<string, CustodyProvider>>;
|
|
78
|
+
/**
|
|
79
|
+
* The Nango `github-app` integration through which a Computer's git gets its
|
|
80
|
+
* installation token. It is its own thing: never in the generic list, and
|
|
81
|
+
* never the same integration as GitHub REST.
|
|
82
|
+
*/
|
|
83
|
+
export declare function configuredGitHubAppIntegration(env: VeilBindings): string | undefined;
|
|
84
|
+
/** Nango's GitHub App custody for Computers; absent when any binding is. */
|
|
85
|
+
export declare function configuredGitHubAppCustody(env: VeilBindings): GitHubAppCustody | undefined;
|
|
63
86
|
/**
|
|
64
87
|
* `veil.fetch` for platform provider keys. The Hub names a route and sends a
|
|
65
88
|
* credential-free request; Veil checks the destination and attaches the key.
|
|
@@ -109,80 +132,13 @@ export declare function configuredModelKeyEgress(env: VeilBindings, options: Rea
|
|
|
109
132
|
provider: import("./model-keys.js").ModelKeyProvider;
|
|
110
133
|
}>, request: Request): Promise<Response>;
|
|
111
134
|
};
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
principalId: string;
|
|
117
|
-
integrationId: string;
|
|
118
|
-
}>, signal: AbortSignal) => Promise<Readonly<{
|
|
119
|
-
token: string;
|
|
120
|
-
connectUrl: string;
|
|
121
|
-
expiresAt: number;
|
|
122
|
-
}>>;
|
|
123
|
-
verifyCompletion: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedCompletion>;
|
|
124
|
-
verifyWebhook: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedWebhook>;
|
|
125
|
-
findCompletion: (attempt: import("./index.js").CustodyCorrelation, signal: AbortSignal) => Promise<Schema.Schema.Type<Schema.Union<readonly [Schema.Struct<{
|
|
126
|
-
readonly status: Schema.Literal<"unresolved">;
|
|
127
|
-
}>, Schema.Struct<{
|
|
128
|
-
readonly status: Schema.Literal<"ambiguous">;
|
|
129
|
-
}>, Schema.Struct<{
|
|
130
|
-
readonly status: Schema.Literal<"verified">;
|
|
131
|
-
readonly completion: Schema.Struct<{
|
|
132
|
-
readonly teamId: Schema.NonEmptyString;
|
|
133
|
-
readonly attemptId: Schema.NonEmptyString;
|
|
134
|
-
readonly principalId: Schema.NonEmptyString;
|
|
135
|
-
readonly integrationId: Schema.NonEmptyString;
|
|
136
|
-
readonly locator: Schema.NonEmptyString;
|
|
137
|
-
}>;
|
|
138
|
-
}>]>>>;
|
|
139
|
-
deleteConnection: (locator: string, signal: AbortSignal) => Promise<"deleted" | "absent">;
|
|
140
|
-
readRepository: (access: import("./index.js").RepositoryAccess, signal: AbortSignal) => Promise<string>;
|
|
141
|
-
}>;
|
|
142
|
-
/** Git Connect/cleanup and final egress use the same Git-profile integration.
|
|
143
|
-
* No metadata Connection locator is translated into this profile. Veil confines
|
|
144
|
-
* the issued Connect document to the configured public Connect URLs. */
|
|
145
|
-
export declare function configuredComputerGitCustodian(env: VeilBindings): {
|
|
146
|
-
integrationId: string;
|
|
147
|
-
openSession: (input: Readonly<{
|
|
148
|
-
teamId: string;
|
|
149
|
-
attemptId: string;
|
|
150
|
-
principalId: string;
|
|
151
|
-
integrationId: string;
|
|
152
|
-
}>, signal: AbortSignal) => Promise<Readonly<{
|
|
153
|
-
token: string;
|
|
154
|
-
connectUrl: string;
|
|
155
|
-
expiresAt: number;
|
|
156
|
-
}>>;
|
|
157
|
-
verifyCompletion: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedCompletion>;
|
|
158
|
-
verifyWebhook: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedWebhook>;
|
|
159
|
-
findCompletion: (attempt: import("./index.js").CustodyCorrelation, signal: AbortSignal) => Promise<Schema.Schema.Type<Schema.Union<readonly [Schema.Struct<{
|
|
160
|
-
readonly status: Schema.Literal<"unresolved">;
|
|
161
|
-
}>, Schema.Struct<{
|
|
162
|
-
readonly status: Schema.Literal<"ambiguous">;
|
|
163
|
-
}>, Schema.Struct<{
|
|
164
|
-
readonly status: Schema.Literal<"verified">;
|
|
165
|
-
readonly completion: Schema.Struct<{
|
|
166
|
-
readonly teamId: Schema.NonEmptyString;
|
|
167
|
-
readonly attemptId: Schema.NonEmptyString;
|
|
168
|
-
readonly principalId: Schema.NonEmptyString;
|
|
169
|
-
readonly integrationId: Schema.NonEmptyString;
|
|
170
|
-
readonly locator: Schema.NonEmptyString;
|
|
171
|
-
}>;
|
|
172
|
-
}>]>>>;
|
|
173
|
-
deleteConnection: (locator: string, signal: AbortSignal) => Promise<"deleted" | "absent">;
|
|
174
|
-
readGit: (access: import("./nango-git.js").GitRepositoryAccess, request: Request) => Promise<Response>;
|
|
175
|
-
};
|
|
176
|
-
/** The decoded key selects one of the two configured profiles only. The
|
|
177
|
-
* selected maintained custodian still verifies the original signed body. */
|
|
135
|
+
/** The decoded key must name an integration this host runs a sign-in through;
|
|
136
|
+
* the maintained custodian then verifies the original signed body. The GitHub
|
|
137
|
+
* App integration settles only a Computer's git, a generic one only a
|
|
138
|
+
* connection: neither can settle the other's attempt. */
|
|
178
139
|
export declare function verifyVeilWebhook(env: VeilBindings, rawBody: string, signature: string): Promise<{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
profile: "computer-git";
|
|
182
|
-
} | {
|
|
183
|
-
operation: "creation" | "refresh_failed";
|
|
184
|
-
completion: import("./index.js").VerifiedCompletion;
|
|
185
|
-
profile: "agent-metadata";
|
|
140
|
+
profile: "connections" | "computer-git";
|
|
141
|
+
ownerId: string;
|
|
186
142
|
}>;
|
|
187
143
|
export type OpenApiHostOptions = Readonly<{
|
|
188
144
|
connect: PublicConnect;
|
|
@@ -192,6 +148,8 @@ export type OpenApiHostOptions = Readonly<{
|
|
|
192
148
|
previousCredentialKeys?: Readonly<Record<string, string>>;
|
|
193
149
|
oauthProviders?: Readonly<Record<string, OpenApiOAuthProvider>>;
|
|
194
150
|
}>;
|
|
151
|
+
/** The keyring that seals OpenAPI credentials; only Gatekeeper reads the master key. */
|
|
152
|
+
export declare function openApiCredentialKeyring(env: VeilBindings, previous?: Readonly<Record<string, string>>): OpenApiCredentialKeyring | undefined;
|
|
195
153
|
/** Anonymous calls need no secret; API-key/bearer calls require configured Veil custody. */
|
|
196
154
|
export declare function configuredOpenApiDoor(env: VeilBindings, options: OpenApiHostOptions): Readonly<{
|
|
197
155
|
seal: (access: import("./openapi-contract.js").OpenApiAccess, credential: string, signal: AbortSignal) => Promise<void>;
|
package/dist/gatekeeper.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createOpenApiDoor, } from "./openapi.js";
|
|
2
2
|
import { createOpenApiEffects } from "./openapi-effect.js";
|
|
3
3
|
import { createDnsOverHttpsResolver } from "./model-key-network.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ConnectionsError, } from "./connections/contract.js";
|
|
5
|
+
import { createGitHubAppCustody } from "./github-app.js";
|
|
6
|
+
import { createNangoCustody, } from "./connections/nango.js";
|
|
5
7
|
import { createWebhookTriggerSecrets } from "./webhook-trigger-secrets.js";
|
|
6
|
-
import { createNangoGitCustodian } from "./nango-git.js";
|
|
7
8
|
import { createNangoModelKeyCustodian } from "./nango-model-keys.js";
|
|
8
9
|
import { createNangoWorldSeatCustodian } from "./nango-world-seat.js";
|
|
9
10
|
import { worldSeatEndpoint } from "./world-seat.js";
|
|
@@ -26,18 +27,98 @@ import * as Exit from "effect/Exit";
|
|
|
26
27
|
* Hub code reaches a provider only through `configuredProviderEgress`.
|
|
27
28
|
*/
|
|
28
29
|
export { providerFetch };
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Optional Nango custody for Team connections. A Hub with none of the Nango
|
|
32
|
+
* bindings keeps sealed custody and still connects OAuth MCP and OpenAPI.
|
|
33
|
+
*/
|
|
34
|
+
export function configuredConnectionsCustody(env) {
|
|
35
|
+
if (!env.NANGO_SERVER_URL ||
|
|
36
|
+
!env.NANGO_API_KEY ||
|
|
37
|
+
!env.NANGO_WEBHOOK_SIGNING_KEY ||
|
|
38
|
+
!env.NANGO_CONNECT_BASE_URL ||
|
|
39
|
+
!env.NANGO_PUBLIC_API_URL ||
|
|
40
|
+
!env.NANGO_CONNECTIONS_INTEGRATION_ID)
|
|
41
|
+
return undefined;
|
|
42
|
+
try {
|
|
43
|
+
return createNangoCustody({
|
|
44
|
+
serverUrl: env.NANGO_SERVER_URL,
|
|
45
|
+
apiKey: env.NANGO_API_KEY,
|
|
46
|
+
webhookSigningKey: env.NANGO_WEBHOOK_SIGNING_KEY,
|
|
47
|
+
staticIntegrationId: env.NANGO_CONNECTIONS_INTEGRATION_ID,
|
|
48
|
+
connectBaseUrl: env.NANGO_CONNECT_BASE_URL,
|
|
49
|
+
publicApiUrl: env.NANGO_PUBLIC_API_URL,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new VeilError("unavailable");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** GitHub's REST API: the only place the GitHub REST integration's token may go. */
|
|
57
|
+
const GITHUB_REST_ORIGIN = "https://api.github.com";
|
|
58
|
+
const custodyProvidersJson = Schema.fromJsonString(Schema.Record(Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_.:-]{1,128}$/u))), Schema.Struct({
|
|
59
|
+
apiOrigins: Schema.Array(Schema.String).pipe(Schema.check(Schema.isMinLength(1), Schema.isMaxLength(16))),
|
|
60
|
+
})));
|
|
61
|
+
const isHttpsOrigin = (value) => URL.canParse(value) &&
|
|
62
|
+
new URL(value).protocol === "https:" &&
|
|
63
|
+
new URL(value).origin === value;
|
|
64
|
+
/**
|
|
65
|
+
* The Nango integrations an owner may sign in through for connections, each
|
|
66
|
+
* pinned to the origins its credential may reach. Absent or malformed means
|
|
67
|
+
* none: a sign-in through an undeclared integration is refused, never guessed.
|
|
68
|
+
*/
|
|
69
|
+
export function configuredConnectionsCustodyProviders(env) {
|
|
70
|
+
const raw = env.NANGO_CONNECTIONS_OAUTH_INTEGRATIONS;
|
|
71
|
+
const decoded = raw === undefined || raw.trim() === ""
|
|
72
|
+
? undefined
|
|
73
|
+
: Schema.decodeUnknownExit(custodyProvidersJson)(raw);
|
|
74
|
+
const operator = decoded === undefined || Exit.isFailure(decoded) ? {} : decoded.value;
|
|
75
|
+
const declared = {};
|
|
76
|
+
for (const [id, provider] of Object.entries(operator))
|
|
77
|
+
if (provider.apiOrigins.every(isHttpsOrigin))
|
|
78
|
+
declared[id] = provider;
|
|
79
|
+
// GitHub REST is pinned to its API host whatever the operator declared.
|
|
80
|
+
const rest = env.NANGO_GITHUB_INTEGRATION_ID;
|
|
81
|
+
if (rest !== undefined && rest !== "")
|
|
82
|
+
declared[rest] = { apiOrigins: [GITHUB_REST_ORIGIN] };
|
|
83
|
+
const app = configuredGitHubAppIntegration(env);
|
|
84
|
+
return Object.fromEntries(Object.entries(declared).filter(([id]) => id !== app));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The Nango `github-app` integration through which a Computer's git gets its
|
|
88
|
+
* installation token. It is its own thing: never in the generic list, and
|
|
89
|
+
* never the same integration as GitHub REST.
|
|
90
|
+
*/
|
|
91
|
+
export function configuredGitHubAppIntegration(env) {
|
|
92
|
+
const id = env.NANGO_GITHUB_APP_INTEGRATION_ID;
|
|
93
|
+
if (id === undefined || id === "" || id === env.NANGO_GITHUB_INTEGRATION_ID)
|
|
94
|
+
return undefined;
|
|
95
|
+
return id;
|
|
96
|
+
}
|
|
97
|
+
/** Nango's GitHub App custody for Computers; absent when any binding is. */
|
|
98
|
+
export function configuredGitHubAppCustody(env) {
|
|
99
|
+
const appIntegrationId = configuredGitHubAppIntegration(env);
|
|
100
|
+
if (appIntegrationId === undefined ||
|
|
101
|
+
!env.NANGO_SERVER_URL ||
|
|
102
|
+
!env.NANGO_API_KEY ||
|
|
103
|
+
!env.NANGO_WEBHOOK_SIGNING_KEY ||
|
|
104
|
+
!env.NANGO_CONNECT_BASE_URL ||
|
|
105
|
+
!env.NANGO_PUBLIC_API_URL ||
|
|
106
|
+
!env.NANGO_CONNECTIONS_INTEGRATION_ID)
|
|
107
|
+
return undefined;
|
|
108
|
+
try {
|
|
109
|
+
return createGitHubAppCustody({
|
|
110
|
+
serverUrl: env.NANGO_SERVER_URL,
|
|
111
|
+
apiKey: env.NANGO_API_KEY,
|
|
112
|
+
webhookSigningKey: env.NANGO_WEBHOOK_SIGNING_KEY,
|
|
113
|
+
staticIntegrationId: env.NANGO_CONNECTIONS_INTEGRATION_ID,
|
|
114
|
+
connectBaseUrl: env.NANGO_CONNECT_BASE_URL,
|
|
115
|
+
publicApiUrl: env.NANGO_PUBLIC_API_URL,
|
|
116
|
+
appIntegrationId,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new VeilError("unavailable");
|
|
121
|
+
}
|
|
41
122
|
}
|
|
42
123
|
/**
|
|
43
124
|
* `veil.fetch` for platform provider keys. The Hub names a route and sends a
|
|
@@ -116,89 +197,59 @@ export function configuredModelKeyEgress(env, options) {
|
|
|
116
197
|
ownerIsMember: options.ownerIsMember,
|
|
117
198
|
});
|
|
118
199
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return configuredCustodian(env, state.metadataIntegrationId);
|
|
124
|
-
}
|
|
125
|
-
/** Git Connect/cleanup and final egress use the same Git-profile integration.
|
|
126
|
-
* No metadata Connection locator is translated into this profile. Veil confines
|
|
127
|
-
* the issued Connect document to the configured public Connect URLs. */
|
|
128
|
-
export function configuredComputerGitCustodian(env) {
|
|
129
|
-
const state = configuredGitHubState(env);
|
|
130
|
-
if (state.github === "off")
|
|
131
|
-
throw new VeilError("github_not_configured");
|
|
132
|
-
const custody = configuredCustodian(env, state.gitIntegrationId);
|
|
133
|
-
if (!env.NANGO_SERVER_URL || !env.NANGO_API_KEY)
|
|
134
|
-
throw new VeilError("unavailable");
|
|
135
|
-
try {
|
|
136
|
-
const git = createNangoGitCustodian({
|
|
137
|
-
serverUrl: env.NANGO_SERVER_URL,
|
|
138
|
-
apiKey: env.NANGO_API_KEY,
|
|
139
|
-
integrationId: state.gitIntegrationId,
|
|
140
|
-
});
|
|
141
|
-
return {
|
|
142
|
-
integrationId: state.gitIntegrationId,
|
|
143
|
-
openSession: custody.openSession,
|
|
144
|
-
verifyCompletion: custody.verifyCompletion,
|
|
145
|
-
verifyWebhook: custody.verifyWebhook,
|
|
146
|
-
findCompletion: custody.findCompletion,
|
|
147
|
-
deleteConnection: custody.deleteConnection,
|
|
148
|
-
readGit: git.readGit.bind(git),
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
throw new VeilError("unavailable");
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/** The decoded key selects one of the two configured profiles only. The
|
|
156
|
-
* selected maintained custodian still verifies the original signed body. */
|
|
200
|
+
/** The decoded key must name an integration this host runs a sign-in through;
|
|
201
|
+
* the maintained custodian then verifies the original signed body. The GitHub
|
|
202
|
+
* App integration settles only a Computer's git, a generic one only a
|
|
203
|
+
* connection: neither can settle the other's attempt. */
|
|
157
204
|
export async function verifyVeilWebhook(env, rawBody, signature) {
|
|
158
|
-
const
|
|
159
|
-
|
|
205
|
+
const generic = Object.keys(configuredConnectionsCustodyProviders(env));
|
|
206
|
+
const app = configuredGitHubAppIntegration(env);
|
|
207
|
+
if (generic.length === 0 && app === undefined)
|
|
160
208
|
throw new VeilError("github_not_configured");
|
|
161
209
|
const decoded = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Struct({
|
|
162
210
|
providerConfigKey: Schema.NonEmptyString,
|
|
163
211
|
})))(rawBody);
|
|
164
212
|
if (Exit.isFailure(decoded))
|
|
165
213
|
throw new VeilError("invalid_completion");
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
throw new VeilError("invalid_completion");
|
|
214
|
+
const key = decoded.value.providerConfigKey;
|
|
215
|
+
const profile = generic.includes(key)
|
|
216
|
+
? "connections"
|
|
217
|
+
: key === app
|
|
218
|
+
? "computer-git"
|
|
219
|
+
: undefined;
|
|
220
|
+
if (profile === undefined)
|
|
221
|
+
throw new VeilError("invalid_completion");
|
|
222
|
+
return {
|
|
223
|
+
profile,
|
|
224
|
+
ownerId: await verifiedConnectionOwner(env, rawBody, signature),
|
|
225
|
+
};
|
|
179
226
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
!env.NANGO_CONNECT_BASE_URL ||
|
|
185
|
-
!env.NANGO_PUBLIC_API_URL ||
|
|
186
|
-
!integrationId)
|
|
227
|
+
/** The signed sign-in names the Team that began it; only a verified body is believed. */
|
|
228
|
+
async function verifiedConnectionOwner(env, rawBody, signature) {
|
|
229
|
+
const custody = configuredConnectionsCustody(env);
|
|
230
|
+
if (custody === undefined)
|
|
187
231
|
throw new VeilError("unavailable");
|
|
188
232
|
try {
|
|
189
|
-
return
|
|
190
|
-
serverUrl: env.NANGO_SERVER_URL,
|
|
191
|
-
apiKey: env.NANGO_API_KEY,
|
|
192
|
-
webhookSigningKey: env.NANGO_WEBHOOK_SIGNING_KEY,
|
|
193
|
-
integrationId,
|
|
194
|
-
connectBaseUrl: env.NANGO_CONNECT_BASE_URL,
|
|
195
|
-
publicApiUrl: env.NANGO_PUBLIC_API_URL,
|
|
196
|
-
});
|
|
233
|
+
return (await custody.verifyCompletion(rawBody, signature)).ownerId;
|
|
197
234
|
}
|
|
198
|
-
catch {
|
|
199
|
-
|
|
235
|
+
catch (cause) {
|
|
236
|
+
if (cause instanceof ConnectionsError)
|
|
237
|
+
throw new VeilError(cause.code === "unsupported"
|
|
238
|
+
? "unsupported"
|
|
239
|
+
: cause.code === "unavailable"
|
|
240
|
+
? "unavailable"
|
|
241
|
+
: "invalid_completion", { cause });
|
|
242
|
+
throw cause;
|
|
200
243
|
}
|
|
201
244
|
}
|
|
245
|
+
/** The keyring that seals OpenAPI credentials; only Gatekeeper reads the master key. */
|
|
246
|
+
export function openApiCredentialKeyring(env, previous) {
|
|
247
|
+
const key = env.OPENAPI_CREDENTIAL_MASTER_KEY;
|
|
248
|
+
if (!key)
|
|
249
|
+
return undefined;
|
|
250
|
+
const id = env.OPENAPI_CREDENTIAL_KEY_ID ?? "primary";
|
|
251
|
+
return { activeKeyId: id, keys: { ...previous, [id]: key } };
|
|
252
|
+
}
|
|
202
253
|
/** Gatekeeper owns the common opaque configuration for both runtime facades. */
|
|
203
254
|
function openApiHostConfiguration(env, options) {
|
|
204
255
|
const doorOptions = {
|
|
@@ -208,17 +259,9 @@ function openApiHostConfiguration(env, options) {
|
|
|
208
259
|
};
|
|
209
260
|
if (options.oauthProviders)
|
|
210
261
|
doorOptions.oauthProviders = options.oauthProviders;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
activeKeyId: env.OPENAPI_CREDENTIAL_KEY_ID ?? "primary",
|
|
215
|
-
keys: {
|
|
216
|
-
...options.previousCredentialKeys,
|
|
217
|
-
[env.OPENAPI_CREDENTIAL_KEY_ID ?? "primary"]: env.OPENAPI_CREDENTIAL_MASTER_KEY,
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
|
-
store: options.store,
|
|
221
|
-
};
|
|
262
|
+
const keys = openApiCredentialKeyring(env, options.previousCredentialKeys);
|
|
263
|
+
if (keys)
|
|
264
|
+
doorOptions.custody = { keys, store: options.store };
|
|
222
265
|
return doorOptions;
|
|
223
266
|
}
|
|
224
267
|
/** Anonymous calls need no secret; API-key/bearer calls require configured Veil custody. */
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type CustodyLookup } from "./connections/contract.js";
|
|
2
|
+
import { type NangoCustodyOptions } from "./connections/nango.js";
|
|
3
|
+
/** A completion Nango signed for the GitHub App sign-in, once verified. */
|
|
4
|
+
export type GitHubAppCompletion = Readonly<{
|
|
5
|
+
teamId: string;
|
|
6
|
+
state: string;
|
|
7
|
+
principalId: string;
|
|
8
|
+
locator: string;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Nango's `github-app` integration as the one source of a Computer's GitHub
|
|
12
|
+
* token. Nango holds the App's credentials and returns an installation token
|
|
13
|
+
* limited to the repositories the person picked at install. The token is read
|
|
14
|
+
* here, in memory, to be placed on a request to `github.com`; it is never
|
|
15
|
+
* stored, listed or sent anywhere else.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createGitHubAppCustody(options: NangoCustodyOptions & Readonly<{
|
|
18
|
+
appIntegrationId: string;
|
|
19
|
+
}>): {
|
|
20
|
+
integrationId: string;
|
|
21
|
+
/** The page that sends the member to GitHub's installation consent. */
|
|
22
|
+
connect: (ownerId: string, session: {
|
|
23
|
+
state: string;
|
|
24
|
+
actor: string;
|
|
25
|
+
}) => Promise<Readonly<{
|
|
26
|
+
connectUrl: string;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
}>>;
|
|
29
|
+
/** The signed completion, and only one for this App integration. */
|
|
30
|
+
verifyCompletion: (rawBody: string, signature: string) => Promise<GitHubAppCompletion>;
|
|
31
|
+
/** Ask Nango whether a sign-in finished whose signed completion was lost. */
|
|
32
|
+
find: (ownerId: string, session: {
|
|
33
|
+
state: string;
|
|
34
|
+
actor: string;
|
|
35
|
+
}) => Promise<CustodyLookup>;
|
|
36
|
+
remove: (ownerId: string, locator: string) => Promise<"absent" | "deleted">;
|
|
37
|
+
/**
|
|
38
|
+
* A current installation token. `fresh` asks Nango to refresh it first.
|
|
39
|
+
* A connection Nango no longer holds, or whose App access GitHub ended,
|
|
40
|
+
* is `reconnect_required`; an outage is `unavailable`.
|
|
41
|
+
*/
|
|
42
|
+
token: (locator: string, fresh: boolean, signal: AbortSignal) => Promise<string>;
|
|
43
|
+
};
|
|
44
|
+
export type GitHubAppCustody = ReturnType<typeof createGitHubAppCustody>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { ConnectionsError, } from "./connections/contract.js";
|
|
4
|
+
import { createNangoCustody, } from "./connections/nango.js";
|
|
5
|
+
import { createNangoCustodyHttp } from "./nango-custody-http.js";
|
|
6
|
+
const tokenResponse = Schema.Struct({
|
|
7
|
+
credentials: Schema.Struct({
|
|
8
|
+
access_token: Schema.NonEmptyString,
|
|
9
|
+
expires_at: Schema.optional(Schema.String),
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* Nango's `github-app` integration as the one source of a Computer's GitHub
|
|
14
|
+
* token. Nango holds the App's credentials and returns an installation token
|
|
15
|
+
* limited to the repositories the person picked at install. The token is read
|
|
16
|
+
* here, in memory, to be placed on a request to `github.com`; it is never
|
|
17
|
+
* stored, listed or sent anywhere else.
|
|
18
|
+
*/
|
|
19
|
+
export function createGitHubAppCustody(options) {
|
|
20
|
+
const nango = createNangoCustody(options);
|
|
21
|
+
const http = createNangoCustodyHttp(options);
|
|
22
|
+
const address = (ownerId) => ({
|
|
23
|
+
ownerId,
|
|
24
|
+
connectionId: "github-app",
|
|
25
|
+
credential: { kind: "oauth", provider: options.appIntegrationId },
|
|
26
|
+
});
|
|
27
|
+
const run = (effect) => Effect.runPromise(effect);
|
|
28
|
+
const connect = nango.connect;
|
|
29
|
+
if (connect === undefined)
|
|
30
|
+
throw new ConnectionsError("unsupported");
|
|
31
|
+
const find = nango.find;
|
|
32
|
+
if (find === undefined)
|
|
33
|
+
throw new ConnectionsError("unsupported");
|
|
34
|
+
return {
|
|
35
|
+
integrationId: options.appIntegrationId,
|
|
36
|
+
/** The page that sends the member to GitHub's installation consent. */
|
|
37
|
+
connect: (ownerId, session) => run(connect(address(ownerId), {
|
|
38
|
+
...session,
|
|
39
|
+
integrationId: options.appIntegrationId,
|
|
40
|
+
})),
|
|
41
|
+
/** The signed completion, and only one for this App integration. */
|
|
42
|
+
verifyCompletion: async (rawBody, signature) => {
|
|
43
|
+
const verified = await nango.verifyCompletion(rawBody, signature);
|
|
44
|
+
if (verified.integrationId !== options.appIntegrationId)
|
|
45
|
+
throw new ConnectionsError("denied");
|
|
46
|
+
return {
|
|
47
|
+
teamId: verified.ownerId,
|
|
48
|
+
state: verified.state,
|
|
49
|
+
principalId: verified.principalId,
|
|
50
|
+
locator: verified.locator,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
/** Ask Nango whether a sign-in finished whose signed completion was lost. */
|
|
54
|
+
find: (ownerId, session) => run(find(address(ownerId), {
|
|
55
|
+
...session,
|
|
56
|
+
integrationId: options.appIntegrationId,
|
|
57
|
+
})),
|
|
58
|
+
remove: (ownerId, locator) => run(nango.remove(address(ownerId), locator)),
|
|
59
|
+
/**
|
|
60
|
+
* A current installation token. `fresh` asks Nango to refresh it first.
|
|
61
|
+
* A connection Nango no longer holds, or whose App access GitHub ended,
|
|
62
|
+
* is `reconnect_required`; an outage is `unavailable`.
|
|
63
|
+
*/
|
|
64
|
+
token: async (locator, fresh, signal) => {
|
|
65
|
+
const query = new URLSearchParams({
|
|
66
|
+
provider_config_key: options.appIntegrationId,
|
|
67
|
+
});
|
|
68
|
+
if (fresh)
|
|
69
|
+
query.set("force_refresh", "true");
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await http.call(`/connection/${encodeURIComponent(locator)}?${query.toString()}`, signal, {});
|
|
73
|
+
}
|
|
74
|
+
catch (cause) {
|
|
75
|
+
throw new ConnectionsError("unavailable", { cause });
|
|
76
|
+
}
|
|
77
|
+
if ([400, 401, 403, 404].includes(response.status)) {
|
|
78
|
+
await response.body?.cancel();
|
|
79
|
+
throw new ConnectionsError("reconnect_required");
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
await response.body?.cancel();
|
|
83
|
+
throw new ConnectionsError("unavailable");
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return Schema.decodeUnknownSync(Schema.fromJsonString(tokenResponse))(await response.text()).credentials.access_token;
|
|
87
|
+
}
|
|
88
|
+
catch (cause) {
|
|
89
|
+
throw new ConnectionsError("invalid_response", { cause });
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|