@fungi.computer/veil 0.3.0 → 0.5.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/dist/connections/cleanup.d.ts +21 -0
- package/dist/connections/cleanup.js +66 -0
- package/dist/connections/contract.d.ts +287 -0
- package/dist/connections/contract.js +95 -0
- package/dist/connections/core.d.ts +98 -0
- package/dist/connections/core.js +631 -0
- package/dist/connections/discover.d.ts +3 -0
- package/dist/connections/discover.js +23 -0
- package/dist/connections/egress.d.ts +8 -0
- package/dist/connections/egress.js +78 -0
- package/dist/connections/index.d.ts +4 -0
- package/dist/connections/index.js +4 -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 +136 -0
- package/dist/connections/oauth-flow.d.ts +81 -0
- package/dist/connections/oauth-flow.js +322 -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.d.ts +21 -0
- package/dist/connections/sealed.js +73 -0
- 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/credential-store.d.ts +64 -0
- package/dist/credential-store.js +151 -0
- package/dist/gatekeeper.d.ts +21 -1
- package/dist/gatekeeper.js +74 -12
- package/dist/index.d.ts +1 -1
- package/dist/model-keys.d.ts +24 -0
- package/dist/nango.d.ts +41 -0
- package/dist/nango.js +6 -6
- package/dist/openapi-secrets.d.ts +1 -1
- package/dist/openapi-secrets.js +19 -58
- package/package.json +22 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { ConnectionsError } from "./contract.js";
|
|
3
|
+
/** Early-refresh margin so a token never expires mid-request. */
|
|
4
|
+
const skewMs = 30_000;
|
|
5
|
+
const tokensSchema = Schema.Struct({
|
|
6
|
+
access_token: Schema.String,
|
|
7
|
+
refresh_token: Schema.optional(Schema.String),
|
|
8
|
+
/** Seconds the access token lives, as the authorization server said. */
|
|
9
|
+
expires_in: Schema.optional(Schema.Number),
|
|
10
|
+
obtainedAt: Schema.Number,
|
|
11
|
+
});
|
|
12
|
+
const clientSchema = Schema.Struct({
|
|
13
|
+
client_id: Schema.String,
|
|
14
|
+
client_secret: Schema.optional(Schema.String),
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Everything custody seals for one OAuth connection or attempt: the client,
|
|
18
|
+
* the authorization server it was issued by, the PKCE verifier while an
|
|
19
|
+
* attempt is pending, and the token set once it completes.
|
|
20
|
+
*/
|
|
21
|
+
const oauthBundleSchema = Schema.Struct({
|
|
22
|
+
authorizationServerUrl: Schema.String,
|
|
23
|
+
/** The operator-registered provider this was issued by, when there is one. */
|
|
24
|
+
provider: Schema.optional(Schema.String),
|
|
25
|
+
resource: Schema.optional(Schema.String),
|
|
26
|
+
client: clientSchema,
|
|
27
|
+
codeVerifier: Schema.optional(Schema.String),
|
|
28
|
+
tokens: Schema.optional(tokensSchema),
|
|
29
|
+
});
|
|
30
|
+
export const oauthClientJson = Schema.fromJsonString(clientSchema);
|
|
31
|
+
export const oauthBundleJson = Schema.fromJsonString(oauthBundleSchema);
|
|
32
|
+
export const tokenExpired = (bundle, now) => {
|
|
33
|
+
const tokens = bundle.tokens;
|
|
34
|
+
return (tokens?.expires_in !== undefined &&
|
|
35
|
+
tokens.obtainedAt + tokens.expires_in * 1000 - skewMs <= now);
|
|
36
|
+
};
|
|
37
|
+
/** The SDK's exact-optional client shape. */
|
|
38
|
+
const clientInfo = (client) => client.client_secret === undefined
|
|
39
|
+
? { client_id: client.client_id }
|
|
40
|
+
: { client_id: client.client_id, client_secret: client.client_secret };
|
|
41
|
+
const emptyCapture = () => ({
|
|
42
|
+
authorizeUrl: undefined,
|
|
43
|
+
codeVerifier: undefined,
|
|
44
|
+
client: undefined,
|
|
45
|
+
authorizationServerUrl: undefined,
|
|
46
|
+
resource: undefined,
|
|
47
|
+
tokens: undefined,
|
|
48
|
+
});
|
|
49
|
+
function provider(flow, captured) {
|
|
50
|
+
const provided = flow.own ?? captured.client;
|
|
51
|
+
const metadata = () => {
|
|
52
|
+
const base = {
|
|
53
|
+
redirect_uris: [flow.callbackUrl],
|
|
54
|
+
client_name: "Fungi Oyster",
|
|
55
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
56
|
+
response_types: ["code"],
|
|
57
|
+
token_endpoint_auth_method: provided?.client_secret === undefined ? "none" : "client_secret_post",
|
|
58
|
+
};
|
|
59
|
+
if (flow.scope !== undefined)
|
|
60
|
+
base.scope = flow.scope;
|
|
61
|
+
return base;
|
|
62
|
+
};
|
|
63
|
+
const base = {
|
|
64
|
+
redirectUrl: flow.callbackUrl,
|
|
65
|
+
get clientMetadata() {
|
|
66
|
+
return metadata();
|
|
67
|
+
},
|
|
68
|
+
clientInformation: async (ctx) => {
|
|
69
|
+
const known = captured.client ??
|
|
70
|
+
(ctx === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: await flow.registry?.lookup(ctx.issuer));
|
|
73
|
+
return known === undefined ? undefined : clientInfo(known);
|
|
74
|
+
},
|
|
75
|
+
saveClientInformation: async (info, ctx) => {
|
|
76
|
+
const client = {
|
|
77
|
+
client_id: info.client_id,
|
|
78
|
+
client_secret: info.client_secret,
|
|
79
|
+
};
|
|
80
|
+
captured.client = client;
|
|
81
|
+
if (ctx !== undefined && flow.own === undefined)
|
|
82
|
+
await flow.registry?.save(ctx.issuer, client);
|
|
83
|
+
},
|
|
84
|
+
tokens: () => undefined,
|
|
85
|
+
saveTokens: (tokens) => {
|
|
86
|
+
captured.tokens = {
|
|
87
|
+
access_token: tokens.access_token,
|
|
88
|
+
refresh_token: tokens.refresh_token,
|
|
89
|
+
expires_in: tokens.expires_in,
|
|
90
|
+
obtainedAt: flow.now,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
redirectToAuthorization: (url) => {
|
|
94
|
+
captured.authorizeUrl = url;
|
|
95
|
+
},
|
|
96
|
+
saveCodeVerifier: (verifier) => {
|
|
97
|
+
captured.codeVerifier = verifier;
|
|
98
|
+
},
|
|
99
|
+
codeVerifier: () => {
|
|
100
|
+
const verifier = captured.codeVerifier ?? flow.resumed?.codeVerifier;
|
|
101
|
+
if (verifier === undefined)
|
|
102
|
+
throw new ConnectionsError("denied");
|
|
103
|
+
return verifier;
|
|
104
|
+
},
|
|
105
|
+
saveDiscoveryState: (found) => {
|
|
106
|
+
captured.authorizationServerUrl = found.authorizationServerUrl;
|
|
107
|
+
captured.resource = found.resourceMetadata?.resource;
|
|
108
|
+
},
|
|
109
|
+
saveResourceUrl: (url) => {
|
|
110
|
+
captured.resource = url;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
if (flow.state !== undefined) {
|
|
114
|
+
const state = flow.state;
|
|
115
|
+
base.state = () => state;
|
|
116
|
+
}
|
|
117
|
+
if (flow.resumed !== undefined) {
|
|
118
|
+
const authorizationServerUrl = flow.resumed.authorizationServerUrl;
|
|
119
|
+
base.discoveryState = () => ({ authorizationServerUrl });
|
|
120
|
+
}
|
|
121
|
+
return base;
|
|
122
|
+
}
|
|
123
|
+
function authOptions(serverUrl, fetchFn, extra) {
|
|
124
|
+
const options = { serverUrl, fetchFn };
|
|
125
|
+
if (extra.scope !== undefined)
|
|
126
|
+
options.scope = extra.scope;
|
|
127
|
+
if (extra.code !== undefined)
|
|
128
|
+
options.authorizationCode = extra.code;
|
|
129
|
+
if (extra.iss !== undefined)
|
|
130
|
+
options.iss = extra.iss;
|
|
131
|
+
return options;
|
|
132
|
+
}
|
|
133
|
+
/** Discovery, registration and PKCE through the SDK; returns the redirect and the sealed attempt. */
|
|
134
|
+
export async function startAuthorization(input) {
|
|
135
|
+
const captured = emptyCapture();
|
|
136
|
+
captured.client = input.client;
|
|
137
|
+
const flow = provider({
|
|
138
|
+
callbackUrl: input.callbackUrl,
|
|
139
|
+
scope: input.scope,
|
|
140
|
+
now: 0,
|
|
141
|
+
resumed: undefined,
|
|
142
|
+
state: input.state,
|
|
143
|
+
registry: input.registry,
|
|
144
|
+
own: input.client,
|
|
145
|
+
}, captured);
|
|
146
|
+
const sdk = await import("@modelcontextprotocol/client");
|
|
147
|
+
const result = await sdk.auth(flow, authOptions(input.serverUrl, input.fetch, { scope: input.scope }));
|
|
148
|
+
if (result !== "REDIRECT" ||
|
|
149
|
+
captured.authorizeUrl === undefined ||
|
|
150
|
+
captured.client === undefined ||
|
|
151
|
+
captured.codeVerifier === undefined ||
|
|
152
|
+
captured.authorizationServerUrl === undefined)
|
|
153
|
+
throw new ConnectionsError("invalid_response");
|
|
154
|
+
return {
|
|
155
|
+
authorizeUrl: captured.authorizeUrl.href,
|
|
156
|
+
bundle: {
|
|
157
|
+
authorizationServerUrl: captured.authorizationServerUrl,
|
|
158
|
+
client: captured.client,
|
|
159
|
+
codeVerifier: captured.codeVerifier,
|
|
160
|
+
resource: captured.resource,
|
|
161
|
+
tokens: undefined,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Redeem the code; the SDK validates `iss` and PKCE. The issuer must be the one the attempt began with. */
|
|
166
|
+
export async function finishAuthorization(input) {
|
|
167
|
+
const { bundle } = input;
|
|
168
|
+
if (bundle.codeVerifier === undefined)
|
|
169
|
+
throw new ConnectionsError("denied");
|
|
170
|
+
const captured = emptyCapture();
|
|
171
|
+
captured.client = bundle.client;
|
|
172
|
+
const flow = provider({
|
|
173
|
+
callbackUrl: input.callbackUrl,
|
|
174
|
+
scope: undefined,
|
|
175
|
+
now: input.now,
|
|
176
|
+
resumed: {
|
|
177
|
+
authorizationServerUrl: bundle.authorizationServerUrl,
|
|
178
|
+
codeVerifier: bundle.codeVerifier,
|
|
179
|
+
},
|
|
180
|
+
state: undefined,
|
|
181
|
+
registry: undefined,
|
|
182
|
+
own: bundle.client,
|
|
183
|
+
}, captured);
|
|
184
|
+
const sdk = await import("@modelcontextprotocol/client");
|
|
185
|
+
const result = await sdk.auth(flow, authOptions(input.serverUrl, input.fetch, {
|
|
186
|
+
code: input.code,
|
|
187
|
+
iss: input.iss,
|
|
188
|
+
}));
|
|
189
|
+
if (result !== "AUTHORIZED" ||
|
|
190
|
+
captured.tokens === undefined ||
|
|
191
|
+
captured.authorizationServerUrl !== bundle.authorizationServerUrl)
|
|
192
|
+
throw new ConnectionsError("denied");
|
|
193
|
+
return {
|
|
194
|
+
authorizationServerUrl: bundle.authorizationServerUrl,
|
|
195
|
+
client: bundle.client,
|
|
196
|
+
resource: captured.resource ?? bundle.resource,
|
|
197
|
+
codeVerifier: undefined,
|
|
198
|
+
tokens: captured.tokens,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* One refresh grant. A rotated refresh token replaces the old one; the caller
|
|
203
|
+
* persists the returned bundle before using the new access token.
|
|
204
|
+
*/
|
|
205
|
+
export async function refreshTokens(input) {
|
|
206
|
+
const { bundle } = input;
|
|
207
|
+
const held = bundle.tokens?.refresh_token;
|
|
208
|
+
if (held === undefined)
|
|
209
|
+
throw new ConnectionsError("reconnect_required");
|
|
210
|
+
const sdk = await import("@modelcontextprotocol/client");
|
|
211
|
+
try {
|
|
212
|
+
const metadata = await sdk.discoverAuthorizationServerMetadata(bundle.authorizationServerUrl, { fetchFn: input.fetch });
|
|
213
|
+
const options = {
|
|
214
|
+
clientInformation: clientInfo(bundle.client),
|
|
215
|
+
refreshToken: held,
|
|
216
|
+
fetchFn: input.fetch,
|
|
217
|
+
};
|
|
218
|
+
if (metadata !== undefined)
|
|
219
|
+
options.metadata = metadata;
|
|
220
|
+
if (bundle.resource !== undefined)
|
|
221
|
+
options.resource = new URL(bundle.resource);
|
|
222
|
+
const fresh = await sdk.refreshAuthorization(bundle.authorizationServerUrl, options);
|
|
223
|
+
return {
|
|
224
|
+
...bundle,
|
|
225
|
+
tokens: {
|
|
226
|
+
access_token: fresh.access_token,
|
|
227
|
+
refresh_token: fresh.refresh_token ?? held,
|
|
228
|
+
expires_in: fresh.expires_in,
|
|
229
|
+
obtainedAt: input.now,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch (cause) {
|
|
234
|
+
// The server no longer honours the grant: only a new authorization helps.
|
|
235
|
+
if (sdk.OAuthError.isInstance(cause) && cause.code === "invalid_grant")
|
|
236
|
+
throw new ConnectionsError("reconnect_required", { cause });
|
|
237
|
+
throw cause;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { OpenApiOperation } from "@fungi.computer/forage/openapi";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type Tools } from "./contract.js";
|
|
4
|
+
/** Stored operations are decoded again on every use; the digest binds what the owner approved. */
|
|
5
|
+
export declare function decodeTable(stored: readonly unknown[] | null | undefined): Promise<{
|
|
6
|
+
operations: {
|
|
7
|
+
status: "supported";
|
|
8
|
+
name: string;
|
|
9
|
+
method: "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
10
|
+
path: string;
|
|
11
|
+
effect: "write" | "read";
|
|
12
|
+
description: string & z.core.$brand<"ThirdPartyText">;
|
|
13
|
+
textSource: "third_party";
|
|
14
|
+
tags: (string & z.core.$brand<"ThirdPartyText">)[];
|
|
15
|
+
parameters: {
|
|
16
|
+
name: string;
|
|
17
|
+
in: "header" | "path" | "query";
|
|
18
|
+
required: boolean;
|
|
19
|
+
schema: boolean | Record<string, z.core.util.JSONType>;
|
|
20
|
+
style?: string | undefined;
|
|
21
|
+
explode?: boolean | undefined;
|
|
22
|
+
allowReserved?: boolean | undefined;
|
|
23
|
+
}[];
|
|
24
|
+
input: boolean | Record<string, z.core.util.JSONType>;
|
|
25
|
+
output: boolean | Record<string, z.core.util.JSONType>;
|
|
26
|
+
}[];
|
|
27
|
+
digest: string;
|
|
28
|
+
}>;
|
|
29
|
+
/** Whether a grant admits one operation by name and stored effect. */
|
|
30
|
+
export declare function admits(tools: Tools, operation: Pick<OpenApiOperation, "name" | "effect">): boolean;
|
|
31
|
+
/** Builds only a stored operation's request; the core alone attaches the credential. */
|
|
32
|
+
export declare function operationRequest(baseUrl: string, operation: OpenApiOperation, rawInput: unknown): Promise<Request>;
|
|
33
|
+
/** The stored output schema is the contract for what an Agent may see. */
|
|
34
|
+
export declare function operationOutput(operation: OpenApiOperation, text: string): Promise<z.core.util.JSONType>;
|
|
35
|
+
/** A response body as text, refusing more than the response cap. */
|
|
36
|
+
export declare function boundedText(response: Response): Promise<string>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ConnectionsError } from "./contract.js";
|
|
4
|
+
/** Stored operations are decoded again on every use; the digest binds what the owner approved. */
|
|
5
|
+
export async function decodeTable(stored) {
|
|
6
|
+
try {
|
|
7
|
+
// Forage loads on first use, never at Team startup.
|
|
8
|
+
const { parseOpenApiTable } = await import("@fungi.computer/forage/openapi");
|
|
9
|
+
return await parseOpenApiTable(stored);
|
|
10
|
+
}
|
|
11
|
+
catch (cause) {
|
|
12
|
+
throw new ConnectionsError("unavailable", { cause });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Whether a grant admits one operation by name and stored effect. */
|
|
16
|
+
export function admits(tools, operation) {
|
|
17
|
+
if (tools === "all")
|
|
18
|
+
return true;
|
|
19
|
+
if (Array.isArray(tools))
|
|
20
|
+
return tools.includes(operation.name);
|
|
21
|
+
return operation.effect === "read";
|
|
22
|
+
}
|
|
23
|
+
function hasControlCharacters(value) {
|
|
24
|
+
for (let index = 0; index < value.length; index++) {
|
|
25
|
+
const code = value.charCodeAt(index);
|
|
26
|
+
if (code < 32 || code === 127)
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
/** Builds only a stored operation's request; the core alone attaches the credential. */
|
|
32
|
+
export async function operationRequest(baseUrl, operation, rawInput) {
|
|
33
|
+
const { jsonSchemaToZod } = await import("@fungi.computer/forage");
|
|
34
|
+
try {
|
|
35
|
+
const input = z
|
|
36
|
+
.record(z.string(), z.json())
|
|
37
|
+
.parse(jsonSchemaToZod(operation.input).parse(rawInput));
|
|
38
|
+
const base = new URL(baseUrl);
|
|
39
|
+
let path = operation.path;
|
|
40
|
+
const headers = new Headers();
|
|
41
|
+
const query = new URLSearchParams();
|
|
42
|
+
for (const parameter of operation.parameters) {
|
|
43
|
+
const value = input[parameter.name];
|
|
44
|
+
if (value === undefined || value === null)
|
|
45
|
+
continue;
|
|
46
|
+
const scalar = z
|
|
47
|
+
.union([z.string(), z.number(), z.boolean()])
|
|
48
|
+
.parse(value);
|
|
49
|
+
if (parameter.in === "path") {
|
|
50
|
+
const segment = String(scalar);
|
|
51
|
+
if (!segment ||
|
|
52
|
+
segment === "." ||
|
|
53
|
+
segment === ".." ||
|
|
54
|
+
segment.includes("/") ||
|
|
55
|
+
segment.includes("\\") ||
|
|
56
|
+
hasControlCharacters(segment) ||
|
|
57
|
+
/%(?:2e|2f|5c|25)/iu.test(segment))
|
|
58
|
+
throw new ConnectionsError("invalid_input");
|
|
59
|
+
path = path.replace(`{${parameter.name}}`, encodeURIComponent(segment));
|
|
60
|
+
}
|
|
61
|
+
else if (parameter.in === "query")
|
|
62
|
+
query.set(parameter.name, String(scalar));
|
|
63
|
+
else
|
|
64
|
+
headers.set(parameter.name, String(scalar));
|
|
65
|
+
}
|
|
66
|
+
const basePath = `${base.pathname.replace(/\/$/u, "")}/`;
|
|
67
|
+
const url = new URL(`${basePath}${path.slice(1)}`, base.origin);
|
|
68
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(basePath))
|
|
69
|
+
throw new ConnectionsError("invalid_input");
|
|
70
|
+
url.search = query.toString();
|
|
71
|
+
const body = input.body === undefined ? undefined : JSON.stringify(input.body);
|
|
72
|
+
if (body !== undefined)
|
|
73
|
+
headers.set("Content-Type", "application/json");
|
|
74
|
+
const init = {
|
|
75
|
+
method: operation.method,
|
|
76
|
+
headers,
|
|
77
|
+
redirect: "error",
|
|
78
|
+
};
|
|
79
|
+
if (body !== undefined)
|
|
80
|
+
init.body = body;
|
|
81
|
+
return new Request(url, init);
|
|
82
|
+
}
|
|
83
|
+
catch (cause) {
|
|
84
|
+
if (cause instanceof ConnectionsError)
|
|
85
|
+
throw cause;
|
|
86
|
+
throw new ConnectionsError("invalid_input", { cause });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The stored output schema is the contract for what an Agent may see. */
|
|
90
|
+
export async function operationOutput(operation, text) {
|
|
91
|
+
const { jsonSchemaToZod } = await import("@fungi.computer/forage");
|
|
92
|
+
try {
|
|
93
|
+
return z
|
|
94
|
+
.json()
|
|
95
|
+
.parse(jsonSchemaToZod(operation.output).parse(Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(text)));
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
throw new ConnectionsError("invalid_response", { cause });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const responseLimit = 4 * 1024 * 1024;
|
|
102
|
+
/** A response body as text, refusing more than the response cap. */
|
|
103
|
+
export async function boundedText(response) {
|
|
104
|
+
const reader = response.body?.getReader();
|
|
105
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
106
|
+
let size = 0;
|
|
107
|
+
let text = "";
|
|
108
|
+
for (;;) {
|
|
109
|
+
const next = await reader?.read();
|
|
110
|
+
if (next === undefined || next.done)
|
|
111
|
+
break;
|
|
112
|
+
size += next.value.byteLength;
|
|
113
|
+
if (size > responseLimit) {
|
|
114
|
+
await reader?.cancel();
|
|
115
|
+
throw new ConnectionsError("too_large");
|
|
116
|
+
}
|
|
117
|
+
text += decoder.decode(next.value, { stream: true });
|
|
118
|
+
}
|
|
119
|
+
return text + decoder.decode();
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CustodyAdapter } from "./contract.js";
|
|
2
|
+
/**
|
|
3
|
+
* The slice of Veil's one credential store (`createCredentialStore`) that sealed
|
|
4
|
+
* custody needs: ciphertext at rest, the secret released only into a callback.
|
|
5
|
+
*/
|
|
6
|
+
export type CredentialVault = Readonly<{
|
|
7
|
+
put: (input: {
|
|
8
|
+
kind: string;
|
|
9
|
+
label: string;
|
|
10
|
+
secret: string;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
credentialId: string;
|
|
13
|
+
}>;
|
|
14
|
+
withSecret: <T>(credentialId: string, action: (secret: string) => Promise<T> | T) => Promise<T>;
|
|
15
|
+
remove: (credentialId: string) => Promise<"deleted" | "absent">;
|
|
16
|
+
}>;
|
|
17
|
+
/** Default custody: a thin adapter over the shared credential store. `send` is the checked public transport. */
|
|
18
|
+
export declare function createSealedCustody(options: {
|
|
19
|
+
vault: CredentialVault;
|
|
20
|
+
send: (request: Request) => Promise<Response>;
|
|
21
|
+
}): CustodyAdapter;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { oauthBundleJson } from "./oauth.js";
|
|
4
|
+
import { ConnectionsError, } from "./contract.js";
|
|
5
|
+
/** Where a static secret rides on the final request. */
|
|
6
|
+
function applyPlacement(request, placement, secret) {
|
|
7
|
+
if (placement !== "bearer" && "query" in placement) {
|
|
8
|
+
const target = new URL(request.url);
|
|
9
|
+
target.searchParams.set(placement.query, secret);
|
|
10
|
+
return new Request(target.href, request);
|
|
11
|
+
}
|
|
12
|
+
const headers = new Headers(request.headers);
|
|
13
|
+
if (placement === "bearer")
|
|
14
|
+
headers.set("authorization", `Bearer ${secret}`);
|
|
15
|
+
else
|
|
16
|
+
headers.set(placement.header, secret);
|
|
17
|
+
return new Request(request, { headers });
|
|
18
|
+
}
|
|
19
|
+
const failure = Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String }));
|
|
20
|
+
const refusal = (cause) => {
|
|
21
|
+
if (cause instanceof ConnectionsError)
|
|
22
|
+
return cause;
|
|
23
|
+
try {
|
|
24
|
+
const { code } = failure(cause);
|
|
25
|
+
return new ConnectionsError(code === "absent" || code === "denied"
|
|
26
|
+
? "reconnect_required"
|
|
27
|
+
: "unavailable", { cause });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return new ConnectionsError("unavailable", { cause });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Attach the sealed secret the way the credential says. */
|
|
34
|
+
function authorized(credential, secret, request) {
|
|
35
|
+
if (credential.kind === "static")
|
|
36
|
+
return applyPlacement(request, credential.placement, secret);
|
|
37
|
+
if (credential.kind === "none")
|
|
38
|
+
return request;
|
|
39
|
+
const bundle = Schema.decodeUnknownOption(oauthBundleJson)(secret);
|
|
40
|
+
if (bundle._tag === "None" || bundle.value.tokens === undefined)
|
|
41
|
+
throw new ConnectionsError("reconnect_required");
|
|
42
|
+
return applyPlacement(request, "bearer", bundle.value.tokens.access_token);
|
|
43
|
+
}
|
|
44
|
+
/** Default custody: a thin adapter over the shared credential store. `send` is the checked public transport. */
|
|
45
|
+
export function createSealedCustody(options) {
|
|
46
|
+
return {
|
|
47
|
+
put: (address, secret) => Effect.tryPromise({
|
|
48
|
+
try: async () => (await options.vault.put({
|
|
49
|
+
kind: "connection",
|
|
50
|
+
label: address.connectionId,
|
|
51
|
+
secret,
|
|
52
|
+
})).credentialId,
|
|
53
|
+
catch: refusal,
|
|
54
|
+
}),
|
|
55
|
+
inject: (address, locator, request) => address.credential.kind === "none"
|
|
56
|
+
? Effect.tryPromise({
|
|
57
|
+
try: () => options.send(request),
|
|
58
|
+
catch: refusal,
|
|
59
|
+
})
|
|
60
|
+
: Effect.tryPromise({
|
|
61
|
+
try: () => options.vault.withSecret(locator, (secret) => options.send(authorized(address.credential, secret, request))),
|
|
62
|
+
catch: refusal,
|
|
63
|
+
}),
|
|
64
|
+
open: (_address, locator) => Effect.tryPromise({
|
|
65
|
+
try: () => options.vault.withSecret(locator, (secret) => secret),
|
|
66
|
+
catch: refusal,
|
|
67
|
+
}),
|
|
68
|
+
remove: (_address, locator) => Effect.tryPromise({
|
|
69
|
+
try: () => options.vault.remove(locator),
|
|
70
|
+
catch: refusal,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type CapabilitySource } from "@fungi.computer/mycelium";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type ConnectionProjection, type EgressRef } from "./contract.js";
|
|
4
|
+
/**
|
|
5
|
+
* The Agent half for OpenAPI connections: one Mycelium module per granted
|
|
6
|
+
* connection, its operations built from the stored table and admitted by the
|
|
7
|
+
* grant. Every call is completed by the owner; nothing here holds a credential.
|
|
8
|
+
* MCP connections stay with the host's Knapsack source.
|
|
9
|
+
*/
|
|
10
|
+
export declare function connectionsSource(input: {
|
|
11
|
+
id: string;
|
|
12
|
+
/** Told when a granted connection cannot be offered, with its id and nothing else. */
|
|
13
|
+
onSkipped?: (connectionId: string) => void;
|
|
14
|
+
read: () => Promise<ConnectionProjection>;
|
|
15
|
+
subscribe: (invalidate: () => void) => () => void;
|
|
16
|
+
call: (ref: EgressRef, operation: string, arguments_: z.core.output<ReturnType<typeof z.json>>) => Promise<z.core.output<ReturnType<typeof z.json>>>;
|
|
17
|
+
}): CapabilitySource;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { CapabilityFailed, Mycelium, } from "@fungi.computer/mycelium";
|
|
2
|
+
import { jsonSchemaToZod } from "@fungi.computer/forage";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ConnectionsError, } from "./contract.js";
|
|
6
|
+
import { admits, decodeTable } from "./openapi.js";
|
|
7
|
+
/** A table that no longer decodes is not offered; the caller is told which connection. */
|
|
8
|
+
async function tryDecode(table) {
|
|
9
|
+
const outcome = await Effect.runPromise(Effect.result(Effect.tryPromise(() => decodeTable(table))));
|
|
10
|
+
return outcome._tag === "Success" ? outcome.success : null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The Agent half for OpenAPI connections: one Mycelium module per granted
|
|
14
|
+
* connection, its operations built from the stored table and admitted by the
|
|
15
|
+
* grant. Every call is completed by the owner; nothing here holds a credential.
|
|
16
|
+
* MCP connections stay with the host's Knapsack source.
|
|
17
|
+
*/
|
|
18
|
+
export function connectionsSource(input) {
|
|
19
|
+
return {
|
|
20
|
+
id: input.id,
|
|
21
|
+
acquire: async ({ signal, invalidate }) => {
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
const unsubscribe = input.subscribe(invalidate);
|
|
24
|
+
const release = () => {
|
|
25
|
+
unsubscribe();
|
|
26
|
+
return Promise.resolve();
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
const captured = await input.read();
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
const modules = [];
|
|
32
|
+
for (const connection of captured.connections) {
|
|
33
|
+
if (connection.surface.kind !== "openapi")
|
|
34
|
+
continue;
|
|
35
|
+
// The projection crossed a boundary: decode its table again and match the digest.
|
|
36
|
+
const decoded = await tryDecode(connection.surface.table);
|
|
37
|
+
if (decoded?.digest !== connection.surface.digest) {
|
|
38
|
+
input.onSkipped?.(connection.connectionId);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const ref = {
|
|
42
|
+
connectionId: connection.connectionId,
|
|
43
|
+
grantVersion: connection.grantVersion,
|
|
44
|
+
};
|
|
45
|
+
const connectorModule = Mycelium.module({
|
|
46
|
+
id: `connector:${connection.connectionId}`,
|
|
47
|
+
name: connection.name,
|
|
48
|
+
description: `Third-party connector: ${connection.name}`,
|
|
49
|
+
operations: Object.fromEntries(decoded.operations
|
|
50
|
+
.filter((operation) => admits(connection.tools, operation))
|
|
51
|
+
.map((operation) => [
|
|
52
|
+
operation.name,
|
|
53
|
+
Mycelium.operation({
|
|
54
|
+
replay: "unsafe",
|
|
55
|
+
access: operation.effect,
|
|
56
|
+
description: `Third-party API text: ${operation.description}\nEffect: ${operation.effect}.`,
|
|
57
|
+
input: jsonSchemaToZod(operation.input).pipe(z.json()),
|
|
58
|
+
output: z.strictObject({
|
|
59
|
+
textSource: z.literal("third_party"),
|
|
60
|
+
value: jsonSchemaToZod(operation.output).pipe(z.json()),
|
|
61
|
+
}),
|
|
62
|
+
execute: async (arguments_, context) => {
|
|
63
|
+
context.signal.throwIfAborted();
|
|
64
|
+
try {
|
|
65
|
+
return {
|
|
66
|
+
textSource: "third_party",
|
|
67
|
+
value: await input.call(ref, operation.name, z.json().parse(arguments_)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
throw new CapabilityFailed({
|
|
72
|
+
namespace: "connectors",
|
|
73
|
+
operation: operation.name,
|
|
74
|
+
cause: {
|
|
75
|
+
kind: "OperationFailed",
|
|
76
|
+
message: cause instanceof ConnectionsError
|
|
77
|
+
? cause.code
|
|
78
|
+
: "unavailable",
|
|
79
|
+
},
|
|
80
|
+
rawCause: cause,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
])),
|
|
86
|
+
});
|
|
87
|
+
modules.push(connectorModule);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
snapshot: {
|
|
91
|
+
version: JSON.stringify([
|
|
92
|
+
captured.revision,
|
|
93
|
+
...captured.connections.map((connection) => [
|
|
94
|
+
connection.connectionId,
|
|
95
|
+
connection.version,
|
|
96
|
+
connection.authorityVersion,
|
|
97
|
+
connection.grantVersion,
|
|
98
|
+
]),
|
|
99
|
+
]),
|
|
100
|
+
modules,
|
|
101
|
+
},
|
|
102
|
+
release,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
await release();
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { ConnectionsError, type ConnectionsOptions, type UpdateInput } from "./contract.js";
|
|
3
|
+
import { type ConnectionRow } from "./internal.js";
|
|
4
|
+
/** The owner's SQLite as `update` sees it; every method is one synchronous step or transaction. */
|
|
5
|
+
export type UpdateStore = Readonly<{
|
|
6
|
+
authorize: (actor: string) => void;
|
|
7
|
+
connection: (id: string) => ConnectionRow;
|
|
8
|
+
/** A recorded update by request id, with the stamp it was recorded under. */
|
|
9
|
+
receipt: (requestId: string) => Readonly<{
|
|
10
|
+
stamp: string;
|
|
11
|
+
connectionId: string;
|
|
12
|
+
version: number;
|
|
13
|
+
}> | null;
|
|
14
|
+
checkName: (name: string, exceptId: string) => void;
|
|
15
|
+
publicUrl: (value: string) => string;
|
|
16
|
+
commit: (input: Readonly<{
|
|
17
|
+
actor: string;
|
|
18
|
+
update: UpdateInput;
|
|
19
|
+
stamp: string;
|
|
20
|
+
row: ConnectionRow;
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
table: ConnectionRow["surface"]["table"];
|
|
23
|
+
digest: string | null;
|
|
24
|
+
credential: ConnectionRow["credential"];
|
|
25
|
+
locator: string | null;
|
|
26
|
+
}>) => {
|
|
27
|
+
connectionId: string;
|
|
28
|
+
version: number;
|
|
29
|
+
};
|
|
30
|
+
/** Queue a sealed locator for deletion. */
|
|
31
|
+
discard: (connectionId: string, locator: string, credential: ConnectionRow["credential"]) => void;
|
|
32
|
+
}>;
|
|
33
|
+
/** Owner edits guarded by the version the owner last saw; replays are idempotent. */
|
|
34
|
+
export declare function createUpdate(deps: {
|
|
35
|
+
ownerId: string;
|
|
36
|
+
custody: ConnectionsOptions["custody"];
|
|
37
|
+
store: UpdateStore;
|
|
38
|
+
}): (actor: string, raw: UpdateInput) => Effect.Effect<{
|
|
39
|
+
connectionId: string;
|
|
40
|
+
version: number;
|
|
41
|
+
}, ConnectionsError, never>;
|