@fungi.computer/veil 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -2
- package/dist/best-effort-cleanup.d.ts +2 -0
- package/dist/best-effort-cleanup.js +5 -0
- package/dist/event-verifiers.d.ts +80 -0
- package/dist/event-verifiers.js +168 -0
- package/dist/gatekeeper.d.ts +202 -0
- package/dist/gatekeeper.js +231 -0
- package/dist/index.d.ts +254 -0
- package/dist/index.js +267 -0
- package/dist/model-credentials.d.ts +226 -0
- package/dist/model-credentials.js +133 -0
- package/dist/model-key-grants.d.ts +56 -0
- package/dist/model-key-grants.js +107 -0
- package/dist/model-key-network.d.ts +33 -0
- package/dist/model-key-network.js +202 -0
- package/dist/model-keys.d.ts +42 -0
- package/dist/model-keys.js +115 -0
- package/dist/nango-custody-http.d.ts +16 -0
- package/dist/nango-custody-http.js +93 -0
- package/dist/nango-git.d.ts +19 -0
- package/dist/nango-git.js +125 -0
- package/dist/nango-model-keys.d.ts +13 -0
- package/dist/nango-model-keys.js +48 -0
- package/dist/nango-world-seat.d.ts +14 -0
- package/dist/nango-world-seat.js +47 -0
- package/dist/nango.d.ts +16 -0
- package/dist/nango.js +300 -0
- package/dist/openapi-contract.d.ts +64 -0
- package/dist/openapi-contract.js +11 -0
- package/dist/openapi-effect.d.ts +9 -0
- package/dist/openapi-effect.js +284 -0
- package/dist/openapi-http.d.ts +2 -0
- package/dist/openapi-http.js +37 -0
- package/dist/openapi-secrets.d.ts +6 -0
- package/dist/openapi-secrets.js +80 -0
- package/dist/openapi.d.ts +5 -0
- package/dist/openapi.js +24 -0
- package/dist/owned-infrastructure.d.ts +13 -0
- package/dist/owned-infrastructure.js +17 -0
- package/dist/provider-egress.d.ts +51 -0
- package/dist/provider-egress.js +155 -0
- package/dist/public-egress-policy.d.ts +13 -0
- package/dist/public-egress-policy.js +101 -0
- package/dist/public-fetch.d.ts +90 -0
- package/dist/public-fetch.js +347 -0
- package/dist/webhook-trigger-secrets.d.ts +23 -0
- package/dist/webhook-trigger-secrets.js +53 -0
- package/dist/world-seat.d.ts +40 -0
- package/dist/world-seat.js +82 -0
- package/package.json +140 -3
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
export { validatePublicEgressPolicyEntries } from "./public-egress-policy.js";
|
|
2
|
+
import { createPublicEgressPolicy, } from "./public-egress-policy.js";
|
|
3
|
+
export { createPublicEgressPolicy, } from "./public-egress-policy.js";
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
5
|
+
import { HTTPParser } from "http-parser-js";
|
|
6
|
+
import { VeilError } from "./index.js";
|
|
7
|
+
import { bestEffortCleanup } from "./best-effort-cleanup.js";
|
|
8
|
+
import { createDnsOverHttpsResolver, resolvePublicAddresses, } from "./model-key-network.js";
|
|
9
|
+
export class PublicFetchError extends VeilError {
|
|
10
|
+
stage;
|
|
11
|
+
constructor(code, stage) {
|
|
12
|
+
super(code);
|
|
13
|
+
this.stage = stage;
|
|
14
|
+
this.name = "PublicFetchError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Response status line and headers must fit here before the body starts. */
|
|
18
|
+
const MAX_HEADER_BYTES = 64 * 1024;
|
|
19
|
+
const HEADER_SLICE_BYTES = 4 * 1024;
|
|
20
|
+
/** Checked addresses tried in order when a connection cannot be opened. */
|
|
21
|
+
const MAX_ADDRESSES = 4;
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
function destination(url) {
|
|
24
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
25
|
+
throw new PublicFetchError("denied", "address");
|
|
26
|
+
if (url.username !== "" || url.password !== "")
|
|
27
|
+
throw new PublicFetchError("denied", "address");
|
|
28
|
+
const secure = url.protocol === "https:";
|
|
29
|
+
return {
|
|
30
|
+
secure,
|
|
31
|
+
hostname: url.hostname,
|
|
32
|
+
port: url.port === "" ? (secure ? 443 : 80) : Number(url.port),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function headerValue(value) {
|
|
36
|
+
for (const character of value) {
|
|
37
|
+
const code = character.charCodeAt(0);
|
|
38
|
+
if (code < 0x20 || code === 0x7f)
|
|
39
|
+
throw new PublicFetchError("invalid_input", "address");
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function requestBytes(request) {
|
|
44
|
+
const { url } = request;
|
|
45
|
+
// The URL parser percent-encodes the path and query, so neither can carry a
|
|
46
|
+
// line break; `host` is the URL's own authority.
|
|
47
|
+
const head = encoder.encode([
|
|
48
|
+
`${request.method} ${url.pathname}${url.search} HTTP/1.1`,
|
|
49
|
+
`Host: ${url.host}`,
|
|
50
|
+
...Array.from(request.headers, ([name, value]) => `${name}: ${headerValue(value)}`),
|
|
51
|
+
"Accept-Encoding: identity",
|
|
52
|
+
"Connection: close",
|
|
53
|
+
...(request.body.byteLength > 0 ||
|
|
54
|
+
!["GET", "HEAD"].includes(request.method)
|
|
55
|
+
? [`Content-Length: ${String(request.body.byteLength)}`]
|
|
56
|
+
: []),
|
|
57
|
+
"",
|
|
58
|
+
"",
|
|
59
|
+
].join("\r\n"));
|
|
60
|
+
if (head.byteLength > MAX_HEADER_BYTES)
|
|
61
|
+
throw new PublicFetchError("invalid_input", "address");
|
|
62
|
+
const bytes = new Uint8Array(head.byteLength + request.body.byteLength);
|
|
63
|
+
bytes.set(head);
|
|
64
|
+
bytes.set(request.body, head.byteLength);
|
|
65
|
+
return bytes;
|
|
66
|
+
}
|
|
67
|
+
/** Wait for the socket to open, giving up when the call is cancelled. */
|
|
68
|
+
async function opened(socket, signal) {
|
|
69
|
+
let onAbort;
|
|
70
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
71
|
+
onAbort = () => {
|
|
72
|
+
reject(new PublicFetchError("cancelled", "connect"));
|
|
73
|
+
};
|
|
74
|
+
if (signal.aborted)
|
|
75
|
+
onAbort();
|
|
76
|
+
else
|
|
77
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
await Promise.race([socket.opened, aborted]);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (onAbort !== undefined)
|
|
84
|
+
signal.removeEventListener("abort", onAbort);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Open a connection to the first checked address that accepts one. */
|
|
88
|
+
async function connectChecked(connect, addresses, to, signal) {
|
|
89
|
+
for (const hostname of addresses.slice(0, MAX_ADDRESSES)) {
|
|
90
|
+
let socket;
|
|
91
|
+
try {
|
|
92
|
+
signal.throwIfAborted();
|
|
93
|
+
socket = connect({ hostname, port: to.port }, {
|
|
94
|
+
secureTransport: to.secure ? "starttls" : "off",
|
|
95
|
+
allowHalfOpen: false,
|
|
96
|
+
});
|
|
97
|
+
await opened(socket, signal);
|
|
98
|
+
return to.secure
|
|
99
|
+
? socket.startTls({
|
|
100
|
+
expectedServerHostname: to.hostname.replace(/^\[|\]$/gu, ""),
|
|
101
|
+
})
|
|
102
|
+
: socket;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
await bestEffortCleanup("public-fetch-socket", () => socket?.close() ?? Promise.resolve());
|
|
106
|
+
if (signal.aborted)
|
|
107
|
+
throw new PublicFetchError("cancelled", "connect");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new PublicFetchError("unavailable", "connect");
|
|
111
|
+
}
|
|
112
|
+
const bodylessStatuses = new Set([204, 205, 304]);
|
|
113
|
+
/** Headers describing wire framing, which the parsed Response no longer has. */
|
|
114
|
+
const framingHeaders = new Set([
|
|
115
|
+
"transfer-encoding",
|
|
116
|
+
"connection",
|
|
117
|
+
"keep-alive",
|
|
118
|
+
]);
|
|
119
|
+
function refusedResponse() {
|
|
120
|
+
return new PublicFetchError("invalid_response", "response");
|
|
121
|
+
}
|
|
122
|
+
function decodeHead(info) {
|
|
123
|
+
if (info.upgrade || info.statusCode < 200)
|
|
124
|
+
throw refusedResponse();
|
|
125
|
+
const headers = new Headers();
|
|
126
|
+
let transferEncodings = 0;
|
|
127
|
+
for (let index = 0; index + 1 < info.headers.length; index += 2) {
|
|
128
|
+
const name = (info.headers[index] ?? "").toLowerCase();
|
|
129
|
+
const value = info.headers[index + 1] ?? "";
|
|
130
|
+
if (name === "transfer-encoding") {
|
|
131
|
+
// The parser frames only a bare `chunked`; anything layered is unreadable.
|
|
132
|
+
transferEncodings += 1;
|
|
133
|
+
if (transferEncodings > 1 || value.toLowerCase() !== "chunked")
|
|
134
|
+
throw refusedResponse();
|
|
135
|
+
}
|
|
136
|
+
if (name === "content-length" && !/^[0-9]{1,15}$/u.test(value))
|
|
137
|
+
throw refusedResponse();
|
|
138
|
+
// Compressed bodies are never requested; an unrequested one is not text.
|
|
139
|
+
if (name === "content-encoding" &&
|
|
140
|
+
value.trim() !== "" &&
|
|
141
|
+
value.trim().toLowerCase() !== "identity")
|
|
142
|
+
throw refusedResponse();
|
|
143
|
+
if (framingHeaders.has(name))
|
|
144
|
+
continue;
|
|
145
|
+
try {
|
|
146
|
+
headers.append(name, value);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw refusedResponse();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { status: info.statusCode, headers };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Write the request and read one HTTP/1.1 response from `socket`. The
|
|
156
|
+
* maintained parser frames it (status line, headers, content-length, chunked,
|
|
157
|
+
* read-to-close). The body stays a pull stream, so a reader that cancels
|
|
158
|
+
* closes the socket and cancelling `signal` closes it too.
|
|
159
|
+
*/
|
|
160
|
+
async function exchange(socket, request) {
|
|
161
|
+
const { signal } = request;
|
|
162
|
+
const reader = socket.readable.getReader();
|
|
163
|
+
const parser = new HTTPParser(HTTPParser.RESPONSE);
|
|
164
|
+
parser.maxHeaderSize = MAX_HEADER_BYTES;
|
|
165
|
+
const pending = [];
|
|
166
|
+
let head;
|
|
167
|
+
const state = { complete: false };
|
|
168
|
+
const finished = () => state.complete;
|
|
169
|
+
let disposed = false;
|
|
170
|
+
const dispose = async () => {
|
|
171
|
+
if (disposed)
|
|
172
|
+
return;
|
|
173
|
+
disposed = true;
|
|
174
|
+
signal.removeEventListener("abort", onAbort);
|
|
175
|
+
await Promise.allSettled([reader.cancel(), socket.close()]);
|
|
176
|
+
};
|
|
177
|
+
function onAbort() {
|
|
178
|
+
void dispose();
|
|
179
|
+
}
|
|
180
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
181
|
+
// One request per connection: once the first response is complete, whatever
|
|
182
|
+
// else the peer sends is not part of it.
|
|
183
|
+
parser[HTTPParser.kOnHeadersComplete] = (info) => {
|
|
184
|
+
if (state.complete)
|
|
185
|
+
return 1;
|
|
186
|
+
head = decodeHead(info);
|
|
187
|
+
return bodylessStatuses.has(info.statusCode) || request.method === "HEAD"
|
|
188
|
+
? 1
|
|
189
|
+
: undefined;
|
|
190
|
+
};
|
|
191
|
+
parser[HTTPParser.kOnBody] = (chunk, offset, length) => {
|
|
192
|
+
if (!state.complete && length > 0)
|
|
193
|
+
pending.push(Uint8Array.from(chunk.subarray(offset, offset + length)));
|
|
194
|
+
};
|
|
195
|
+
parser[HTTPParser.kOnMessageComplete] = () => {
|
|
196
|
+
state.complete = true;
|
|
197
|
+
};
|
|
198
|
+
/** Read one socket chunk into the parser; false once the peer has closed. */
|
|
199
|
+
const advance = async () => {
|
|
200
|
+
let next;
|
|
201
|
+
try {
|
|
202
|
+
next = await reader.read();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
throw new PublicFetchError(signal.aborted ? "cancelled" : "unavailable", "response");
|
|
206
|
+
}
|
|
207
|
+
if (next.done) {
|
|
208
|
+
if (parser.finish() instanceof Error && !state.complete)
|
|
209
|
+
throw refusedResponse();
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
const { buffer, byteOffset, byteLength } = next.value;
|
|
213
|
+
// The parser measures its header cap after each call, so feed small slices
|
|
214
|
+
// until the head is read; a body may then arrive whole.
|
|
215
|
+
for (let start = 0; start < byteLength;) {
|
|
216
|
+
const end = head === undefined
|
|
217
|
+
? Math.min(start + HEADER_SLICE_BYTES, byteLength)
|
|
218
|
+
: byteLength;
|
|
219
|
+
const parsed = parser.execute(Buffer.from(buffer, byteOffset + start, end - start));
|
|
220
|
+
if (parsed instanceof Error && !state.complete)
|
|
221
|
+
throw refusedResponse();
|
|
222
|
+
start = end;
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
try {
|
|
228
|
+
const writer = socket.writable.getWriter();
|
|
229
|
+
await writer.write(requestBytes(request));
|
|
230
|
+
writer.releaseLock();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
throw new PublicFetchError(signal.aborted ? "cancelled" : "unavailable", "connect");
|
|
234
|
+
}
|
|
235
|
+
while (head === undefined) {
|
|
236
|
+
if (!(await advance()))
|
|
237
|
+
throw refusedResponse();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
await dispose();
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
const { status, headers } = head;
|
|
245
|
+
if (bodylessStatuses.has(status) || request.method === "HEAD") {
|
|
246
|
+
await dispose();
|
|
247
|
+
return new Response(null, { status, headers });
|
|
248
|
+
}
|
|
249
|
+
const body = new ReadableStream({
|
|
250
|
+
pull: async (controller) => {
|
|
251
|
+
try {
|
|
252
|
+
for (;;) {
|
|
253
|
+
const chunk = pending.shift();
|
|
254
|
+
if (chunk !== undefined) {
|
|
255
|
+
controller.enqueue(chunk);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (state.complete) {
|
|
259
|
+
await dispose();
|
|
260
|
+
controller.close();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const more = await advance();
|
|
264
|
+
// `advance` may have completed the response, even at end of stream.
|
|
265
|
+
if (!more && !finished())
|
|
266
|
+
throw refusedResponse();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
await dispose();
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
cancel: dispose,
|
|
275
|
+
});
|
|
276
|
+
try {
|
|
277
|
+
return new Response(body, { status, headers });
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
await dispose();
|
|
281
|
+
throw refusedResponse();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
export function createPublicHttp(options) {
|
|
285
|
+
const policy = createPublicEgressPolicy(options.networkPolicy);
|
|
286
|
+
return {
|
|
287
|
+
send: async (request) => {
|
|
288
|
+
const to = destination(request.url);
|
|
289
|
+
let addresses;
|
|
290
|
+
try {
|
|
291
|
+
policy.destination(request.url);
|
|
292
|
+
addresses = await resolvePublicAddresses(to.hostname, options.resolve, request.signal);
|
|
293
|
+
policy.addresses(addresses);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (error instanceof VeilError && error.code === "denied")
|
|
297
|
+
throw new PublicFetchError("denied", "address");
|
|
298
|
+
throw new PublicFetchError(request.signal.aborted ? "cancelled" : "unavailable", "dns");
|
|
299
|
+
}
|
|
300
|
+
const socket = await connectChecked(options.connect, addresses, to, request.signal);
|
|
301
|
+
return await exchange(socket, request);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** Anonymous GET keeps its existing two-header authority. */
|
|
306
|
+
export function createPublicFetch(options) {
|
|
307
|
+
const http = createPublicHttp({
|
|
308
|
+
...options,
|
|
309
|
+
networkPolicy: {
|
|
310
|
+
...options.networkPolicy,
|
|
311
|
+
// The anonymous public-web contract includes ordinary HTTP. Extra ports remain host-only.
|
|
312
|
+
extraPorts: [80, ...(options.networkPolicy?.extraPorts ?? [])],
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
return {
|
|
316
|
+
get: async (request) => {
|
|
317
|
+
let headers;
|
|
318
|
+
try {
|
|
319
|
+
headers = new Headers({
|
|
320
|
+
Accept: request.accept,
|
|
321
|
+
"User-Agent": request.userAgent,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
throw new PublicFetchError("invalid_input", "address");
|
|
326
|
+
}
|
|
327
|
+
return http.send({
|
|
328
|
+
url: request.url,
|
|
329
|
+
method: "GET",
|
|
330
|
+
headers,
|
|
331
|
+
body: new Uint8Array(),
|
|
332
|
+
signal: request.signal,
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The production door: the host's socket primitive plus its `fetch`, used only
|
|
339
|
+
* for the DNS-over-HTTPS lookup of the one resolution each call makes.
|
|
340
|
+
*/
|
|
341
|
+
export function configuredPublicFetch(options) {
|
|
342
|
+
return createPublicFetch({
|
|
343
|
+
connect: options.connect,
|
|
344
|
+
resolve: createDnsOverHttpsResolver(options.fetch),
|
|
345
|
+
networkPolicy: options.networkPolicy ?? {},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type EventRequest, type EventVerification, type EventVerifier } from "./event-verifiers.js";
|
|
2
|
+
/**
|
|
3
|
+
* One event binding's secret. Rotating bumps `secretGeneration`, which derives
|
|
4
|
+
* a new, unrelated secret; the previous one stops verifying.
|
|
5
|
+
*/
|
|
6
|
+
export type EventBindingAddress = Readonly<{
|
|
7
|
+
teamId: string;
|
|
8
|
+
bindingId: string;
|
|
9
|
+
secretGeneration: number;
|
|
10
|
+
}>;
|
|
11
|
+
/** A Worker-secret-backed custodian. Per-binding bytes are derived on demand and never enter TeamDO. */
|
|
12
|
+
export declare function createWebhookTriggerSecrets(masterSecret: string): {
|
|
13
|
+
/** Show this only on create or rotate. It is never part of a projection. */
|
|
14
|
+
reveal: (input: EventBindingAddress) => Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Verify one inbound request against the binding's current generation. A
|
|
17
|
+
* signature or token made under any earlier generation is `bad_signature`.
|
|
18
|
+
*/
|
|
19
|
+
verify(input: EventRequest & Readonly<{
|
|
20
|
+
verifier: EventVerifier;
|
|
21
|
+
address: EventBindingAddress;
|
|
22
|
+
}>): Promise<EventVerification>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { verifyFungiV1, verifyGithub, verifySecretUrl, verifyStandardWebhooks, } from "./event-verifiers.js";
|
|
3
|
+
const secretSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/u);
|
|
4
|
+
const addressSchema = z.object({
|
|
5
|
+
teamId: z.string().min(1),
|
|
6
|
+
bindingId: z.string().min(1),
|
|
7
|
+
secretGeneration: z.number().int().nonnegative(),
|
|
8
|
+
});
|
|
9
|
+
/** A Worker-secret-backed custodian. Per-binding bytes are derived on demand and never enter TeamDO. */
|
|
10
|
+
export function createWebhookTriggerSecrets(masterSecret) {
|
|
11
|
+
const decoded = secretSchema.safeParse(masterSecret);
|
|
12
|
+
// Never echo the master secret, even through a parse error.
|
|
13
|
+
if (!decoded.success)
|
|
14
|
+
throw new Error("Invalid webhook custody key");
|
|
15
|
+
const masterBytes = Uint8Array.from(Buffer.from(decoded.data, "base64url"));
|
|
16
|
+
if (masterBytes.length !== 32)
|
|
17
|
+
throw new Error("Invalid webhook custody key");
|
|
18
|
+
const master = crypto.subtle.importKey("raw", masterBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
19
|
+
const reveal = async (input) => {
|
|
20
|
+
const address = addressSchema.safeParse(input);
|
|
21
|
+
if (!address.success)
|
|
22
|
+
throw new Error("Invalid event binding address");
|
|
23
|
+
const key = await master;
|
|
24
|
+
const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(JSON.stringify([
|
|
25
|
+
"fungi-event-binding-v1",
|
|
26
|
+
address.data.teamId,
|
|
27
|
+
address.data.bindingId,
|
|
28
|
+
address.data.secretGeneration,
|
|
29
|
+
]))));
|
|
30
|
+
return Buffer.from(bytes).toString("base64url");
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
/** Show this only on create or rotate. It is never part of a projection. */
|
|
34
|
+
reveal,
|
|
35
|
+
/**
|
|
36
|
+
* Verify one inbound request against the binding's current generation. A
|
|
37
|
+
* signature or token made under any earlier generation is `bad_signature`.
|
|
38
|
+
*/
|
|
39
|
+
async verify(input) {
|
|
40
|
+
const current = await reveal(input.address);
|
|
41
|
+
switch (input.verifier) {
|
|
42
|
+
case "fungi-v1":
|
|
43
|
+
return verifyFungiV1(current, input);
|
|
44
|
+
case "github":
|
|
45
|
+
return verifyGithub(current, input);
|
|
46
|
+
case "secret-url":
|
|
47
|
+
return verifySecretUrl(current, input);
|
|
48
|
+
case "standard-webhooks":
|
|
49
|
+
return verifyStandardWebhooks(current, input);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-only custody for the credential a Team gives one Agent's Vishnu world
|
|
3
|
+
* seat. The token enters once through `seal`; nothing returns it. `send`
|
|
4
|
+
* attaches it at final egress, so the Team, the Agent and every projection
|
|
5
|
+
* hold only the private locator and the public endpoint.
|
|
6
|
+
*/
|
|
7
|
+
export type WorldSeatAccess = Readonly<{
|
|
8
|
+
/** Private custody id chosen by the Team owner; never a public field. */
|
|
9
|
+
locator: string;
|
|
10
|
+
/** The seat's MCP endpoint, already accepted by `worldSeatEndpoint`. */
|
|
11
|
+
endpoint: string;
|
|
12
|
+
}>;
|
|
13
|
+
export type WorldSeatCustodian = Readonly<{
|
|
14
|
+
seal: (input: Readonly<{
|
|
15
|
+
teamId: string;
|
|
16
|
+
locator: string;
|
|
17
|
+
endpoint: string;
|
|
18
|
+
token: string;
|
|
19
|
+
}>, signal: AbortSignal) => Promise<void>;
|
|
20
|
+
send: (access: WorldSeatAccess, request: Request) => Promise<Response>;
|
|
21
|
+
remove: (access: Pick<WorldSeatAccess, "locator">, signal: AbortSignal) => Promise<"deleted" | "absent">;
|
|
22
|
+
}>;
|
|
23
|
+
/** Longest token a seat may seal; the connection input schema bounds it too. */
|
|
24
|
+
export declare const WORLD_SEAT_TOKEN_MAX = 512;
|
|
25
|
+
/**
|
|
26
|
+
* The one MCP endpoint a seat may name: HTTPS on a public host, no
|
|
27
|
+
* credentials, query or fragment, path exactly `/mcp`. Custody fetches it from
|
|
28
|
+
* inside Fungi's own network, so a private name would be an SSRF reach.
|
|
29
|
+
*
|
|
30
|
+
* `allowLocalDev` is the host's explicit local/dev configuration, never
|
|
31
|
+
* inferred from the URL: it additionally admits plain HTTP (or HTTPS) to a
|
|
32
|
+
* loopback host so a developer's machine can serve the world.
|
|
33
|
+
*/
|
|
34
|
+
export declare function worldSeatEndpoint(text: string, options?: Readonly<{
|
|
35
|
+
allowLocalDev?: boolean;
|
|
36
|
+
}>): string;
|
|
37
|
+
/** The seat's endpoint and nothing else; the caller names no other target. */
|
|
38
|
+
export declare function worldSeatTarget(access: WorldSeatAccess, request: Request): URL;
|
|
39
|
+
/** Copy only MCP-meaningful headers; the caller's authorization is dropped. */
|
|
40
|
+
export declare function worldSeatForwardedHeaders(request: Request): Headers;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { VeilError } from "./index.js";
|
|
2
|
+
import { assertPublicHostName } from "./model-key-network.js";
|
|
3
|
+
/** Longest token a seat may seal; the connection input schema bounds it too. */
|
|
4
|
+
export const WORLD_SEAT_TOKEN_MAX = 512;
|
|
5
|
+
/**
|
|
6
|
+
* The one MCP endpoint a seat may name: HTTPS on a public host, no
|
|
7
|
+
* credentials, query or fragment, path exactly `/mcp`. Custody fetches it from
|
|
8
|
+
* inside Fungi's own network, so a private name would be an SSRF reach.
|
|
9
|
+
*
|
|
10
|
+
* `allowLocalDev` is the host's explicit local/dev configuration, never
|
|
11
|
+
* inferred from the URL: it additionally admits plain HTTP (or HTTPS) to a
|
|
12
|
+
* loopback host so a developer's machine can serve the world.
|
|
13
|
+
*/
|
|
14
|
+
export function worldSeatEndpoint(text, options = {}) {
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new VeilError("invalid_input");
|
|
21
|
+
}
|
|
22
|
+
if (url.username || url.password || url.search || url.hash)
|
|
23
|
+
throw new VeilError("invalid_input");
|
|
24
|
+
if (url.pathname !== "/mcp")
|
|
25
|
+
throw new VeilError("invalid_input");
|
|
26
|
+
if (options.allowLocalDev === true && isLoopbackHost(url.hostname)) {
|
|
27
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
28
|
+
throw new VeilError("invalid_input");
|
|
29
|
+
return url.href;
|
|
30
|
+
}
|
|
31
|
+
if (url.protocol !== "https:")
|
|
32
|
+
throw new VeilError("invalid_input");
|
|
33
|
+
try {
|
|
34
|
+
assertPublicHostName(url.hostname);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new VeilError("invalid_input");
|
|
38
|
+
}
|
|
39
|
+
return url.href;
|
|
40
|
+
}
|
|
41
|
+
function isLoopbackHost(hostname) {
|
|
42
|
+
return (hostname === "localhost" ||
|
|
43
|
+
hostname === "[::1]" ||
|
|
44
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(hostname));
|
|
45
|
+
}
|
|
46
|
+
const forwardedHeaders = [
|
|
47
|
+
"accept",
|
|
48
|
+
"content-type",
|
|
49
|
+
"mcp-protocol-version",
|
|
50
|
+
"mcp-session-id",
|
|
51
|
+
"last-event-id",
|
|
52
|
+
];
|
|
53
|
+
/** Streamable HTTP uses exactly these three verbs; any other is refused. */
|
|
54
|
+
const worldSeatMethods = new Set([
|
|
55
|
+
"GET",
|
|
56
|
+
"POST",
|
|
57
|
+
"DELETE",
|
|
58
|
+
]);
|
|
59
|
+
/** The seat's endpoint and nothing else; the caller names no other target. */
|
|
60
|
+
export function worldSeatTarget(access, request) {
|
|
61
|
+
let target;
|
|
62
|
+
try {
|
|
63
|
+
target = new URL(request.url);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new VeilError("denied");
|
|
67
|
+
}
|
|
68
|
+
if (target.href !== new URL(access.endpoint).href ||
|
|
69
|
+
!worldSeatMethods.has(request.method))
|
|
70
|
+
throw new VeilError("denied");
|
|
71
|
+
return target;
|
|
72
|
+
}
|
|
73
|
+
/** Copy only MCP-meaningful headers; the caller's authorization is dropped. */
|
|
74
|
+
export function worldSeatForwardedHeaders(request) {
|
|
75
|
+
const headers = new Headers();
|
|
76
|
+
for (const name of forwardedHeaders) {
|
|
77
|
+
const value = request.headers.get(name);
|
|
78
|
+
if (value !== null)
|
|
79
|
+
headers.set(name, value);
|
|
80
|
+
}
|
|
81
|
+
return headers;
|
|
82
|
+
}
|