@fungi.computer/veil 0.3.0 → 0.4.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.
@@ -0,0 +1,141 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import type { PublicEgressPolicy } from "../public-egress-policy.js";
4
+ export type ConnectionsRefusal = "invalid_input" | "denied" | "revoked" | "conflict" | "unsupported" | "too_large" | "reconnect_required" | "invalid_response" | "unavailable";
5
+ /** Fixed codes only: upstream bodies, URLs and credential bytes never enter errors. */
6
+ export declare class ConnectionsError extends Error {
7
+ readonly code: ConnectionsRefusal;
8
+ readonly name = "ConnectionsError";
9
+ constructor(code: ConnectionsRefusal, options?: ErrorOptions);
10
+ }
11
+ export declare const identifier: Schema.String;
12
+ declare const placementSchema: Schema.Union<readonly [Schema.Literal<"bearer">, Schema.Struct<{
13
+ readonly header: Schema.String;
14
+ }>, Schema.Struct<{
15
+ readonly query: Schema.String;
16
+ }>]>;
17
+ export type Placement = typeof placementSchema.Type;
18
+ /** How the final request is authorized. OAuth joins this union in slice 3. */
19
+ export declare const credentialSchema: Schema.Union<readonly [Schema.Struct<{
20
+ readonly kind: Schema.Literal<"none">;
21
+ }>, Schema.Struct<{
22
+ readonly kind: Schema.Literal<"static">;
23
+ readonly placement: Schema.Union<readonly [Schema.Literal<"bearer">, Schema.Struct<{
24
+ readonly header: Schema.String;
25
+ }>, Schema.Struct<{
26
+ readonly query: Schema.String;
27
+ }>]>;
28
+ }>]>;
29
+ export type Credential = typeof credentialSchema.Type;
30
+ export type SurfaceView = Readonly<{
31
+ kind: "mcp";
32
+ url: string;
33
+ }> | Readonly<{
34
+ kind: "openapi";
35
+ baseUrl: string;
36
+ table: unknown;
37
+ }>;
38
+ export declare const toolsSchema: Schema.Union<readonly [Schema.Literal<"all">, Schema.$Array<Schema.String>]>;
39
+ export type Tools = typeof toolsSchema.Type;
40
+ export declare const draftSchema: Schema.Struct<{
41
+ /** Idempotency key: a replay with the same request returns the same connection. */
42
+ readonly requestId: Schema.String;
43
+ readonly name: Schema.String;
44
+ readonly surface: Schema.Union<readonly [Schema.Struct<{
45
+ readonly kind: Schema.Literal<"mcp">;
46
+ readonly url: Schema.String;
47
+ }>, Schema.Struct<{
48
+ readonly kind: Schema.Literal<"openapi">;
49
+ readonly baseUrl: Schema.String;
50
+ readonly table: Schema.Unknown;
51
+ }>]>;
52
+ readonly credential: Schema.Union<readonly [Schema.Struct<{
53
+ readonly kind: Schema.Literal<"none">;
54
+ }>, Schema.Struct<{
55
+ readonly kind: Schema.Literal<"static">;
56
+ readonly placement: Schema.Union<readonly [Schema.Literal<"bearer">, Schema.Struct<{
57
+ readonly header: Schema.String;
58
+ }>, Schema.Struct<{
59
+ readonly query: Schema.String;
60
+ }>]>;
61
+ }>]>;
62
+ readonly secret: Schema.optional<Schema.String>;
63
+ }>;
64
+ export type ConnectionDraft = typeof draftSchema.Type;
65
+ export declare const grantInputSchema: Schema.Struct<{
66
+ readonly connectionId: Schema.String;
67
+ readonly agentId: Schema.String;
68
+ readonly tools: Schema.Union<readonly [Schema.Literal<"all">, Schema.$Array<Schema.String>]>;
69
+ }>;
70
+ export type GrantInput = typeof grantInputSchema.Type;
71
+ export declare const egressRefSchema: Schema.Struct<{
72
+ readonly connectionId: Schema.String;
73
+ readonly grantVersion: Schema.Number;
74
+ }>;
75
+ export type EgressRef = typeof egressRefSchema.Type;
76
+ /** Where custody finds one connection; carries no secret. */
77
+ export type CustodyAddress = Readonly<{
78
+ ownerId: string;
79
+ connectionId: string;
80
+ credential: Credential;
81
+ }>;
82
+ /**
83
+ * Owner-side credential custody. `sealed` stores ciphertext, `nango` a locator.
84
+ * Only `inject` ever opens a credential, and it sends the request itself.
85
+ */
86
+ export type CustodyAdapter = Readonly<{
87
+ put: (address: CustodyAddress, secret: string) => Effect.Effect<string, ConnectionsError>;
88
+ inject: (address: CustodyAddress, locator: string, request: Request) => Effect.Effect<Response, ConnectionsError>;
89
+ remove: (address: CustodyAddress, locator: string) => Effect.Effect<"deleted" | "absent", ConnectionsError>;
90
+ }>;
91
+ export type SqlValue = string | number | null;
92
+ /** The owner's SQLite in Cloudflare `ctx.storage` shape. */
93
+ export type SqlStore = Readonly<{
94
+ sql: Readonly<{
95
+ exec: (statement: string, ...bindings: SqlValue[]) => Readonly<{
96
+ toArray: () => readonly unknown[];
97
+ }>;
98
+ }>;
99
+ transactionSync: <A>(operation: () => A) => A;
100
+ }>;
101
+ export type ConnectionsOptions = Readonly<{
102
+ ownerId: string;
103
+ store: SqlStore;
104
+ custody: CustodyAdapter;
105
+ /** Host authority read: may this actor manage connections, or still stand as a grantor? */
106
+ authorize: (actorId: string, purpose: "manage" | "grantor") => boolean;
107
+ egressPolicy?: PublicEgressPolicy;
108
+ clock: () => number;
109
+ ids: () => string;
110
+ }>;
111
+ /** Secret-free, versioned, and safe to hand to an Agent. */
112
+ export type ConnectionProjection = Readonly<{
113
+ revision: number;
114
+ connections: readonly Readonly<{
115
+ connectionId: string;
116
+ name: string;
117
+ surface: SurfaceView;
118
+ tools: Tools;
119
+ authorityVersion: number;
120
+ grantVersion: number;
121
+ }>[];
122
+ }>;
123
+ export type ConnectionView = Readonly<{
124
+ connectionId: string;
125
+ name: string;
126
+ surface: Readonly<{
127
+ kind: "mcp" | "openapi";
128
+ origin: string;
129
+ }>;
130
+ credentialKind: Credential["kind"];
131
+ state: "active" | "reconnect_required" | "revoked";
132
+ authorityVersion: number;
133
+ grants: readonly Readonly<{
134
+ agentId: string;
135
+ tools: Tools;
136
+ version: number;
137
+ }>[];
138
+ }>;
139
+ /** The Agent's narrowed capability: only the owner opens the credential. */
140
+ export type NarrowFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
141
+ export {};
@@ -0,0 +1,55 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ /** Fixed codes only: upstream bodies, URLs and credential bytes never enter errors. */
4
+ export class ConnectionsError extends Error {
5
+ code;
6
+ name = "ConnectionsError";
7
+ constructor(code, options) {
8
+ super(code, options);
9
+ this.code = code;
10
+ }
11
+ }
12
+ export const identifier = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_.:-]{1,128}$/u)));
13
+ const headerName = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9-]{1,64}$/u)));
14
+ const queryName = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_.-]{1,64}$/u)));
15
+ const version = Schema.Number.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)));
16
+ const placementSchema = Schema.Union([
17
+ Schema.Literal("bearer"),
18
+ Schema.Struct({ header: headerName }),
19
+ Schema.Struct({ query: queryName }),
20
+ ]);
21
+ /** How the final request is authorized. OAuth joins this union in slice 3. */
22
+ export const credentialSchema = Schema.Union([
23
+ Schema.Struct({ kind: Schema.Literal("none") }),
24
+ Schema.Struct({ kind: Schema.Literal("static"), placement: placementSchema }),
25
+ ]);
26
+ /** What the Agent can call. The OpenAPI table is decoded by Forage before it is stored. */
27
+ const surfaceDraftSchema = Schema.Union([
28
+ Schema.Struct({ kind: Schema.Literal("mcp"), url: Schema.String }),
29
+ Schema.Struct({
30
+ kind: Schema.Literal("openapi"),
31
+ baseUrl: Schema.String,
32
+ table: Schema.Unknown,
33
+ }),
34
+ ]);
35
+ export const toolsSchema = Schema.Union([
36
+ Schema.Literal("all"),
37
+ Schema.Array(Schema.String.pipe(Schema.check(Schema.isMinLength(1), Schema.isMaxLength(256)))).pipe(Schema.check(Schema.isMaxLength(256))),
38
+ ]);
39
+ export const draftSchema = Schema.Struct({
40
+ /** Idempotency key: a replay with the same request returns the same connection. */
41
+ requestId: identifier,
42
+ name: Schema.String.pipe(Schema.check(Schema.isMinLength(1), Schema.isMaxLength(128))),
43
+ surface: surfaceDraftSchema,
44
+ credential: credentialSchema,
45
+ secret: Schema.optional(Schema.String.pipe(Schema.check(Schema.isPattern(/^[\x21-\x7e]{1,16384}$/u)))),
46
+ });
47
+ export const grantInputSchema = Schema.Struct({
48
+ connectionId: identifier,
49
+ agentId: identifier,
50
+ tools: toolsSchema,
51
+ });
52
+ export const egressRefSchema = Schema.Struct({
53
+ connectionId: identifier,
54
+ grantVersion: version,
55
+ });
@@ -0,0 +1,48 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { ConnectionsError, type ConnectionDraft, type GrantInput, type ConnectionsOptions, type EgressRef, type NarrowFetch, type SurfaceView } from "./contract.js";
3
+ /** One owner's connections: attempts, connections, grants, receipts and cleanup in its SQLite. */
4
+ export declare function createConnections(options: ConnectionsOptions): {
5
+ begin: (actor: string, input: ConnectionDraft) => Effect.Effect<{
6
+ connectionId: string;
7
+ authorizeUrl: null;
8
+ }, ConnectionsError, never>;
9
+ grant: (actor: string, input: GrantInput) => Effect.Effect<{
10
+ grantVersion: number;
11
+ }, ConnectionsError, never>;
12
+ ungrant: (actor: string, input: {
13
+ connectionId: string;
14
+ agentId: string;
15
+ }) => Effect.Effect<void, ConnectionsError, never>;
16
+ revoke: (actor: string, input: {
17
+ connectionId: string;
18
+ }) => Effect.Effect<void, ConnectionsError, never>;
19
+ projection: (agentId: string) => Effect.Effect<{
20
+ revision: number;
21
+ connections: {
22
+ connectionId: string;
23
+ name: string;
24
+ surface: SurfaceView;
25
+ tools: readonly string[] | "all";
26
+ authorityVersion: number;
27
+ grantVersion: number;
28
+ }[];
29
+ }, ConnectionsError, never>;
30
+ list: (actor: string) => Effect.Effect<Readonly<{
31
+ connectionId: string;
32
+ name: string;
33
+ surface: Readonly<{
34
+ kind: "mcp" | "openapi";
35
+ origin: string;
36
+ }>;
37
+ credentialKind: import("./contract.js").Credential["kind"];
38
+ state: "active" | "reconnect_required" | "revoked";
39
+ authorityVersion: number;
40
+ grants: readonly Readonly<{
41
+ agentId: string;
42
+ tools: import("./contract.js").Tools;
43
+ version: number;
44
+ }>[];
45
+ }>[], ConnectionsError, never>;
46
+ egress: (agentId: string, input: EgressRef) => NarrowFetch;
47
+ sweep: () => Effect.Effect<void, ConnectionsError, never>;
48
+ };
@@ -0,0 +1,372 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import { parseOpenApiTable } from "@fungi.computer/forage/openapi";
4
+ import { assertPublicHostName } from "../model-key-network.js";
5
+ import { createPublicEgressPolicy } from "../public-egress-policy.js";
6
+ import { ConnectionsError, credentialSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
7
+ import { admitRequest } from "./egress.js";
8
+ const attemptTtlMs = 10 * 60 * 1000;
9
+ const cleanupLimit = 5;
10
+ const json = (schema) => Schema.fromJsonString(schema);
11
+ const connectionRow = Schema.Struct({
12
+ id: identifier,
13
+ name: Schema.String,
14
+ surface: json(Schema.Struct({
15
+ kind: Schema.Literals(["mcp", "openapi"]),
16
+ url: Schema.String,
17
+ table: Schema.NullOr(Schema.Unknown),
18
+ })),
19
+ credential: json(credentialSchema),
20
+ locator: Schema.NullOr(Schema.String),
21
+ state: Schema.Literals(["active", "reconnect_required", "revoked"]),
22
+ authorityVersion: Schema.Number,
23
+ });
24
+ const grantRow = Schema.Struct({
25
+ connectionId: identifier,
26
+ agentId: identifier,
27
+ tools: json(toolsSchema),
28
+ grantor: identifier,
29
+ version: Schema.Number,
30
+ active: Schema.Literals([0, 1]),
31
+ });
32
+ const receiptRow = Schema.Struct({
33
+ fingerprint: Schema.String,
34
+ result: json(Schema.Struct({ connectionId: identifier })),
35
+ });
36
+ const cleanupRow = Schema.Struct({
37
+ id: Schema.Number,
38
+ connectionId: identifier,
39
+ locator: Schema.String,
40
+ credential: json(credentialSchema),
41
+ attempts: Schema.Number,
42
+ });
43
+ const count = Schema.Struct({ n: Schema.Number });
44
+ function decode(schema, input) {
45
+ try {
46
+ return Schema.decodeUnknownSync(schema)(input);
47
+ }
48
+ catch {
49
+ throw new ConnectionsError("invalid_input");
50
+ }
51
+ }
52
+ const sync = (operation) => Effect.try({
53
+ try: operation,
54
+ catch: (cause) => cause instanceof ConnectionsError
55
+ ? cause
56
+ : new ConnectionsError("unavailable", { cause }),
57
+ });
58
+ const io = (operation) => Effect.tryPromise({
59
+ try: operation,
60
+ catch: (cause) => cause instanceof ConnectionsError
61
+ ? cause
62
+ : new ConnectionsError("unavailable", { cause }),
63
+ });
64
+ /** One owner's connections: attempts, connections, grants, receipts and cleanup in its SQLite. */
65
+ export function createConnections(options) {
66
+ const ownerId = decode(identifier, options.ownerId);
67
+ const { sql, transactionSync } = options.store;
68
+ const policy = createPublicEgressPolicy(options.egressPolicy);
69
+ sql.exec(`
70
+ CREATE TABLE IF NOT EXISTS veil_connection_attempts (
71
+ state TEXT PRIMARY KEY, connectionId TEXT NOT NULL, actor TEXT NOT NULL,
72
+ status TEXT NOT NULL, expiresAt INTEGER NOT NULL,
73
+ pkceVerifierSealed TEXT, issuer TEXT) STRICT;
74
+ CREATE TABLE IF NOT EXISTS veil_connections (
75
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, surface TEXT NOT NULL, credential TEXT NOT NULL,
76
+ locator TEXT, state TEXT NOT NULL, authorityVersion INTEGER NOT NULL) STRICT;
77
+ CREATE TABLE IF NOT EXISTS veil_connection_grants (
78
+ connectionId TEXT NOT NULL, agentId TEXT NOT NULL, tools TEXT NOT NULL,
79
+ grantor TEXT NOT NULL, version INTEGER NOT NULL, active INTEGER NOT NULL,
80
+ PRIMARY KEY (connectionId, agentId)) STRICT;
81
+ CREATE TABLE IF NOT EXISTS veil_connection_receipts (
82
+ requestId TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, result TEXT NOT NULL) STRICT;
83
+ CREATE TABLE IF NOT EXISTS veil_connection_cleanup (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT, connectionId TEXT NOT NULL, locator TEXT NOT NULL,
85
+ credential TEXT NOT NULL, attempts INTEGER NOT NULL) STRICT;
86
+ CREATE TABLE IF NOT EXISTS veil_connection_epoch (id INTEGER PRIMARY KEY CHECK (id = 1), revision INTEGER NOT NULL) STRICT;
87
+ INSERT OR IGNORE INTO veil_connection_epoch VALUES (1, 1);`);
88
+ const rows = (schema, statement, ...bindings) => sql
89
+ .exec(statement, ...bindings)
90
+ .toArray()
91
+ .map((row) => decode(schema, row));
92
+ const bump = () => sql.exec("UPDATE veil_connection_epoch SET revision = revision + 1");
93
+ const authorize = (actor, purpose) => {
94
+ decode(identifier, actor);
95
+ if (!options.authorize(actor, purpose))
96
+ throw new ConnectionsError("denied");
97
+ };
98
+ const connection = (id) => {
99
+ const row = rows(connectionRow, "SELECT * FROM veil_connections WHERE id = ?", id)[0];
100
+ if (!row)
101
+ throw new ConnectionsError("denied");
102
+ return row;
103
+ };
104
+ const grant = (id, agentId) => rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND agentId = ?", id, agentId)[0];
105
+ const address = (row) => ({
106
+ ownerId,
107
+ connectionId: row.id,
108
+ credential: row.credential,
109
+ });
110
+ /** The surface URL must be public https with nothing the owner did not choose to send. */
111
+ const publicUrl = (value) => {
112
+ if (!URL.canParse(value))
113
+ throw new ConnectionsError("invalid_input");
114
+ const url = new URL(value);
115
+ if (url.protocol !== "https:" ||
116
+ url.username ||
117
+ url.password ||
118
+ url.search ||
119
+ url.hash)
120
+ throw new ConnectionsError("invalid_input");
121
+ try {
122
+ assertPublicHostName(url.hostname);
123
+ policy.destination(url);
124
+ }
125
+ catch (cause) {
126
+ throw new ConnectionsError("denied", { cause });
127
+ }
128
+ return url.href;
129
+ };
130
+ const sweep = () => Effect.gen(function* () {
131
+ const due = yield* sync(() => rows(cleanupRow, "SELECT * FROM veil_connection_cleanup WHERE attempts < ?", cleanupLimit));
132
+ for (const job of due) {
133
+ const outcome = yield* options.custody
134
+ .remove({
135
+ ownerId,
136
+ connectionId: job.connectionId,
137
+ credential: job.credential,
138
+ }, job.locator)
139
+ .pipe(Effect.result);
140
+ yield* sync(() => {
141
+ if (outcome._tag === "Success")
142
+ sql.exec("DELETE FROM veil_connection_cleanup WHERE id = ?", job.id);
143
+ else
144
+ sql.exec("UPDATE veil_connection_cleanup SET attempts = attempts + 1 WHERE id = ?", job.id);
145
+ });
146
+ }
147
+ });
148
+ const enqueueCleanup = (row) => {
149
+ if (row.locator !== null)
150
+ sql.exec("INSERT INTO veil_connection_cleanup (connectionId, locator, credential, attempts) VALUES (?, ?, ?, 0)", row.id, row.locator, JSON.stringify(row.credential));
151
+ };
152
+ const fingerprint = (draft) => JSON.stringify([
153
+ draft.name,
154
+ draft.surface.kind,
155
+ "url" in draft.surface ? draft.surface.url : draft.surface.baseUrl,
156
+ draft.credential,
157
+ draft.secret !== undefined,
158
+ ]);
159
+ const begin = (actor, input) => Effect.gen(function* () {
160
+ const draft = yield* sync(() => {
161
+ authorize(actor, "manage");
162
+ const parsed = decode(draftSchema, input);
163
+ if (parsed.credential.kind === "static"
164
+ ? parsed.secret === undefined
165
+ : parsed.secret !== undefined)
166
+ throw new ConnectionsError("invalid_input");
167
+ return parsed;
168
+ });
169
+ const replay = yield* sync(() => {
170
+ const receipt = rows(receiptRow, "SELECT fingerprint, result FROM veil_connection_receipts WHERE requestId = ?", draft.requestId)[0];
171
+ if (!receipt)
172
+ return null;
173
+ if (receipt.fingerprint !== fingerprint(draft))
174
+ throw new ConnectionsError("conflict");
175
+ return receipt.result.connectionId;
176
+ });
177
+ if (replay !== null)
178
+ return { connectionId: replay, authorizeUrl: null };
179
+ const endpoint = yield* sync(() => publicUrl(draft.surface.kind === "mcp"
180
+ ? draft.surface.url
181
+ : draft.surface.baseUrl));
182
+ const surface = draft.surface;
183
+ const table = surface.kind === "openapi"
184
+ ? (yield* io(() => parseOpenApiTable(surface.table))).operations
185
+ : null;
186
+ const connectionId = options.ids();
187
+ const state = options.ids();
188
+ yield* sync(() => {
189
+ decode(identifier, connectionId);
190
+ sql.exec("INSERT INTO veil_connection_attempts (state, connectionId, actor, status, expiresAt) VALUES (?, ?, ?, 'pending', ?)", state, connectionId, actor, options.clock() + attemptTtlMs);
191
+ });
192
+ const addr = { ownerId, connectionId, credential: draft.credential };
193
+ const locator = draft.secret === undefined
194
+ ? null
195
+ : yield* options.custody.put(addr, draft.secret);
196
+ const stored = {
197
+ id: connectionId,
198
+ name: draft.name,
199
+ credential: draft.credential,
200
+ locator,
201
+ state: "active",
202
+ authorityVersion: 1,
203
+ surface: {
204
+ kind: draft.surface.kind,
205
+ url: endpoint,
206
+ table,
207
+ },
208
+ };
209
+ return yield* sync(() => transactionSync(() => {
210
+ authorize(actor, "manage");
211
+ const attempt = rows(count, "SELECT COUNT(*) AS n FROM veil_connection_attempts WHERE state = ? AND status = 'pending' AND expiresAt > ?", state, options.clock())[0];
212
+ if (attempt?.n !== 1)
213
+ throw new ConnectionsError("denied");
214
+ sql.exec("INSERT INTO veil_connections VALUES (?, ?, ?, ?, ?, 'active', 1)", stored.id, stored.name, JSON.stringify(stored.surface), JSON.stringify(stored.credential), stored.locator);
215
+ sql.exec("UPDATE veil_connection_attempts SET status = 'completed' WHERE state = ?", state);
216
+ sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", draft.requestId, fingerprint(draft), JSON.stringify({ connectionId }));
217
+ bump();
218
+ return { connectionId, authorizeUrl: null };
219
+ })).pipe(Effect.tapError(() => sync(() => {
220
+ enqueueCleanup(stored);
221
+ })));
222
+ });
223
+ const grantTo = (actor, input) => sync(() => transactionSync(() => {
224
+ authorize(actor, "manage");
225
+ const wanted = decode(grantInputSchema, input);
226
+ const row = connection(wanted.connectionId);
227
+ if (row.state === "revoked")
228
+ throw new ConnectionsError("revoked");
229
+ if (wanted.tools === "all"
230
+ ? false
231
+ : row.surface.kind !== "mcp" && wanted.tools.length === 0)
232
+ throw new ConnectionsError("invalid_input");
233
+ const tools = JSON.stringify(wanted.tools);
234
+ const existing = grant(wanted.connectionId, wanted.agentId);
235
+ if (existing?.active === 1 && JSON.stringify(existing.tools) === tools)
236
+ return { grantVersion: existing.version };
237
+ const next = (existing?.version ?? 0) + 1;
238
+ sql.exec(`INSERT INTO veil_connection_grants VALUES (?, ?, ?, ?, ?, 1)
239
+ ON CONFLICT (connectionId, agentId) DO UPDATE SET tools = excluded.tools, grantor = excluded.grantor, version = excluded.version, active = 1`, wanted.connectionId, wanted.agentId, tools, actor, next);
240
+ bump();
241
+ return { grantVersion: next };
242
+ }));
243
+ const ungrant = (actor, input) => sync(() => {
244
+ transactionSync(() => {
245
+ authorize(actor, "manage");
246
+ const wanted = decode(Schema.Struct({ connectionId: identifier, agentId: identifier }), input);
247
+ const existing = grant(wanted.connectionId, wanted.agentId);
248
+ if (existing?.active !== 1)
249
+ return;
250
+ sql.exec("UPDATE veil_connection_grants SET active = 0, version = version + 1 WHERE connectionId = ? AND agentId = ?", wanted.connectionId, wanted.agentId);
251
+ bump();
252
+ });
253
+ });
254
+ const revoke = (actor, input) => Effect.gen(function* () {
255
+ yield* sync(() => {
256
+ transactionSync(() => {
257
+ authorize(actor, "manage");
258
+ const row = connection(decode(identifier, input.connectionId));
259
+ if (row.state === "revoked")
260
+ return;
261
+ sql.exec("UPDATE veil_connections SET state = 'revoked', locator = NULL, authorityVersion = authorityVersion + 1 WHERE id = ?", row.id);
262
+ sql.exec("UPDATE veil_connection_grants SET active = 0, version = version + 1 WHERE connectionId = ? AND active = 1", row.id);
263
+ enqueueCleanup(row);
264
+ bump();
265
+ });
266
+ });
267
+ yield* sweep();
268
+ });
269
+ const projection = (agentId) => Effect.gen(function* () {
270
+ const stored = yield* sync(() => {
271
+ decode(identifier, agentId);
272
+ const revision = rows(Schema.Struct({ revision: Schema.Number }), "SELECT revision FROM veil_connection_epoch")[0]?.revision ?? 1;
273
+ const granted = rows(grantRow, "SELECT * FROM veil_connection_grants WHERE agentId = ? AND active = 1", agentId);
274
+ return {
275
+ revision,
276
+ granted: granted
277
+ .map((g) => ({ g, row: connection(g.connectionId) }))
278
+ .filter(({ row }) => row.state === "active"),
279
+ };
280
+ });
281
+ const connections = [];
282
+ for (const { g, row } of stored.granted) {
283
+ const surface = row.surface.kind === "mcp"
284
+ ? { kind: "mcp", url: row.surface.url }
285
+ : {
286
+ kind: "openapi",
287
+ baseUrl: row.surface.url,
288
+ table: (yield* io(() => parseOpenApiTable(row.surface.table)))
289
+ .operations,
290
+ };
291
+ connections.push({
292
+ connectionId: row.id,
293
+ name: row.name,
294
+ surface,
295
+ tools: g.tools,
296
+ authorityVersion: row.authorityVersion,
297
+ grantVersion: g.version,
298
+ });
299
+ }
300
+ return {
301
+ revision: stored.revision,
302
+ connections,
303
+ };
304
+ });
305
+ const list = (actor) => sync(() => {
306
+ authorize(actor, "manage");
307
+ return rows(connectionRow, "SELECT * FROM veil_connections ORDER BY id").map((row) => ({
308
+ connectionId: row.id,
309
+ name: row.name,
310
+ surface: {
311
+ kind: row.surface.kind,
312
+ origin: new URL(row.surface.url).origin,
313
+ },
314
+ credentialKind: row.credential.kind,
315
+ state: row.state,
316
+ authorityVersion: row.authorityVersion,
317
+ grants: rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND active = 1", row.id).map((g) => ({
318
+ agentId: g.agentId,
319
+ tools: g.tools,
320
+ version: g.version,
321
+ })),
322
+ }));
323
+ });
324
+ /** The grant, the connection and the grantor must all still stand. */
325
+ const admitted = (agentId, ref) => {
326
+ decode(identifier, agentId);
327
+ const row = connection(ref.connectionId);
328
+ const held = grant(ref.connectionId, agentId);
329
+ if (row.state === "revoked" ||
330
+ held?.active !== 1 ||
331
+ held.version !== ref.grantVersion)
332
+ throw new ConnectionsError("revoked");
333
+ if (row.state === "reconnect_required")
334
+ throw new ConnectionsError("reconnect_required");
335
+ authorize(held.grantor, "grantor");
336
+ return { row, tools: held.tools };
337
+ };
338
+ const egress = (agentId, input) => {
339
+ const ref = decode(egressRefSchema, input);
340
+ return (target, init) => Effect.runPromise(Effect.gen(function* () {
341
+ const { row, tools } = yield* sync(() => admitted(agentId, ref));
342
+ const request = yield* io(() => admitRequest({ kind: row.surface.kind, url: row.surface.url }, tools, new Request(target, init), policy));
343
+ if (row.locator === null && row.credential.kind === "static")
344
+ return yield* Effect.fail(new ConnectionsError("denied"));
345
+ const response = yield* options.custody
346
+ .inject(address(row), row.locator ?? "", request)
347
+ .pipe(Effect.tapError((error) => error.code === "reconnect_required"
348
+ ? sync(() => sql.exec("UPDATE veil_connections SET state = 'reconnect_required', authorityVersion = authorityVersion + 1 WHERE id = ? AND state = 'active'", row.id))
349
+ : Effect.void));
350
+ const stillCurrent = yield* sync(() => admitted(agentId, ref)).pipe(Effect.result);
351
+ if (stillCurrent._tag === "Failure" ||
352
+ response.redirected ||
353
+ (response.status >= 300 && response.status < 400)) {
354
+ yield* io(() => response.body?.cancel() ?? Promise.resolve());
355
+ return yield* Effect.fail(stillCurrent._tag === "Failure"
356
+ ? stillCurrent.failure
357
+ : new ConnectionsError("invalid_response"));
358
+ }
359
+ return response;
360
+ }));
361
+ };
362
+ return {
363
+ begin,
364
+ grant: grantTo,
365
+ ungrant,
366
+ revoke,
367
+ projection,
368
+ list,
369
+ egress,
370
+ sweep,
371
+ };
372
+ }
@@ -0,0 +1,8 @@
1
+ import type { createPublicEgressPolicy } from "../public-egress-policy.js";
2
+ import { type Tools } from "./contract.js";
3
+ export type Endpoint = Readonly<{
4
+ kind: "mcp" | "openapi";
5
+ url: string;
6
+ }>;
7
+ /** One request, one verdict. The caller holds the grant; this proves the request stays inside it. */
8
+ export declare function admitRequest(endpoint: Endpoint, tools: Tools, request: Request, policy: ReturnType<typeof createPublicEgressPolicy>): Promise<Request>;
@@ -0,0 +1,77 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { isJSONRPCRequest, parseJSONRPCMessage, } from "@modelcontextprotocol/client";
3
+ import { assertPublicHostName } from "../model-key-network.js";
4
+ import { ConnectionsError } from "./contract.js";
5
+ const bodyLimit = 256 * 1024;
6
+ const owned = ["authorization", "proxy-authorization", "cookie"];
7
+ const jsonValue = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
8
+ const toolCall = Schema.decodeUnknownSync(Schema.Struct({ name: Schema.String }));
9
+ /** One request, one verdict. The caller holds the grant; this proves the request stays inside it. */
10
+ export async function admitRequest(endpoint, tools, request, policy) {
11
+ const target = new URL(request.url);
12
+ const base = new URL(endpoint.url);
13
+ const inside = endpoint.kind === "mcp"
14
+ ? target.href === base.href
15
+ : target.origin === base.origin &&
16
+ target.pathname.startsWith(base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`);
17
+ if (!inside ||
18
+ request.redirect !== "error" ||
19
+ target.username ||
20
+ target.password ||
21
+ owned.some((name) => request.headers.has(name)))
22
+ throw new ConnectionsError("denied");
23
+ try {
24
+ assertPublicHostName(target.hostname);
25
+ policy.destination(target);
26
+ }
27
+ catch (cause) {
28
+ throw new ConnectionsError("denied", { cause });
29
+ }
30
+ if (request.body === null)
31
+ return request;
32
+ const bytes = await boundedBytes(request.clone());
33
+ if (endpoint.kind === "mcp" && tools !== "all") {
34
+ let name = null;
35
+ try {
36
+ const message = parseJSONRPCMessage(jsonValue(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
37
+ if (isJSONRPCRequest(message) && message.method === "tools/call")
38
+ name = toolCall(message.params).name;
39
+ }
40
+ catch (cause) {
41
+ throw new ConnectionsError("denied", { cause });
42
+ }
43
+ if (name !== null && !tools.includes(name))
44
+ throw new ConnectionsError("denied");
45
+ }
46
+ return new Request(request.url, {
47
+ method: request.method,
48
+ headers: request.headers,
49
+ body: bytes,
50
+ redirect: request.redirect,
51
+ signal: request.signal,
52
+ });
53
+ }
54
+ /** Read the body once, stopping as soon as it passes the byte cap. */
55
+ async function boundedBytes(request) {
56
+ const chunks = [];
57
+ let size = 0;
58
+ const reader = request.body?.getReader();
59
+ for (;;) {
60
+ const next = await reader?.read();
61
+ if (next === undefined || next.done)
62
+ break;
63
+ size += next.value.byteLength;
64
+ if (size > bodyLimit) {
65
+ await reader?.cancel();
66
+ throw new ConnectionsError("too_large");
67
+ }
68
+ chunks.push(next.value);
69
+ }
70
+ const bytes = new Uint8Array(size);
71
+ let offset = 0;
72
+ for (const chunk of chunks) {
73
+ bytes.set(chunk, offset);
74
+ offset += chunk.byteLength;
75
+ }
76
+ return bytes;
77
+ }
@@ -0,0 +1,3 @@
1
+ export { createConnections } from "./core.js";
2
+ export { createSealedCustody } from "./sealed.js";
3
+ export * from "./contract.js";
@@ -0,0 +1,3 @@
1
+ export { createConnections } from "./core.js";
2
+ export { createSealedCustody } from "./sealed.js";
3
+ export * from "./contract.js";
@@ -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,54 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import { ConnectionsError, } from "./contract.js";
4
+ /** Where a static secret rides on the final request. */
5
+ function applyPlacement(request, placement, secret) {
6
+ if (placement !== "bearer" && "query" in placement) {
7
+ const target = new URL(request.url);
8
+ target.searchParams.set(placement.query, secret);
9
+ return new Request(target.href, request);
10
+ }
11
+ const headers = new Headers(request.headers);
12
+ if (placement === "bearer")
13
+ headers.set("authorization", `Bearer ${secret}`);
14
+ else
15
+ headers.set(placement.header, secret);
16
+ return new Request(request, { headers });
17
+ }
18
+ const failure = Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String }));
19
+ const refusal = (cause) => {
20
+ if (cause instanceof ConnectionsError)
21
+ return cause;
22
+ try {
23
+ const { code } = failure(cause);
24
+ return new ConnectionsError(code === "absent" || code === "denied"
25
+ ? "reconnect_required"
26
+ : "unavailable", { cause });
27
+ }
28
+ catch {
29
+ return new ConnectionsError("unavailable", { cause });
30
+ }
31
+ };
32
+ /** Default custody: a thin adapter over the shared credential store. `send` is the checked public transport. */
33
+ export function createSealedCustody(options) {
34
+ return {
35
+ put: (address, secret) => Effect.tryPromise({
36
+ try: async () => (await options.vault.put({
37
+ kind: "connection",
38
+ label: address.connectionId,
39
+ secret,
40
+ })).credentialId,
41
+ catch: refusal,
42
+ }),
43
+ inject: (address, locator, request) => Effect.tryPromise({
44
+ try: () => options.vault.withSecret(locator, (secret) => options.send(address.credential.kind === "static"
45
+ ? applyPlacement(request, address.credential.placement, secret)
46
+ : request)),
47
+ catch: refusal,
48
+ }),
49
+ remove: (_address, locator) => Effect.tryPromise({
50
+ try: () => options.vault.remove(locator),
51
+ catch: refusal,
52
+ }),
53
+ };
54
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Veil's one encrypted credential store. A host holds a dedicated 32-byte
3
+ * master key as a host secret; credentials (model API keys, connector tokens)
4
+ * are sealed under it with WebCrypto AES-GCM, bound to an owner address, and
5
+ * leave the store only into a callback. Listings carry no secret.
6
+ */
7
+ /** Stable, secret-free reason a credential could not be sealed, opened or stored. */
8
+ export declare class CredentialError extends Error {
9
+ readonly code: "unavailable" | "denied" | "absent" | "limit";
10
+ constructor(code: "unavailable" | "denied" | "absent" | "limit");
11
+ }
12
+ /** Dedicated 32-byte base64url keys; the active key seals, retained keys open. */
13
+ export type CredentialKeyring = Readonly<{
14
+ activeKeyId: string;
15
+ keys: Readonly<Record<string, string>>;
16
+ }>;
17
+ /**
18
+ * WebCrypto owns authenticated encryption. `domain` and `context` are the
19
+ * additional authenticated data: a sealed value opens only for its own owner.
20
+ */
21
+ export declare function createCredentialSealer(configuration: CredentialKeyring, domain: string): {
22
+ seal(context: readonly (string | null)[], credential: string): Promise<string>;
23
+ open(context: readonly (string | null)[], sealed: string): Promise<string>;
24
+ };
25
+ export type CredentialSealer = ReturnType<typeof createCredentialSealer>;
26
+ /** What a listing shows: no secret, no ciphertext. */
27
+ export type CredentialRecord = Readonly<{
28
+ credentialId: string;
29
+ kind: string;
30
+ label: string;
31
+ createdAt: number;
32
+ validatedAt: number;
33
+ }>;
34
+ /** One stored row: the listing fields plus the sealed envelope. */
35
+ export type StoredCredential = CredentialRecord & Readonly<{
36
+ sealed: string;
37
+ }>;
38
+ /** Durable Object style key-value storage; the store owns the `credential:` prefix. */
39
+ export type CredentialStorage = Readonly<{
40
+ get(key: string): Promise<StoredCredential | undefined>;
41
+ put(key: string, value: StoredCredential): Promise<void>;
42
+ delete(key: string): Promise<boolean>;
43
+ list(prefix: string): Promise<Map<string, StoredCredential>>;
44
+ }>;
45
+ export declare function createCredentialStore(options: {
46
+ storage: CredentialStorage;
47
+ sealer: CredentialSealer;
48
+ now?: () => number;
49
+ /** Most credentials one store holds. */
50
+ limit?: number;
51
+ }): {
52
+ /** Seal one credential bound to its id and kind. */
53
+ put(input: {
54
+ kind: string;
55
+ label: string;
56
+ secret: string;
57
+ }): Promise<CredentialRecord>;
58
+ list(kind?: string): Promise<readonly CredentialRecord[]>;
59
+ get(credentialId: string): Promise<CredentialRecord | null>;
60
+ remove(credentialId: string): Promise<"deleted" | "absent">;
61
+ /** The only way out: the secret exists for the callback and nowhere else. */
62
+ withSecret<T>(credentialId: string, action: (secret: string, record: CredentialRecord) => Promise<T> | T): Promise<T>;
63
+ };
64
+ export type CredentialStore = ReturnType<typeof createCredentialStore>;
@@ -0,0 +1,151 @@
1
+ import { Buffer } from "node:buffer";
2
+ import * as Schema from "effect/Schema";
3
+ import { defineIdFamily } from "@fungi.computer/cairn";
4
+ /**
5
+ * Veil's one encrypted credential store. A host holds a dedicated 32-byte
6
+ * master key as a host secret; credentials (model API keys, connector tokens)
7
+ * are sealed under it with WebCrypto AES-GCM, bound to an owner address, and
8
+ * leave the store only into a callback. Listings carry no secret.
9
+ */
10
+ /** Stable, secret-free reason a credential could not be sealed, opened or stored. */
11
+ export class CredentialError extends Error {
12
+ code;
13
+ constructor(code) {
14
+ super(code);
15
+ this.code = code;
16
+ this.name = "CredentialError";
17
+ }
18
+ }
19
+ const keySchema = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/u)));
20
+ const keyIdSchema = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/u)));
21
+ const envelopeSchema = Schema.Struct({
22
+ version: Schema.Literal(1),
23
+ keyId: keyIdSchema,
24
+ iv: Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{16}$/u))),
25
+ ciphertext: Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Za-z0-9_-]{22,22000}$/u))),
26
+ });
27
+ const encoder = new TextEncoder();
28
+ /**
29
+ * WebCrypto owns authenticated encryption. `domain` and `context` are the
30
+ * additional authenticated data: a sealed value opens only for its own owner.
31
+ */
32
+ export function createCredentialSealer(configuration, domain) {
33
+ const keys = new Map();
34
+ let activeKeyId;
35
+ try {
36
+ activeKeyId = Schema.decodeUnknownSync(keyIdSchema)(configuration.activeKeyId);
37
+ for (const [id, encoded] of Object.entries(configuration.keys)) {
38
+ Schema.decodeUnknownSync(keyIdSchema)(id);
39
+ const parsed = Schema.decodeUnknownSync(keySchema)(encoded);
40
+ const bytes = Uint8Array.from(Buffer.from(parsed, "base64url"));
41
+ if (bytes.length !== 32)
42
+ throw new CredentialError("unavailable");
43
+ keys.set(id, crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [
44
+ "encrypt",
45
+ "decrypt",
46
+ ]));
47
+ }
48
+ if (!keys.has(activeKeyId))
49
+ throw new CredentialError("unavailable");
50
+ }
51
+ catch {
52
+ throw new CredentialError("unavailable");
53
+ }
54
+ const key = (id) => {
55
+ const found = keys.get(id);
56
+ if (!found)
57
+ throw new CredentialError("denied");
58
+ return found;
59
+ };
60
+ const binding = (keyId, context) => encoder.encode(JSON.stringify([domain, keyId, ...context]));
61
+ return {
62
+ async seal(context, credential) {
63
+ const iv = crypto.getRandomValues(new Uint8Array(12));
64
+ const ciphertext = await crypto.subtle.encrypt({
65
+ name: "AES-GCM",
66
+ iv,
67
+ additionalData: binding(activeKeyId, context),
68
+ }, await key(activeKeyId), encoder.encode(credential));
69
+ return JSON.stringify({
70
+ version: 1,
71
+ keyId: activeKeyId,
72
+ iv: Buffer.from(iv).toString("base64url"),
73
+ ciphertext: Buffer.from(ciphertext).toString("base64url"),
74
+ });
75
+ },
76
+ async open(context, sealed) {
77
+ try {
78
+ const envelope = Schema.decodeUnknownSync(Schema.fromJsonString(envelopeSchema))(sealed);
79
+ const plaintext = await crypto.subtle.decrypt({
80
+ name: "AES-GCM",
81
+ iv: Uint8Array.from(Buffer.from(envelope.iv, "base64url")),
82
+ additionalData: binding(envelope.keyId, context),
83
+ }, await key(envelope.keyId), Uint8Array.from(Buffer.from(envelope.ciphertext, "base64url")));
84
+ return new TextDecoder().decode(plaintext);
85
+ }
86
+ catch {
87
+ throw new CredentialError("denied");
88
+ }
89
+ },
90
+ };
91
+ }
92
+ const ids = defineIdFamily({
93
+ family: "veil-credential",
94
+ version: 1,
95
+ kinds: { credential: { prefix: "cred" } },
96
+ });
97
+ const prefix = "credential:";
98
+ export function createCredentialStore(options) {
99
+ const now = options.now ?? Date.now;
100
+ const limit = options.limit ?? 64;
101
+ const publicRecord = (row) => ({
102
+ credentialId: row.credentialId,
103
+ kind: row.kind,
104
+ label: row.label,
105
+ createdAt: row.createdAt,
106
+ validatedAt: row.validatedAt,
107
+ });
108
+ const row = async (credentialId) => options.storage.get(`${prefix}${credentialId}`);
109
+ return {
110
+ /** Seal one credential bound to its id and kind. */
111
+ async put(input) {
112
+ if ((await options.storage.list(prefix)).size >= limit)
113
+ throw new CredentialError("limit");
114
+ const credentialId = ids.credential.generate();
115
+ const at = now();
116
+ const stored = {
117
+ credentialId,
118
+ kind: input.kind,
119
+ label: input.label,
120
+ createdAt: at,
121
+ validatedAt: at,
122
+ sealed: await options.sealer.seal([credentialId, input.kind], input.secret),
123
+ };
124
+ await options.storage.put(`${prefix}${credentialId}`, stored);
125
+ return publicRecord(stored);
126
+ },
127
+ async list(kind) {
128
+ return [...(await options.storage.list(prefix)).values()]
129
+ .filter((item) => kind === undefined || item.kind === kind)
130
+ .map(publicRecord)
131
+ .sort((a, b) => a.createdAt - b.createdAt);
132
+ },
133
+ async get(credentialId) {
134
+ const found = await row(credentialId);
135
+ return found === undefined ? null : publicRecord(found);
136
+ },
137
+ async remove(credentialId) {
138
+ return (await options.storage.delete(`${prefix}${credentialId}`))
139
+ ? "deleted"
140
+ : "absent";
141
+ },
142
+ /** The only way out: the secret exists for the callback and nowhere else. */
143
+ async withSecret(credentialId, action) {
144
+ const found = await row(credentialId);
145
+ if (found === undefined)
146
+ throw new CredentialError("absent");
147
+ const secret = await options.sealer.open([found.credentialId, found.kind], found.sealed);
148
+ return action(secret, publicRecord(found));
149
+ },
150
+ };
151
+ }
package/dist/index.d.ts CHANGED
@@ -227,7 +227,7 @@ export declare function createGitHubConnections(options: Readonly<{
227
227
  status: "completed";
228
228
  connectionId: string;
229
229
  authorityVersion: number;
230
- custody: "deleted" | "absent";
230
+ custody: "absent" | "deleted";
231
231
  }>;
232
232
  readRepository: (binding: ReadBinding, signal?: AbortSignal) => Promise<{
233
233
  id: number;
@@ -11,6 +11,30 @@ export declare function isModelKeyProvider(value: string): value is ModelKeyProv
11
11
  export declare function modelKeyBaseUrl(provider: ModelKeyProvider, configured?: string | null, options?: Readonly<{
12
12
  allowLocalDev?: boolean;
13
13
  }>): string;
14
+ /** A stored key as an owner sees it: no secret, no custody locator. */
15
+ export type ModelKeyRecord = Readonly<{
16
+ keyId: string;
17
+ provider: ModelKeyProvider;
18
+ label: string;
19
+ createdAt: number;
20
+ validatedAt: number;
21
+ }>;
22
+ /**
23
+ * The neutral model-key seam every host composes: standalone Oyster over
24
+ * Veil's credential store, the Hub over its Nango custodian. `seal` validates
25
+ * with the provider before storing; `send` is the only place a key meets a
26
+ * request.
27
+ */
28
+ export type ModelKeyVault = Readonly<{
29
+ seal: (input: Readonly<{
30
+ provider: string;
31
+ label: string;
32
+ apiKey: string;
33
+ }>) => Promise<ModelKeyRecord>;
34
+ list: () => Promise<readonly ModelKeyRecord[]>;
35
+ remove: (keyId: string) => Promise<"deleted" | "absent">;
36
+ send: (keyId: string, request: Request) => Promise<Response>;
37
+ }>;
14
38
  /** Private to the Team owner and the custodian adapter; never a public field. */
15
39
  export type ModelKeyAccess = Readonly<{
16
40
  locator: string;
@@ -1,5 +1,5 @@
1
1
  import { type OpenApiCredentialKeyring, type OpenApiAccess } from "./openapi-contract.js";
2
- /** WebCrypto owns authenticated encryption; the owner address and policy are its AAD. */
2
+ /** Connector credentials sealed by Veil's one credential sealer. */
3
3
  export declare function createOpenApiSecrets(configuration: OpenApiCredentialKeyring): {
4
4
  seal(access: OpenApiAccess, credential: string): Promise<string>;
5
5
  open(access: OpenApiAccess, sealed: string): Promise<string>;
@@ -1,76 +1,37 @@
1
- import { Buffer } from "node:buffer";
2
- import * as Schema from "effect/Schema";
3
1
  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,
2
+ import { createCredentialSealer } from "./credential-store.js";
3
+ /** The connector's owner address and policy are the sealed value's context. */
4
+ const context = (access) => [
16
5
  access.teamId,
17
6
  access.connectorId,
18
7
  access.baseUrl,
19
8
  access.auth.kind,
20
- access.auth.kind === "api_key_header" ||
21
- access.auth.kind === "api_key_query"
9
+ access.auth.kind === "api_key_header" || access.auth.kind === "api_key_query"
22
10
  ? access.auth.param
23
11
  : null,
24
- ]));
25
- /** WebCrypto owns authenticated encryption; the owner address and policy are its AAD. */
12
+ ];
13
+ /** Connector credentials sealed by Veil's one credential sealer. */
26
14
  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
- ]));
15
+ const sealer = (() => {
16
+ try {
17
+ return createCredentialSealer(configuration, "fungi.veil.openapi.v1");
41
18
  }
42
- if (!keys.has(activeKeyId))
19
+ catch {
43
20
  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
- };
21
+ }
22
+ })();
54
23
  return {
55
24
  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
- });
25
+ try {
26
+ return await sealer.seal(context(access), credential);
27
+ }
28
+ catch {
29
+ throw new OpenApiError("denied");
30
+ }
64
31
  },
65
32
  async open(access, sealed) {
66
33
  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);
34
+ return await sealer.open(context(access), sealed);
74
35
  }
75
36
  catch {
76
37
  throw new OpenApiError("denied");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/veil",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Team-authorized credential custody and provider access",
6
6
  "type": "module",
@@ -23,6 +23,10 @@
23
23
  "types": "./dist/model-credentials.d.ts",
24
24
  "import": "./dist/model-credentials.js"
25
25
  },
26
+ "./credential-store": {
27
+ "types": "./dist/credential-store.d.ts",
28
+ "import": "./dist/credential-store.js"
29
+ },
26
30
  "./model-keys": {
27
31
  "types": "./dist/model-keys.d.ts",
28
32
  "import": "./dist/model-keys.js"
@@ -78,6 +82,10 @@
78
82
  "./owned-infrastructure": {
79
83
  "types": "./dist/owned-infrastructure.d.ts",
80
84
  "import": "./dist/owned-infrastructure.js"
85
+ },
86
+ "./connections": {
87
+ "types": "./dist/connections/index.d.ts",
88
+ "import": "./dist/connections/index.js"
81
89
  }
82
90
  },
83
91
  "files": [
@@ -87,6 +95,8 @@
87
95
  ],
88
96
  "dependencies": {
89
97
  "@fungi.computer/cairn": "0.1.0",
98
+ "@fungi.computer/forage": "0.3.1",
99
+ "@modelcontextprotocol/client": "2.0.0",
90
100
  "@nangohq/node": "0.71.6",
91
101
  "effect": "4.0.2",
92
102
  "http-parser-js": "0.5.10",
@@ -94,6 +104,7 @@
94
104
  "zod": "4.4.3"
95
105
  },
96
106
  "devDependencies": {
107
+ "@fungi.computer/watchdog": "0.1.0",
97
108
  "@types/node": "24.13.3",
98
109
  "typescript": "5.9.3",
99
110
  "vitest": "4.1.11"