@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,107 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { VeilError } from "./index.js";
|
|
3
|
+
import { assertPublicResolution, createDnsOverHttpsResolver, } from "./model-key-network.js";
|
|
4
|
+
import { modelKeyProviders, } from "./model-keys.js";
|
|
5
|
+
const grantClaimsSchema = Schema.Struct({
|
|
6
|
+
v: Schema.Literal(1),
|
|
7
|
+
teamId: Schema.NonEmptyString,
|
|
8
|
+
keyId: Schema.NonEmptyString,
|
|
9
|
+
locator: Schema.NonEmptyString,
|
|
10
|
+
provider: Schema.Literals(modelKeyProviders),
|
|
11
|
+
baseUrl: Schema.NonEmptyString,
|
|
12
|
+
/** The personal key's owner, re-checked for membership at each egress. */
|
|
13
|
+
ownerUserId: Schema.NullOr(Schema.NonEmptyString),
|
|
14
|
+
expiresAt: Schema.Number,
|
|
15
|
+
});
|
|
16
|
+
const PREFIX = "mkg1";
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
const base64Url = (bytes) => btoa(String.fromCharCode(...bytes))
|
|
19
|
+
.replaceAll("+", "-")
|
|
20
|
+
.replaceAll("/", "_")
|
|
21
|
+
.replace(/=+$/u, "");
|
|
22
|
+
const fromBase64Url = (text) => {
|
|
23
|
+
const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
|
|
24
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
25
|
+
};
|
|
26
|
+
/** Grants are bound to exactly one Team and runner as authenticated data. */
|
|
27
|
+
const binding = (teamId, subject) => encoder.encode(`${PREFIX}\n${teamId}\n${subject.kind}\n${subject.id}`);
|
|
28
|
+
/** Issue and open grants under a key only the custodian's credential derives. */
|
|
29
|
+
export function createModelKeyGrants(options) {
|
|
30
|
+
if (options.custodySecret.length === 0)
|
|
31
|
+
throw new VeilError("unavailable");
|
|
32
|
+
const now = options.now ?? Date.now;
|
|
33
|
+
const key = (async () => {
|
|
34
|
+
const material = await crypto.subtle.importKey("raw", encoder.encode(options.custodySecret), "HKDF", false, ["deriveKey"]);
|
|
35
|
+
return crypto.subtle.deriveKey({
|
|
36
|
+
name: "HKDF",
|
|
37
|
+
hash: "SHA-256",
|
|
38
|
+
salt: encoder.encode("fungi.veil.model-key-grant"),
|
|
39
|
+
info: encoder.encode(PREFIX),
|
|
40
|
+
}, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
41
|
+
})();
|
|
42
|
+
return {
|
|
43
|
+
async issue(subject, claims) {
|
|
44
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
45
|
+
const sealed = await crypto.subtle.encrypt({
|
|
46
|
+
name: "AES-GCM",
|
|
47
|
+
iv,
|
|
48
|
+
additionalData: binding(claims.teamId, subject),
|
|
49
|
+
}, await key, encoder.encode(JSON.stringify({ v: 1, ...claims })));
|
|
50
|
+
return `${PREFIX}.${base64Url(iv)}.${base64Url(new Uint8Array(sealed))}`;
|
|
51
|
+
},
|
|
52
|
+
/** The custodian's check: authentic, for this Team and runner, unexpired. */
|
|
53
|
+
async open(grant, expected) {
|
|
54
|
+
const [prefix, iv, sealed, extra] = grant.split(".");
|
|
55
|
+
if (prefix !== PREFIX ||
|
|
56
|
+
iv === undefined ||
|
|
57
|
+
sealed === undefined ||
|
|
58
|
+
extra !== undefined)
|
|
59
|
+
throw new VeilError("denied");
|
|
60
|
+
let claims;
|
|
61
|
+
try {
|
|
62
|
+
const opened = await crypto.subtle.decrypt({
|
|
63
|
+
name: "AES-GCM",
|
|
64
|
+
iv: fromBase64Url(iv),
|
|
65
|
+
additionalData: binding(expected.teamId, expected.subject),
|
|
66
|
+
}, await key, fromBase64Url(sealed));
|
|
67
|
+
claims = Schema.decodeUnknownSync(Schema.fromJsonString(grantClaimsSchema))(new TextDecoder().decode(opened));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new VeilError("denied");
|
|
71
|
+
}
|
|
72
|
+
if (claims.teamId !== expected.teamId)
|
|
73
|
+
throw new VeilError("denied");
|
|
74
|
+
if (claims.expiresAt <= now())
|
|
75
|
+
throw new VeilError("expired");
|
|
76
|
+
return claims;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Final model egress for a runner holding a grant. The custodian opens the
|
|
82
|
+
* grant, re-checks the personal key owner's membership and a Team-named base
|
|
83
|
+
* URL's public resolution, then attaches the sealed key upstream.
|
|
84
|
+
*/
|
|
85
|
+
export function createModelKeyEgress(options) {
|
|
86
|
+
const resolveHost = options.resolveHost ?? createDnsOverHttpsResolver();
|
|
87
|
+
return {
|
|
88
|
+
async send(grant, expected, request) {
|
|
89
|
+
const claims = await options.grants.open(grant, expected);
|
|
90
|
+
if (claims.provider !== expected.provider)
|
|
91
|
+
throw new VeilError("denied");
|
|
92
|
+
// A departed member's key never pays, even before removal cleanup lands.
|
|
93
|
+
if (claims.ownerUserId !== null) {
|
|
94
|
+
const member = await options.ownerIsMember(claims.teamId, claims.ownerUserId);
|
|
95
|
+
if (member !== "allowed")
|
|
96
|
+
throw new VeilError(member === "denied" ? "denied" : "unavailable");
|
|
97
|
+
}
|
|
98
|
+
if (claims.provider === "openai-compatible")
|
|
99
|
+
await assertPublicResolution(new URL(claims.baseUrl).hostname, resolveHost, request.signal);
|
|
100
|
+
return await options.custodian().send({
|
|
101
|
+
locator: claims.locator,
|
|
102
|
+
provider: claims.provider,
|
|
103
|
+
baseUrl: claims.baseUrl,
|
|
104
|
+
}, request);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a Team-supplied OpenAI-compatible base URL may point. Fungi's
|
|
3
|
+
* custodian fetches it from inside Fungi's own network, so every name must
|
|
4
|
+
* resolve only to public unicast addresses, checked at intake and again
|
|
5
|
+
* before each egress (SSRF defence; see
|
|
6
|
+
* wiki/3-resources/decisions/adr-byok-model-keys-v1.md).
|
|
7
|
+
*/
|
|
8
|
+
/** Resolves a hostname to its A and AAAA addresses. */
|
|
9
|
+
export type HostResolver = (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
|
|
10
|
+
/** Whether an address literal is public unicast. Non-literals return null. */
|
|
11
|
+
export declare function publicAddress(address: string): boolean | null;
|
|
12
|
+
/** Static checks on a base URL's host, before any resolution. */
|
|
13
|
+
export declare function assertPublicHostName(hostname: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* The one canonical text for a literal address, or null for a name. A caller
|
|
16
|
+
* that connects to a literal passes this form so the socket layer reads exactly
|
|
17
|
+
* the address that was classified.
|
|
18
|
+
*/
|
|
19
|
+
export declare function canonicalAddress(address: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve once and return the checked addresses in canonical form. Every answer
|
|
22
|
+
* must be public: one inward answer refuses the name, and no answer is
|
|
23
|
+
* unavailable. A literal address is its own single answer.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolvePublicAddresses(hostname: string, resolve: HostResolver, signal: AbortSignal): Promise<readonly string[]>;
|
|
26
|
+
/** Every address the name resolves to must be public; no answer is unavailable. */
|
|
27
|
+
export declare function assertPublicResolution(hostname: string, resolve: HostResolver, signal: AbortSignal): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve through DNS-over-HTTPS JSON (Cloudflare's public resolver), which a
|
|
30
|
+
* Worker can reach where it has no socket DNS. CNAME chains are followed by the
|
|
31
|
+
* resolver; only A (1) and AAAA (28) data are returned.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createDnsOverHttpsResolver(fetcher?: typeof fetch, endpoint?: string): HostResolver;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { VeilError } from "./index.js";
|
|
3
|
+
const deniedSuffixes = [
|
|
4
|
+
".localhost",
|
|
5
|
+
".local",
|
|
6
|
+
".internal",
|
|
7
|
+
".lan",
|
|
8
|
+
".home.arpa",
|
|
9
|
+
".intranet",
|
|
10
|
+
".corp",
|
|
11
|
+
];
|
|
12
|
+
function ipv4Octets(value) {
|
|
13
|
+
const parts = value.split(".");
|
|
14
|
+
if (parts.length !== 4)
|
|
15
|
+
return null;
|
|
16
|
+
const octets = parts.map((part) => /^(0|[1-9][0-9]{0,2})$/u.test(part) ? Number(part) : Number.NaN);
|
|
17
|
+
return octets.every((octet) => octet >= 0 && octet <= 255) ? octets : null;
|
|
18
|
+
}
|
|
19
|
+
function publicIpv4([a = 0, b = 0, c = 0]) {
|
|
20
|
+
return !(a === 0 ||
|
|
21
|
+
a === 10 ||
|
|
22
|
+
a === 127 ||
|
|
23
|
+
a >= 224 ||
|
|
24
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
25
|
+
(a === 169 && b === 254) ||
|
|
26
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
27
|
+
(a === 192 && b === 0 && (c === 0 || c === 2)) ||
|
|
28
|
+
(a === 192 && b === 88 && c === 99) ||
|
|
29
|
+
(a === 192 && b === 168) ||
|
|
30
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
31
|
+
(a === 198 && b === 51 && c === 100) ||
|
|
32
|
+
(a === 203 && b === 0 && c === 113));
|
|
33
|
+
}
|
|
34
|
+
/** Expand an IPv6 literal to eight 16-bit groups, or null if it is not one. */
|
|
35
|
+
function ipv6Groups(value) {
|
|
36
|
+
let text = value.toLowerCase();
|
|
37
|
+
if (!/^[0-9a-f:.]+$/u.test(text) || !text.includes(":"))
|
|
38
|
+
return null;
|
|
39
|
+
const lastColon = text.lastIndexOf(":");
|
|
40
|
+
const suffix = text.slice(lastColon + 1);
|
|
41
|
+
if (suffix.includes(".")) {
|
|
42
|
+
// Rewrite a trailing dotted IPv4 as its two hex groups.
|
|
43
|
+
const octets = ipv4Octets(suffix);
|
|
44
|
+
if (octets === null)
|
|
45
|
+
return null;
|
|
46
|
+
const [a = 0, b = 0, c = 0, d = 0] = octets;
|
|
47
|
+
text = `${text.slice(0, lastColon + 1)}${(a * 256 + b).toString(16)}:${(c * 256 + d).toString(16)}`;
|
|
48
|
+
}
|
|
49
|
+
const halves = text.split("::");
|
|
50
|
+
if (halves.length > 2)
|
|
51
|
+
return null;
|
|
52
|
+
const read = (part) => part === undefined || part === "" ? [] : part.split(":");
|
|
53
|
+
const head = read(halves[0]);
|
|
54
|
+
const rest = read(halves[1]);
|
|
55
|
+
const groups = [...head, ...rest].map((group) => /^[0-9a-f]{1,4}$/u.test(group) ? Number.parseInt(group, 16) : Number.NaN);
|
|
56
|
+
if (groups.some(Number.isNaN))
|
|
57
|
+
return null;
|
|
58
|
+
if (halves.length === 1)
|
|
59
|
+
return groups.length === 8 ? groups : null;
|
|
60
|
+
if (groups.length >= 8)
|
|
61
|
+
return null;
|
|
62
|
+
return [
|
|
63
|
+
...groups.slice(0, head.length),
|
|
64
|
+
...Array.from({ length: 8 - groups.length }, () => 0),
|
|
65
|
+
...groups.slice(head.length),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
function publicIpv6(groups) {
|
|
69
|
+
const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = groups;
|
|
70
|
+
const embedded = [g6 >> 8, g6 & 255, g7 >> 8, g7 & 255];
|
|
71
|
+
// ::/96 holds the unspecified address, loopback and deprecated IPv4-compatible
|
|
72
|
+
// addresses; none is public unicast.
|
|
73
|
+
if (groups.slice(0, 6).every((group) => group === 0))
|
|
74
|
+
return false;
|
|
75
|
+
// IPv4-mapped ::ffff:a.b.c.d and NAT64 64:ff9b::a.b.c.d carry an IPv4 target.
|
|
76
|
+
if (groups.slice(0, 5).every((group) => group === 0) && g5 === 0xffff)
|
|
77
|
+
return publicIpv4(embedded);
|
|
78
|
+
if (g0 === 0x64 &&
|
|
79
|
+
g1 === 0xff9b &&
|
|
80
|
+
g2 === 0 &&
|
|
81
|
+
g3 === 0 &&
|
|
82
|
+
g4 === 0 &&
|
|
83
|
+
g5 === 0)
|
|
84
|
+
return publicIpv4(embedded);
|
|
85
|
+
// 6to4 2002:AABB:CCDD:: embeds an IPv4 target in its second and third groups.
|
|
86
|
+
if (g0 === 0x2002)
|
|
87
|
+
return publicIpv4([g1 >> 8, g1 & 255, g2 >> 8, g2 & 255]);
|
|
88
|
+
return !(
|
|
89
|
+
// Local-use NAT64 64:ff9b:1::/48 and Teredo 2001::/32 hide an IPv4 target.
|
|
90
|
+
(g0 === 0x64 && g1 === 0xff9b) ||
|
|
91
|
+
(g0 === 0x2001 && g1 === 0) ||
|
|
92
|
+
(g0 & 0xfe00) === 0xfc00 ||
|
|
93
|
+
(g0 & 0xffc0) === 0xfe80 ||
|
|
94
|
+
(g0 & 0xffc0) === 0xfec0 ||
|
|
95
|
+
(g0 === 0 &&
|
|
96
|
+
g1 === 0 &&
|
|
97
|
+
g2 === 0 &&
|
|
98
|
+
g3 === 0 &&
|
|
99
|
+
g4 === 0xffff &&
|
|
100
|
+
g5 === 0) ||
|
|
101
|
+
(g0 === 0x2001 && g1 === 2 && g2 === 0) ||
|
|
102
|
+
(g0 === 0x2001 && (g1 & 0xfff0) === 0x10) ||
|
|
103
|
+
(g0 & 0xff00) === 0xff00 ||
|
|
104
|
+
(g0 === 0x2001 && g1 === 0x0db8) ||
|
|
105
|
+
(g0 === 0x0100 && groups.slice(1, 4).every((group) => group === 0)));
|
|
106
|
+
}
|
|
107
|
+
/** Whether an address literal is public unicast. Non-literals return null. */
|
|
108
|
+
export function publicAddress(address) {
|
|
109
|
+
const bare = address.replace(/^\[|\]$/gu, "");
|
|
110
|
+
const v4 = ipv4Octets(bare);
|
|
111
|
+
if (v4 !== null)
|
|
112
|
+
return publicIpv4(v4);
|
|
113
|
+
const v6 = ipv6Groups(bare);
|
|
114
|
+
return v6 === null ? null : publicIpv6(v6);
|
|
115
|
+
}
|
|
116
|
+
/** Static checks on a base URL's host, before any resolution. */
|
|
117
|
+
export function assertPublicHostName(hostname) {
|
|
118
|
+
const host = hostname.toLowerCase().replace(/\.$/u, "");
|
|
119
|
+
const literal = publicAddress(host);
|
|
120
|
+
if (literal !== null) {
|
|
121
|
+
if (!literal)
|
|
122
|
+
throw new VeilError("denied");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (host === "localhost" ||
|
|
126
|
+
!host.includes(".") ||
|
|
127
|
+
deniedSuffixes.some((suffix) => host.endsWith(suffix)))
|
|
128
|
+
throw new VeilError("denied");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The one canonical text for a literal address, or null for a name. A caller
|
|
132
|
+
* that connects to a literal passes this form so the socket layer reads exactly
|
|
133
|
+
* the address that was classified.
|
|
134
|
+
*/
|
|
135
|
+
export function canonicalAddress(address) {
|
|
136
|
+
const bare = address.replace(/^\[|\]$/gu, "");
|
|
137
|
+
const v4 = ipv4Octets(bare);
|
|
138
|
+
if (v4 !== null)
|
|
139
|
+
return v4.join(".");
|
|
140
|
+
const v6 = ipv6Groups(bare);
|
|
141
|
+
return v6 === null ? null : v6.map((group) => group.toString(16)).join(":");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolve once and return the checked addresses in canonical form. Every answer
|
|
145
|
+
* must be public: one inward answer refuses the name, and no answer is
|
|
146
|
+
* unavailable. A literal address is its own single answer.
|
|
147
|
+
*/
|
|
148
|
+
export async function resolvePublicAddresses(hostname, resolve, signal) {
|
|
149
|
+
assertPublicHostName(hostname);
|
|
150
|
+
const literal = canonicalAddress(hostname.replace(/\.$/u, ""));
|
|
151
|
+
if (literal !== null)
|
|
152
|
+
return [literal];
|
|
153
|
+
let answers;
|
|
154
|
+
try {
|
|
155
|
+
answers = await resolve(hostname, signal);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new VeilError(signal.aborted ? "cancelled" : "unavailable");
|
|
159
|
+
}
|
|
160
|
+
if (answers.length === 0)
|
|
161
|
+
throw new VeilError("unavailable");
|
|
162
|
+
const addresses = new Set();
|
|
163
|
+
for (const answer of answers) {
|
|
164
|
+
const canonical = canonicalAddress(answer);
|
|
165
|
+
if (canonical === null || publicAddress(canonical) !== true)
|
|
166
|
+
throw new VeilError("denied");
|
|
167
|
+
addresses.add(canonical);
|
|
168
|
+
}
|
|
169
|
+
return [...addresses];
|
|
170
|
+
}
|
|
171
|
+
/** Every address the name resolves to must be public; no answer is unavailable. */
|
|
172
|
+
export async function assertPublicResolution(hostname, resolve, signal) {
|
|
173
|
+
await resolvePublicAddresses(hostname, resolve, signal);
|
|
174
|
+
}
|
|
175
|
+
const dohAnswerSchema = Schema.Struct({
|
|
176
|
+
Answer: Schema.optional(Schema.Array(Schema.Struct({ type: Schema.Number, data: Schema.String }))),
|
|
177
|
+
});
|
|
178
|
+
/**
|
|
179
|
+
* Resolve through DNS-over-HTTPS JSON (Cloudflare's public resolver), which a
|
|
180
|
+
* Worker can reach where it has no socket DNS. CNAME chains are followed by the
|
|
181
|
+
* resolver; only A (1) and AAAA (28) data are returned.
|
|
182
|
+
*/
|
|
183
|
+
export function createDnsOverHttpsResolver(fetcher = fetch, endpoint = "https://cloudflare-dns.com/dns-query") {
|
|
184
|
+
return async (hostname, signal) => {
|
|
185
|
+
const answers = await Promise.all(["A", "AAAA"].map(async (type) => {
|
|
186
|
+
const url = new URL(endpoint);
|
|
187
|
+
url.searchParams.set("name", hostname);
|
|
188
|
+
url.searchParams.set("type", type);
|
|
189
|
+
const response = await fetcher(url, {
|
|
190
|
+
headers: { accept: "application/dns-json" },
|
|
191
|
+
signal,
|
|
192
|
+
// Workers reject `redirect: "error"`; a 3xx is simply not `ok`.
|
|
193
|
+
redirect: "manual",
|
|
194
|
+
});
|
|
195
|
+
if (!response.ok)
|
|
196
|
+
throw new VeilError("unavailable");
|
|
197
|
+
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(dohAnswerSchema))(await response.text());
|
|
198
|
+
return (decoded.Answer ?? []).flatMap((entry) => entry.type === 1 || entry.type === 28 ? [entry.data] : []);
|
|
199
|
+
}));
|
|
200
|
+
return answers.flat();
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Providers a Team may bring its own API key for on hosted Fungi. */
|
|
2
|
+
export declare const modelKeyProviders: readonly ["anthropic", "openai", "openrouter", "openai-compatible"];
|
|
3
|
+
export type ModelKeyProvider = (typeof modelKeyProviders)[number];
|
|
4
|
+
export declare function isModelKeyProvider(value: string): value is ModelKeyProvider;
|
|
5
|
+
/**
|
|
6
|
+
* Canonical base URL for a sealed key. Only an OpenAI-compatible key names its
|
|
7
|
+
* own base: HTTPS without credentials, query or fragment, on a public host.
|
|
8
|
+
* Plain HTTP to a loopback host is accepted only with `allowLocalDev`.
|
|
9
|
+
* Resolution is checked separately with `assertPublicResolution`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function modelKeyBaseUrl(provider: ModelKeyProvider, configured?: string | null, options?: Readonly<{
|
|
12
|
+
allowLocalDev?: boolean;
|
|
13
|
+
}>): string;
|
|
14
|
+
/** Private to the Team owner and the custodian adapter; never a public field. */
|
|
15
|
+
export type ModelKeyAccess = Readonly<{
|
|
16
|
+
locator: string;
|
|
17
|
+
provider: ModelKeyProvider;
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Write-only custody for Team model API keys. The key enters once through
|
|
22
|
+
* `seal`; nothing returns it. `send` attaches it at final egress.
|
|
23
|
+
*/
|
|
24
|
+
export type ModelKeyCustodian = Readonly<{
|
|
25
|
+
seal: (input: Readonly<{
|
|
26
|
+
teamId: string;
|
|
27
|
+
/** Private custody id chosen by the owner, distinct from the public key id. */
|
|
28
|
+
locator: string;
|
|
29
|
+
provider: ModelKeyProvider;
|
|
30
|
+
apiKey: string;
|
|
31
|
+
}>, signal: AbortSignal) => Promise<void>;
|
|
32
|
+
send: (access: ModelKeyAccess, request: Request) => Promise<Response>;
|
|
33
|
+
remove: (access: Pick<ModelKeyAccess, "locator" | "provider">, signal: AbortSignal) => Promise<"deleted" | "absent">;
|
|
34
|
+
}>;
|
|
35
|
+
/** The exact supplier target, confined below the sealed key's base URL. */
|
|
36
|
+
export declare function modelKeyTarget(access: ModelKeyAccess, request: Request): URL;
|
|
37
|
+
/** Copy only supplier-meaningful headers; the caller's auth is dropped. */
|
|
38
|
+
export declare function modelKeyForwardedHeaders(request: Request): Headers;
|
|
39
|
+
/** A cheap authenticated read that proves a key works without spending tokens. */
|
|
40
|
+
export declare function modelKeyValidationRequest(access: Pick<ModelKeyAccess, "provider" | "baseUrl">, signal: AbortSignal): Request;
|
|
41
|
+
/** Read a validation response: only an auth refusal says the key itself is bad. */
|
|
42
|
+
export declare function modelKeyValidationResult(status: number): "valid" | "rejected" | "unavailable";
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { VeilError } from "./index.js";
|
|
2
|
+
import { assertPublicHostName, publicAddress } from "./model-key-network.js";
|
|
3
|
+
/** Providers a Team may bring its own API key for on hosted Fungi. */
|
|
4
|
+
export const modelKeyProviders = [
|
|
5
|
+
"anthropic",
|
|
6
|
+
"openai",
|
|
7
|
+
"openrouter",
|
|
8
|
+
"openai-compatible",
|
|
9
|
+
];
|
|
10
|
+
export function isModelKeyProvider(value) {
|
|
11
|
+
return modelKeyProviders.some((provider) => provider === value);
|
|
12
|
+
}
|
|
13
|
+
/** The supplier base each provider's Pi client targets. */
|
|
14
|
+
const providerBaseUrls = {
|
|
15
|
+
anthropic: "https://api.anthropic.com",
|
|
16
|
+
openai: "https://api.openai.com/v1",
|
|
17
|
+
openrouter: "https://openrouter.ai/api/v1",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Canonical base URL for a sealed key. Only an OpenAI-compatible key names its
|
|
21
|
+
* own base: HTTPS without credentials, query or fragment, on a public host.
|
|
22
|
+
* Plain HTTP to a loopback host is accepted only with `allowLocalDev`.
|
|
23
|
+
* Resolution is checked separately with `assertPublicResolution`.
|
|
24
|
+
*/
|
|
25
|
+
export function modelKeyBaseUrl(provider, configured, options = {}) {
|
|
26
|
+
if (provider !== "openai-compatible") {
|
|
27
|
+
if (configured !== undefined && configured !== null)
|
|
28
|
+
throw new VeilError("invalid_input");
|
|
29
|
+
return providerBaseUrls[provider];
|
|
30
|
+
}
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(configured ?? "");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new VeilError("invalid_input");
|
|
37
|
+
}
|
|
38
|
+
if (url.username || url.password || url.search || url.hash)
|
|
39
|
+
throw new VeilError("invalid_input");
|
|
40
|
+
const loopback = url.hostname === "localhost" ||
|
|
41
|
+
(publicAddress(url.hostname) === false &&
|
|
42
|
+
/^(127\.|\[::1\]$)/u.test(url.hostname));
|
|
43
|
+
if (options.allowLocalDev === true && loopback) {
|
|
44
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
45
|
+
throw new VeilError("invalid_input");
|
|
46
|
+
return url.href.replace(/\/+$/u, "");
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol !== "https:")
|
|
49
|
+
throw new VeilError("invalid_input");
|
|
50
|
+
try {
|
|
51
|
+
assertPublicHostName(url.hostname);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new VeilError("invalid_input");
|
|
55
|
+
}
|
|
56
|
+
return url.href.replace(/\/+$/u, "");
|
|
57
|
+
}
|
|
58
|
+
/** Request headers a supplier call may carry; credentials are never among them. */
|
|
59
|
+
const forwardedHeaders = [
|
|
60
|
+
"accept",
|
|
61
|
+
"content-type",
|
|
62
|
+
"anthropic-version",
|
|
63
|
+
"anthropic-beta",
|
|
64
|
+
"openai-beta",
|
|
65
|
+
];
|
|
66
|
+
/** The exact supplier target, confined below the sealed key's base URL. */
|
|
67
|
+
export function modelKeyTarget(access, request) {
|
|
68
|
+
const base = new URL(`${access.baseUrl}/`);
|
|
69
|
+
let target;
|
|
70
|
+
try {
|
|
71
|
+
target = new URL(request.url);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new VeilError("denied");
|
|
75
|
+
}
|
|
76
|
+
if (target.origin !== base.origin ||
|
|
77
|
+
!`${target.pathname}/`.startsWith(base.pathname) ||
|
|
78
|
+
target.username ||
|
|
79
|
+
target.password ||
|
|
80
|
+
target.hash ||
|
|
81
|
+
target.pathname.split("/").some((part) => part === "." || part === ".."))
|
|
82
|
+
throw new VeilError("denied");
|
|
83
|
+
if (!["GET", "POST"].includes(request.method))
|
|
84
|
+
throw new VeilError("denied");
|
|
85
|
+
return target;
|
|
86
|
+
}
|
|
87
|
+
/** Copy only supplier-meaningful headers; the caller's auth is dropped. */
|
|
88
|
+
export function modelKeyForwardedHeaders(request) {
|
|
89
|
+
const headers = new Headers();
|
|
90
|
+
for (const name of forwardedHeaders) {
|
|
91
|
+
const value = request.headers.get(name);
|
|
92
|
+
if (value !== null)
|
|
93
|
+
headers.set(name, value);
|
|
94
|
+
}
|
|
95
|
+
return headers;
|
|
96
|
+
}
|
|
97
|
+
/** A cheap authenticated read that proves a key works without spending tokens. */
|
|
98
|
+
export function modelKeyValidationRequest(access, signal) {
|
|
99
|
+
const path = access.provider === "openrouter" ? "/key" : "/models";
|
|
100
|
+
const url = access.provider === "anthropic"
|
|
101
|
+
? `${access.baseUrl}/v1/models?limit=1`
|
|
102
|
+
: `${access.baseUrl}${path}`;
|
|
103
|
+
const headers = new Headers({ accept: "application/json" });
|
|
104
|
+
if (access.provider === "anthropic")
|
|
105
|
+
headers.set("anthropic-version", "2023-06-01");
|
|
106
|
+
return new Request(url, { method: "GET", headers, signal });
|
|
107
|
+
}
|
|
108
|
+
/** Read a validation response: only an auth refusal says the key itself is bad. */
|
|
109
|
+
export function modelKeyValidationResult(status) {
|
|
110
|
+
if (status >= 200 && status < 300)
|
|
111
|
+
return "valid";
|
|
112
|
+
if (status === 401 || status === 403)
|
|
113
|
+
return "rejected";
|
|
114
|
+
return "unavailable";
|
|
115
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Shared Nango API-key transport policy for private credential custodians. */
|
|
2
|
+
export declare function createNangoCustodyHttp(options: Readonly<{
|
|
3
|
+
serverUrl: string;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
}>): {
|
|
6
|
+
importCredential(input: Readonly<{
|
|
7
|
+
integrationId: string;
|
|
8
|
+
locator: string;
|
|
9
|
+
teamId: string;
|
|
10
|
+
credential: string;
|
|
11
|
+
}>, signal: AbortSignal): Promise<void>;
|
|
12
|
+
removeConnection(locator: string, integrationId: string, signal: AbortSignal): Promise<"deleted" | "absent">;
|
|
13
|
+
call: (path: string, signal: AbortSignal, init: RequestInit & {
|
|
14
|
+
headers?: Headers;
|
|
15
|
+
}) => Promise<Response>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { VeilError } from "./index.js";
|
|
3
|
+
const importedSchema = Schema.Struct({ connection_id: Schema.NonEmptyString });
|
|
4
|
+
const deletedSchema = Schema.Struct({ success: Schema.Literal(true) });
|
|
5
|
+
const absentSchema = Schema.Struct({
|
|
6
|
+
error: Schema.Struct({ code: Schema.Literal("unknown_connection") }),
|
|
7
|
+
});
|
|
8
|
+
/** Shared Nango API-key transport policy for private credential custodians. */
|
|
9
|
+
export function createNangoCustodyHttp(options) {
|
|
10
|
+
let server;
|
|
11
|
+
try {
|
|
12
|
+
server = new URL(options.serverUrl);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new VeilError("invalid_input");
|
|
16
|
+
}
|
|
17
|
+
if (!options.apiKey ||
|
|
18
|
+
server.username ||
|
|
19
|
+
server.password ||
|
|
20
|
+
server.search ||
|
|
21
|
+
server.hash ||
|
|
22
|
+
server.pathname !== "/" ||
|
|
23
|
+
!(server.protocol === "https:" ||
|
|
24
|
+
(server.protocol === "http:" &&
|
|
25
|
+
["localhost", "127.0.0.1", "[::1]"].includes(server.hostname))))
|
|
26
|
+
throw new VeilError("invalid_input");
|
|
27
|
+
const call = async (path, signal, init) => {
|
|
28
|
+
const headers = init.headers ?? new Headers();
|
|
29
|
+
headers.set("Authorization", `Bearer ${options.apiKey}`);
|
|
30
|
+
try {
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
return await fetch(new URL(path, server), {
|
|
33
|
+
...init,
|
|
34
|
+
headers,
|
|
35
|
+
signal,
|
|
36
|
+
redirect: "manual",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new VeilError(signal.aborted ? "cancelled" : "unavailable");
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const text = async (response, signal) => {
|
|
44
|
+
try {
|
|
45
|
+
return await response.text();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new VeilError(signal.aborted ? "cancelled" : "unavailable");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const decode = (schema, raw) => {
|
|
52
|
+
try {
|
|
53
|
+
return Schema.decodeUnknownSync(Schema.fromJsonString(schema))(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new VeilError("invalid_response");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
async importCredential(input, signal) {
|
|
61
|
+
const response = await call("/connections", signal, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: new Headers({ "Content-Type": "application/json" }),
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
provider_config_key: input.integrationId,
|
|
66
|
+
connection_id: input.locator,
|
|
67
|
+
credentials: { type: "API_KEY", apiKey: input.credential },
|
|
68
|
+
tags: { veil_team_id: input.teamId },
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
const raw = await text(response, signal);
|
|
72
|
+
if (!response.ok)
|
|
73
|
+
throw new VeilError("unavailable");
|
|
74
|
+
const imported = decode(importedSchema, raw);
|
|
75
|
+
if (imported.connection_id !== input.locator)
|
|
76
|
+
throw new VeilError("invalid_response");
|
|
77
|
+
},
|
|
78
|
+
async removeConnection(locator, integrationId, signal) {
|
|
79
|
+
const query = new URLSearchParams({ provider_config_key: integrationId });
|
|
80
|
+
const response = await call(`/connections/${encodeURIComponent(locator)}?${query.toString()}`, signal, { method: "DELETE" });
|
|
81
|
+
const raw = await text(response, signal);
|
|
82
|
+
if (response.status === 400) {
|
|
83
|
+
decode(absentSchema, raw);
|
|
84
|
+
return "absent";
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
throw new VeilError("unavailable");
|
|
88
|
+
decode(deletedSchema, raw);
|
|
89
|
+
return "deleted";
|
|
90
|
+
},
|
|
91
|
+
call,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Private Team-owned Computer authorization, never an Agent metadata Grant. */
|
|
2
|
+
export type GitRepositoryAccess = Readonly<{
|
|
3
|
+
repository: Readonly<{
|
|
4
|
+
owner: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}>;
|
|
7
|
+
locator: string;
|
|
8
|
+
integrationId: string;
|
|
9
|
+
}>;
|
|
10
|
+
/** The caller must obtain fresh Computer/repository authorization before each
|
|
11
|
+
* invocation. This adapter owns Nango control headers, not authorization state. */
|
|
12
|
+
export declare function createNangoGitCustodian(options: Readonly<{
|
|
13
|
+
serverUrl: string;
|
|
14
|
+
apiKey: string;
|
|
15
|
+
integrationId: string;
|
|
16
|
+
}>): {
|
|
17
|
+
/** Fetch, clone and push for one authorized repository. */
|
|
18
|
+
readGit(access: GitRepositoryAccess, request: Request): Promise<Response>;
|
|
19
|
+
};
|