@fungi.computer/veil 0.0.0-stage → 0.1.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/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,284 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createPublicEgressPolicy } from "./public-egress-policy.js";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { VeilError } from "./index.js";
|
|
5
|
+
import { OpenApiError, } from "./openapi-contract.js";
|
|
6
|
+
import { createOpenApiSecrets } from "./openapi-secrets.js";
|
|
7
|
+
import { createPublicHttp } from "./public-fetch.js";
|
|
8
|
+
import { assertPublicHostName } from "./model-key-network.js";
|
|
9
|
+
import { readOpenApiBody } from "./openapi-http.js";
|
|
10
|
+
const credentialEncodings = (credential) => [
|
|
11
|
+
...new Set([
|
|
12
|
+
credential,
|
|
13
|
+
`Bearer ${credential}`,
|
|
14
|
+
JSON.stringify(credential).slice(1, -1),
|
|
15
|
+
encodeURIComponent(credential),
|
|
16
|
+
encodeURIComponent(credential).replace(/%[0-9A-F]{2}/gu, (part) => part.toLowerCase()),
|
|
17
|
+
Buffer.from(credential).toString("base64"),
|
|
18
|
+
Buffer.from(credential).toString("base64url"),
|
|
19
|
+
]),
|
|
20
|
+
];
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const bodyLimit = 256 * 1024;
|
|
23
|
+
const methods = new Set([
|
|
24
|
+
"GET",
|
|
25
|
+
"HEAD",
|
|
26
|
+
"OPTIONS",
|
|
27
|
+
"POST",
|
|
28
|
+
"PUT",
|
|
29
|
+
"PATCH",
|
|
30
|
+
"DELETE",
|
|
31
|
+
]);
|
|
32
|
+
const forbiddenHeader = (name) => [
|
|
33
|
+
"authorization",
|
|
34
|
+
"cookie",
|
|
35
|
+
"host",
|
|
36
|
+
"content-length",
|
|
37
|
+
"connection",
|
|
38
|
+
"transfer-encoding",
|
|
39
|
+
"accept-encoding",
|
|
40
|
+
"set-cookie",
|
|
41
|
+
"upgrade",
|
|
42
|
+
"te",
|
|
43
|
+
"trailer",
|
|
44
|
+
"keep-alive",
|
|
45
|
+
"expect",
|
|
46
|
+
"via",
|
|
47
|
+
"forwarded",
|
|
48
|
+
"x-real-ip",
|
|
49
|
+
"max-forwards",
|
|
50
|
+
"x-http-method-override",
|
|
51
|
+
"x-method-override",
|
|
52
|
+
"x-http-method",
|
|
53
|
+
].includes(name.toLowerCase()) ||
|
|
54
|
+
/^(proxy-|x-forwarded-|nango-|cf-)/iu.test(name);
|
|
55
|
+
function sanitize(cause) {
|
|
56
|
+
if (cause instanceof OpenApiError)
|
|
57
|
+
return cause;
|
|
58
|
+
if (cause instanceof VeilError) {
|
|
59
|
+
switch (cause.code) {
|
|
60
|
+
case "denied":
|
|
61
|
+
case "invalid_input":
|
|
62
|
+
case "invalid_response":
|
|
63
|
+
case "cancelled":
|
|
64
|
+
return new OpenApiError(cause.code);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return new OpenApiError("unavailable");
|
|
68
|
+
}
|
|
69
|
+
const io = (operation) => Effect.tryPromise({ try: operation, catch: sanitize });
|
|
70
|
+
const policy = (operation) => Effect.try({ try: operation, catch: sanitize });
|
|
71
|
+
const bounded = (operation) => operation.pipe(Effect.timeoutOrElse({
|
|
72
|
+
duration: "30 seconds",
|
|
73
|
+
orElse: () => Effect.fail(new OpenApiError("timeout")),
|
|
74
|
+
}));
|
|
75
|
+
/** Public HTTPS only. The Team supplies the pinned base, not the spec at call time. */
|
|
76
|
+
function base(access) {
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(access.baseUrl);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new OpenApiError("invalid_input");
|
|
83
|
+
}
|
|
84
|
+
if (!access.teamId ||
|
|
85
|
+
!access.connectorId ||
|
|
86
|
+
url.protocol !== "https:" ||
|
|
87
|
+
url.username ||
|
|
88
|
+
url.password ||
|
|
89
|
+
url.search ||
|
|
90
|
+
url.hash)
|
|
91
|
+
throw new OpenApiError("invalid_input");
|
|
92
|
+
try {
|
|
93
|
+
assertPublicHostName(url.hostname);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new OpenApiError("denied");
|
|
97
|
+
}
|
|
98
|
+
return url;
|
|
99
|
+
}
|
|
100
|
+
function target(access, request, declaredHeaders) {
|
|
101
|
+
const pinned = base(access);
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
const root = pinned.pathname.replace(/\/+$/u, "");
|
|
104
|
+
if (url.origin !== pinned.origin ||
|
|
105
|
+
url.username ||
|
|
106
|
+
url.password ||
|
|
107
|
+
url.hash ||
|
|
108
|
+
!(url.pathname === root || url.pathname.startsWith(`${root}/`)) ||
|
|
109
|
+
!methods.has(request.method))
|
|
110
|
+
throw new OpenApiError("denied");
|
|
111
|
+
// URL normalizes literal dot segments. Encoded separators/dots must not reach an upstream decoder.
|
|
112
|
+
if (/%(?:2e|2f|5c|25)/iu.test(url.pathname))
|
|
113
|
+
throw new OpenApiError("denied");
|
|
114
|
+
const allowed = new Set([
|
|
115
|
+
"accept",
|
|
116
|
+
"content-type",
|
|
117
|
+
...declaredHeaders.map((name) => name.toLowerCase()),
|
|
118
|
+
]);
|
|
119
|
+
for (const [name] of request.headers) {
|
|
120
|
+
if (!allowed.has(name) ||
|
|
121
|
+
forbiddenHeader(name) ||
|
|
122
|
+
(access.auth.kind === "api_key_header" &&
|
|
123
|
+
name.toLowerCase() === access.auth.param.toLowerCase()))
|
|
124
|
+
throw new OpenApiError("denied");
|
|
125
|
+
}
|
|
126
|
+
if (access.auth.kind === "api_key_query" &&
|
|
127
|
+
url.searchParams.has(access.auth.param))
|
|
128
|
+
throw new OpenApiError("denied");
|
|
129
|
+
return url;
|
|
130
|
+
}
|
|
131
|
+
function credentialPolicy(access, credential) {
|
|
132
|
+
base(access);
|
|
133
|
+
if (access.auth.kind === "oauth") {
|
|
134
|
+
if (credential !== undefined)
|
|
135
|
+
throw new OpenApiError("invalid_input");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (access.auth.kind === "none") {
|
|
139
|
+
if (credential !== undefined)
|
|
140
|
+
throw new OpenApiError("invalid_input");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (access.auth.kind === "api_key_header" ||
|
|
144
|
+
access.auth.kind === "api_key_query") {
|
|
145
|
+
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/u.test(access.auth.param))
|
|
146
|
+
throw new OpenApiError("invalid_input");
|
|
147
|
+
if (access.auth.kind === "api_key_header" &&
|
|
148
|
+
forbiddenHeader(access.auth.param))
|
|
149
|
+
throw new OpenApiError("invalid_input");
|
|
150
|
+
}
|
|
151
|
+
if (credential !== undefined) {
|
|
152
|
+
if (credential.length === 0 ||
|
|
153
|
+
encoder.encode(credential).byteLength > 16 * 1024 ||
|
|
154
|
+
/[\r\n\0]/u.test(credential))
|
|
155
|
+
throw new OpenApiError("invalid_input");
|
|
156
|
+
if (access.auth.kind !== "api_key_query") {
|
|
157
|
+
try {
|
|
158
|
+
new Headers({ authorization: credential });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new OpenApiError("invalid_input");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function createOpenApiEffects(options) {
|
|
167
|
+
const http = createPublicHttp(options);
|
|
168
|
+
const networkPolicy = createPublicEgressPolicy(options.networkPolicy);
|
|
169
|
+
const secrets = options.custody === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: createOpenApiSecrets(options.custody.keys);
|
|
172
|
+
const store = options.custody?.store;
|
|
173
|
+
return {
|
|
174
|
+
seal: (access, credential) => bounded(Effect.gen(function* () {
|
|
175
|
+
yield* policy(() => {
|
|
176
|
+
credentialPolicy(access, credential);
|
|
177
|
+
networkPolicy.destination(base(access));
|
|
178
|
+
});
|
|
179
|
+
if (!secrets || !store)
|
|
180
|
+
return yield* Effect.fail(new OpenApiError("unavailable"));
|
|
181
|
+
const sealed = yield* io(() => secrets.seal(access, credential));
|
|
182
|
+
yield* io((signal) => store.write(access, sealed, signal));
|
|
183
|
+
})),
|
|
184
|
+
remove: (address) => bounded(io((signal) => {
|
|
185
|
+
if (!store)
|
|
186
|
+
throw new OpenApiError("unavailable");
|
|
187
|
+
return store.remove(address, signal);
|
|
188
|
+
})),
|
|
189
|
+
send: (access, request, declaredHeaders = []) => bounded(Effect.gen(function* () {
|
|
190
|
+
const url = yield* policy(() => target(access, request, declaredHeaders));
|
|
191
|
+
yield* policy(() => {
|
|
192
|
+
credentialPolicy(access);
|
|
193
|
+
networkPolicy.destination(url);
|
|
194
|
+
});
|
|
195
|
+
const oauth = access.auth.kind === "oauth" &&
|
|
196
|
+
options.oauthProviders !== undefined &&
|
|
197
|
+
Object.hasOwn(options.oauthProviders, access.auth.provider)
|
|
198
|
+
? options.oauthProviders[access.auth.provider]
|
|
199
|
+
: undefined;
|
|
200
|
+
if (access.auth.kind === "oauth") {
|
|
201
|
+
if (!oauth)
|
|
202
|
+
return yield* Effect.fail(new OpenApiError("oauth_not_configured"));
|
|
203
|
+
// Known provider host/path only. A spec cannot redirect OAuth custody to its server.
|
|
204
|
+
if (oauth.baseUrl !== access.baseUrl || !access.auth.locator)
|
|
205
|
+
return yield* Effect.fail(new OpenApiError("denied"));
|
|
206
|
+
}
|
|
207
|
+
const body = yield* io((signal) => readOpenApiBody(request.body, bodyLimit, signal));
|
|
208
|
+
let credential;
|
|
209
|
+
if (access.auth.kind !== "none" && access.auth.kind !== "oauth") {
|
|
210
|
+
if (!secrets || !store)
|
|
211
|
+
return yield* Effect.fail(new OpenApiError("unavailable"));
|
|
212
|
+
const sealed = yield* io((signal) => store.read(access, signal));
|
|
213
|
+
if (sealed === null)
|
|
214
|
+
return yield* Effect.fail(new OpenApiError("credentials_missing"));
|
|
215
|
+
credential = yield* io(() => secrets.open(access, sealed));
|
|
216
|
+
}
|
|
217
|
+
return yield* io(async (signal) => {
|
|
218
|
+
const headers = new Headers(request.headers);
|
|
219
|
+
if (credential !== undefined) {
|
|
220
|
+
switch (access.auth.kind) {
|
|
221
|
+
case "bearer":
|
|
222
|
+
headers.set("authorization", `Bearer ${credential}`);
|
|
223
|
+
break;
|
|
224
|
+
case "api_key_header":
|
|
225
|
+
headers.set(access.auth.param, credential);
|
|
226
|
+
break;
|
|
227
|
+
case "api_key_query":
|
|
228
|
+
url.searchParams.set(access.auth.param, credential);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
let response;
|
|
233
|
+
if (access.auth.kind === "oauth" && oauth) {
|
|
234
|
+
const init = {
|
|
235
|
+
method: request.method,
|
|
236
|
+
headers,
|
|
237
|
+
signal,
|
|
238
|
+
redirect: "manual",
|
|
239
|
+
};
|
|
240
|
+
if (!["GET", "HEAD"].includes(request.method))
|
|
241
|
+
init.body = body;
|
|
242
|
+
response = await oauth.send(access.teamId, access.auth.locator, new Request(url, init));
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
response = await http.send({
|
|
246
|
+
url,
|
|
247
|
+
method: request.method,
|
|
248
|
+
headers,
|
|
249
|
+
body,
|
|
250
|
+
signal,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const bytes = await readOpenApiBody(response.body, bodyLimit, signal);
|
|
254
|
+
// An untrusted server must not turn any common credential encoding into a model result.
|
|
255
|
+
const safeBody = credential === undefined
|
|
256
|
+
? bytes
|
|
257
|
+
: encoder.encode(credentialEncodings(credential)
|
|
258
|
+
.sort((a, b) => b.length - a.length)
|
|
259
|
+
.reduce((body, value) => body.replaceAll(value, "[REDACTED]"), new TextDecoder().decode(bytes)));
|
|
260
|
+
if (safeBody.byteLength > bodyLimit)
|
|
261
|
+
throw new OpenApiError("too_large");
|
|
262
|
+
if (!response.ok)
|
|
263
|
+
throw new OpenApiError("upstream_status", response.status);
|
|
264
|
+
const contentType = (response.headers.get("content-type") ?? "")
|
|
265
|
+
.split(";")[0]
|
|
266
|
+
?.trim()
|
|
267
|
+
.toLowerCase() ?? "";
|
|
268
|
+
if (credential !== undefined &&
|
|
269
|
+
credentialEncodings(credential).some((value) => contentType.includes(value.toLowerCase())))
|
|
270
|
+
throw new OpenApiError("invalid_response");
|
|
271
|
+
if (bytes.byteLength > 0 &&
|
|
272
|
+
!/^(application\/(?:[a-z0-9.+-]+\+)?json|text\/[a-z0-9.+-]+)(?:\s*;|$)/iu.test(contentType))
|
|
273
|
+
throw new OpenApiError("invalid_response");
|
|
274
|
+
return new Response([204, 205, 304].includes(response.status) ||
|
|
275
|
+
request.method === "HEAD"
|
|
276
|
+
? null
|
|
277
|
+
: safeBody, {
|
|
278
|
+
status: response.status,
|
|
279
|
+
headers: contentType === "" ? {} : { "content-type": contentType },
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
})),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { OpenApiError } from "./openapi-contract.js";
|
|
2
|
+
/** Read the wire once under a byte cap, including chunked bodies without Content-Length. */
|
|
3
|
+
export async function readOpenApiBody(body, limit, signal) {
|
|
4
|
+
if (body === null)
|
|
5
|
+
return new Uint8Array();
|
|
6
|
+
const reader = body.getReader();
|
|
7
|
+
const cancel = () => {
|
|
8
|
+
void reader.cancel();
|
|
9
|
+
};
|
|
10
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let size = 0;
|
|
13
|
+
try {
|
|
14
|
+
for (;;) {
|
|
15
|
+
signal.throwIfAborted();
|
|
16
|
+
const next = await reader.read();
|
|
17
|
+
if (next.done)
|
|
18
|
+
break;
|
|
19
|
+
size += next.value.byteLength;
|
|
20
|
+
if (size > limit)
|
|
21
|
+
throw new OpenApiError("too_large");
|
|
22
|
+
chunks.push(next.value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
signal.removeEventListener("abort", cancel);
|
|
27
|
+
await reader.cancel();
|
|
28
|
+
reader.releaseLock();
|
|
29
|
+
}
|
|
30
|
+
const bytes = new Uint8Array(size);
|
|
31
|
+
let offset = 0;
|
|
32
|
+
for (const chunk of chunks) {
|
|
33
|
+
bytes.set(chunk, offset);
|
|
34
|
+
offset += chunk.byteLength;
|
|
35
|
+
}
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type OpenApiCredentialKeyring, type OpenApiAccess } from "./openapi-contract.js";
|
|
2
|
+
/** WebCrypto owns authenticated encryption; the owner address and policy are its AAD. */
|
|
3
|
+
export declare function createOpenApiSecrets(configuration: OpenApiCredentialKeyring): {
|
|
4
|
+
seal(access: OpenApiAccess, credential: string): Promise<string>;
|
|
5
|
+
open(access: OpenApiAccess, sealed: string): Promise<string>;
|
|
6
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { OpenApiError, } from "./openapi-contract.js";
|
|
4
|
+
const keySchema = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/u)));
|
|
5
|
+
const keyIdSchema = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/u)));
|
|
6
|
+
const envelopeSchema = Schema.Struct({
|
|
7
|
+
version: Schema.Literal(1),
|
|
8
|
+
keyId: keyIdSchema,
|
|
9
|
+
iv: Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{16}$/u))),
|
|
10
|
+
ciphertext: Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{22,22000}$/u))),
|
|
11
|
+
});
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
const binding = (access, keyId) => encoder.encode(JSON.stringify([
|
|
14
|
+
"fungi.veil.openapi.v1",
|
|
15
|
+
keyId,
|
|
16
|
+
access.teamId,
|
|
17
|
+
access.connectorId,
|
|
18
|
+
access.baseUrl,
|
|
19
|
+
access.auth.kind,
|
|
20
|
+
access.auth.kind === "api_key_header" ||
|
|
21
|
+
access.auth.kind === "api_key_query"
|
|
22
|
+
? access.auth.param
|
|
23
|
+
: null,
|
|
24
|
+
]));
|
|
25
|
+
/** WebCrypto owns authenticated encryption; the owner address and policy are its AAD. */
|
|
26
|
+
export function createOpenApiSecrets(configuration) {
|
|
27
|
+
const keys = new Map();
|
|
28
|
+
let activeKeyId;
|
|
29
|
+
try {
|
|
30
|
+
activeKeyId = Schema.decodeUnknownSync(keyIdSchema)(configuration.activeKeyId);
|
|
31
|
+
for (const [id, encoded] of Object.entries(configuration.keys)) {
|
|
32
|
+
Schema.decodeUnknownSync(keyIdSchema)(id);
|
|
33
|
+
const parsed = Schema.decodeUnknownSync(keySchema)(encoded);
|
|
34
|
+
const bytes = Uint8Array.from(Buffer.from(parsed, "base64url"));
|
|
35
|
+
if (bytes.length !== 32)
|
|
36
|
+
throw new OpenApiError("unavailable");
|
|
37
|
+
keys.set(id, crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [
|
|
38
|
+
"encrypt",
|
|
39
|
+
"decrypt",
|
|
40
|
+
]));
|
|
41
|
+
}
|
|
42
|
+
if (!keys.has(activeKeyId))
|
|
43
|
+
throw new OpenApiError("unavailable");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new OpenApiError("unavailable");
|
|
47
|
+
}
|
|
48
|
+
const key = (id) => {
|
|
49
|
+
const found = keys.get(id);
|
|
50
|
+
if (!found)
|
|
51
|
+
throw new OpenApiError("denied");
|
|
52
|
+
return found;
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
async seal(access, credential) {
|
|
56
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
57
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: binding(access, activeKeyId) }, await key(activeKeyId), encoder.encode(credential));
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
version: 1,
|
|
60
|
+
keyId: activeKeyId,
|
|
61
|
+
iv: Buffer.from(iv).toString("base64url"),
|
|
62
|
+
ciphertext: Buffer.from(ciphertext).toString("base64url"),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
async open(access, sealed) {
|
|
66
|
+
try {
|
|
67
|
+
const envelope = Schema.decodeUnknownSync(Schema.fromJsonString(envelopeSchema))(sealed);
|
|
68
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
69
|
+
name: "AES-GCM",
|
|
70
|
+
iv: Uint8Array.from(Buffer.from(envelope.iv, "base64url")),
|
|
71
|
+
additionalData: binding(access, envelope.keyId),
|
|
72
|
+
}, await key(envelope.keyId), Uint8Array.from(Buffer.from(envelope.ciphertext, "base64url")));
|
|
73
|
+
return new TextDecoder().decode(plaintext);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new OpenApiError("denied");
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { OpenApiError, type OpenApiDoor, type OpenApiDoorOptions } from "./openapi-contract.js";
|
|
2
|
+
export { OpenApiError };
|
|
3
|
+
export type { PublicEgressPolicy } from "./public-egress-policy.js";
|
|
4
|
+
export type { OpenApiAccess, OpenApiAuth, OpenApiCredentialAddress, OpenApiCredentialKeyring, OpenApiCredentialStore, OpenApiDoor, OpenApiDoorOptions, OpenApiOAuthProvider, OpenApiRefusal, } from "./openapi-contract.js";
|
|
5
|
+
export declare function createOpenApiDoor(options: OpenApiDoorOptions): OpenApiDoor;
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Exit from "effect/Exit";
|
|
3
|
+
import * as Cause from "effect/Cause";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
import { OpenApiError, } from "./openapi-contract.js";
|
|
6
|
+
import { createOpenApiEffects } from "./openapi-effect.js";
|
|
7
|
+
export { OpenApiError };
|
|
8
|
+
/** The one Promise facade; cancellation interrupts the same Effect-owned operation. */
|
|
9
|
+
function run(effect, signal) {
|
|
10
|
+
return Effect.runPromiseExit(effect, { signal }).then((exit) => {
|
|
11
|
+
if (Exit.isSuccess(exit))
|
|
12
|
+
return exit.value;
|
|
13
|
+
throw (Option.getOrUndefined(Cause.findErrorOption(exit.cause)) ??
|
|
14
|
+
new OpenApiError(Cause.hasInterrupts(exit.cause) ? "cancelled" : "unavailable"));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function createOpenApiDoor(options) {
|
|
18
|
+
const effects = createOpenApiEffects(options);
|
|
19
|
+
return {
|
|
20
|
+
seal: (access, credential, signal) => run(effects.seal(access, credential), signal),
|
|
21
|
+
send: (access, request, declaredHeaders) => run(effects.send(access, request, declaredHeaders), request.signal),
|
|
22
|
+
remove: (address, signal) => run(effects.remove(address), signal),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Inventory structure only; Veil owns CIDR/domain matching and public-unicast admission. */
|
|
3
|
+
declare const ownedInfrastructureSchema: z.ZodObject<{
|
|
4
|
+
version: z.ZodLiteral<1>;
|
|
5
|
+
publicAddressAdmission: z.ZodLiteral<"@fungi.computer/veil/public-fetch">;
|
|
6
|
+
deniedCidrs: z.ZodArray<z.ZodString>;
|
|
7
|
+
deniedHostSuffixes: z.ZodArray<z.ZodString>;
|
|
8
|
+
}, z.core.$strict>;
|
|
9
|
+
/** CTO-approved host inventory. Never populate it from a Connector or OpenAPI document. */
|
|
10
|
+
export type OwnedInfrastructure = z.infer<typeof ownedInfrastructureSchema>;
|
|
11
|
+
/** Decode an inventory at the file or binding boundary; missing/empty data refuses. */
|
|
12
|
+
export declare function parseOwnedInfrastructure(input: unknown): OwnedInfrastructure;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { validatePublicEgressPolicyEntries } from "./public-egress-policy.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/** Inventory structure only; Veil owns CIDR/domain matching and public-unicast admission. */
|
|
4
|
+
const ownedInfrastructureSchema = z
|
|
5
|
+
.strictObject({
|
|
6
|
+
version: z.literal(1),
|
|
7
|
+
publicAddressAdmission: z.literal("@fungi.computer/veil/public-fetch"),
|
|
8
|
+
deniedCidrs: z.array(z.string().min(1).max(128)).min(1).max(256),
|
|
9
|
+
deniedHostSuffixes: z.array(z.string().min(1).max(253)).min(1).max(256),
|
|
10
|
+
})
|
|
11
|
+
.refine(validatePublicEgressPolicyEntries, {
|
|
12
|
+
message: "Owned infrastructure entries must be canonical, distinct CIDRs and domain suffixes",
|
|
13
|
+
});
|
|
14
|
+
/** Decode an inventory at the file or binding boundary; missing/empty data refuses. */
|
|
15
|
+
export function parseOwnedInfrastructure(input) {
|
|
16
|
+
return ownedInfrastructureSchema.parse(input);
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider egress, `veil.fetch` for Fungi's own platform keys (V1b). The Hub
|
|
3
|
+
* names a route and hands over a request that carries no credential; Veil
|
|
4
|
+
* checks the destination against that route's allowed origin and path, then
|
|
5
|
+
* attaches the platform key itself. The key value never crosses back: not in a
|
|
6
|
+
* returned value, not in a thrown error (only fixed `VeilError` codes), and
|
|
7
|
+
* not in anything this module logs (it logs nothing).
|
|
8
|
+
*
|
|
9
|
+
* Routes:
|
|
10
|
+
* - `camel`: Camel's hosted stream, `Authorization: Bearer`.
|
|
11
|
+
* - `ai-gateway`: Cloudflare AI Gateway's OpenAI-compatible route, with the
|
|
12
|
+
* gateway token as `cf-aig-authorization` and no `Authorization`.
|
|
13
|
+
* - `parallel`: Parallel Search, directly or through the AI Gateway's
|
|
14
|
+
* `/parallel` route, with `x-api-key`.
|
|
15
|
+
* - `ai-gateway-logs`: Cloudflare's API log read used for usage
|
|
16
|
+
* reconciliation, `Authorization: Bearer` with the gateway token.
|
|
17
|
+
*/
|
|
18
|
+
/** Private Worker bindings read only here; never Team storage or responses. */
|
|
19
|
+
export type ProviderKeyBindings = Readonly<{
|
|
20
|
+
CAMEL_API_KEY?: string;
|
|
21
|
+
/** Cloudflare API token; authorizes AI Gateway calls and log reads. */
|
|
22
|
+
AI_GATEWAY_AUTH_TOKEN?: string;
|
|
23
|
+
PARALLEL_API_KEY?: string;
|
|
24
|
+
/** Cloudflare account that owns the gateway; routes are scoped to it. */
|
|
25
|
+
AI_GATEWAY_ACCOUNT_ID?: string;
|
|
26
|
+
AI_GATEWAY_ID?: string;
|
|
27
|
+
}>;
|
|
28
|
+
export type ProviderRoute = "camel" | "ai-gateway" | "parallel" | "ai-gateway-logs";
|
|
29
|
+
/**
|
|
30
|
+
* The provider-key door. `fetch` is the transport the authorized request is
|
|
31
|
+
* finally sent on (a test double, or the Hub's telemetry-wrapped fetch).
|
|
32
|
+
*/
|
|
33
|
+
export declare function createProviderEgress(env: ProviderKeyBindings, options?: Readonly<{
|
|
34
|
+
fetch?: typeof globalThis.fetch;
|
|
35
|
+
}>): {
|
|
36
|
+
/** Whether this route's credential is configured; reveals no value. */
|
|
37
|
+
credentialed(route: ProviderRoute): boolean;
|
|
38
|
+
/** The route's public base URL, or `null` while it is not configured. */
|
|
39
|
+
baseUrl(route: ProviderRoute): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Send a credential-free request to a route. Refuses (`denied`) any
|
|
42
|
+
* destination outside the route and (`unavailable`) a route with no
|
|
43
|
+
* credential. Redirects are never followed, so a redirect cannot carry
|
|
44
|
+
* the key to another origin (workerd has no `error` mode; `manual` hands
|
|
45
|
+
* the 3xx back instead).
|
|
46
|
+
*/
|
|
47
|
+
fetch(route: ProviderRoute, input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
48
|
+
};
|
|
49
|
+
export type ProviderEgress = ReturnType<typeof createProviderEgress>;
|
|
50
|
+
/** A `fetch` bound to one route, for consumers that take a plain fetch. */
|
|
51
|
+
export declare function providerFetch(egress: ProviderEgress, route: ProviderRoute): typeof globalThis.fetch;
|