@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.
Files changed (43) hide show
  1. package/README.md +48 -54
  2. package/dist/best-effort-cleanup.d.ts +1 -1
  3. package/dist/connections/cleanup.d.ts +21 -0
  4. package/dist/connections/cleanup.js +66 -0
  5. package/dist/connections/contract.d.ts +192 -7
  6. package/dist/connections/contract.js +44 -2
  7. package/dist/connections/core.d.ts +58 -5
  8. package/dist/connections/core.js +438 -111
  9. package/dist/connections/discover.d.ts +3 -0
  10. package/dist/connections/discover.js +23 -0
  11. package/dist/connections/egress.js +3 -2
  12. package/dist/connections/index.d.ts +1 -0
  13. package/dist/connections/index.js +1 -0
  14. package/dist/connections/internal.d.ts +78 -0
  15. package/dist/connections/internal.js +66 -0
  16. package/dist/connections/mcp-agent.d.ts +37 -0
  17. package/dist/connections/mcp-agent.js +75 -0
  18. package/dist/connections/nango.d.ts +30 -0
  19. package/dist/connections/nango.js +179 -0
  20. package/dist/connections/oauth-flow.d.ts +83 -0
  21. package/dist/connections/oauth-flow.js +330 -0
  22. package/dist/connections/oauth-provider.d.ts +31 -0
  23. package/dist/connections/oauth-provider.js +96 -0
  24. package/dist/connections/oauth.d.ts +93 -0
  25. package/dist/connections/oauth.js +239 -0
  26. package/dist/connections/openapi.d.ts +36 -0
  27. package/dist/connections/openapi.js +120 -0
  28. package/dist/connections/sealed.js +23 -4
  29. package/dist/connections/source.d.ts +17 -0
  30. package/dist/connections/source.js +111 -0
  31. package/dist/connections/update.d.ts +41 -0
  32. package/dist/connections/update.js +81 -0
  33. package/dist/gatekeeper.d.ts +43 -85
  34. package/dist/gatekeeper.js +138 -95
  35. package/dist/github-app.d.ts +44 -0
  36. package/dist/github-app.js +93 -0
  37. package/dist/index.d.ts +0 -241
  38. package/dist/index.js +0 -256
  39. package/dist/nango.d.ts +40 -15
  40. package/dist/nango.js +7 -242
  41. package/package.json +15 -9
  42. package/dist/nango-git.d.ts +0 -19
  43. 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
+ }
@@ -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
- NANGO_GITHUB_GIT_INTEGRATION_ID?: string;
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
- export declare function configuredGitHubState(env: VeilBindings): {
55
- github: "off";
56
- metadataIntegrationId?: never;
57
- gitIntegrationId?: never;
58
- } | {
59
- github: "on";
60
- metadataIntegrationId: string;
61
- gitIntegrationId: string;
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
- export declare function configuredVeilCustodian(env: VeilBindings): Readonly<{
113
- openSession: (input: Readonly<{
114
- teamId: string;
115
- attemptId: string;
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
- operation: "creation" | "refresh_failed";
180
- completion: import("./index.js").VerifiedCompletion;
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>;
@@ -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 { createNangoCustodian } from "./nango.js";
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
- export function configuredGitHubState(env) {
30
- const metadataIntegrationId = env.NANGO_GITHUB_INTEGRATION_ID;
31
- const gitIntegrationId = env.NANGO_GITHUB_GIT_INTEGRATION_ID;
32
- if (!metadataIntegrationId ||
33
- !gitIntegrationId ||
34
- metadataIntegrationId === gitIntegrationId)
35
- return { github: "off" };
36
- return {
37
- github: "on",
38
- metadataIntegrationId,
39
- gitIntegrationId,
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
- export function configuredVeilCustodian(env) {
120
- const state = configuredGitHubState(env);
121
- if (state.github === "off")
122
- throw new VeilError("github_not_configured");
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 state = configuredGitHubState(env);
159
- if (state.github === "off")
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
- if (decoded.value.providerConfigKey === state.gitIntegrationId) {
167
- return {
168
- profile: "computer-git",
169
- ...(await configuredComputerGitCustodian(env).verifyWebhook(rawBody, signature)),
170
- };
171
- }
172
- if (decoded.value.providerConfigKey === state.metadataIntegrationId) {
173
- return {
174
- profile: "agent-metadata",
175
- ...(await configuredVeilCustodian(env).verifyWebhook(rawBody, signature)),
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
- function configuredCustodian(env, integrationId) {
181
- if (!env.NANGO_SERVER_URL ||
182
- !env.NANGO_API_KEY ||
183
- !env.NANGO_WEBHOOK_SIGNING_KEY ||
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 createNangoCustodian({
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
- throw new VeilError("unavailable");
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
- if (env.OPENAPI_CREDENTIAL_MASTER_KEY)
212
- doorOptions.custody = {
213
- keys: {
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
+ }