@fungi.computer/veil 0.2.1 → 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,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;
@@ -3,12 +3,11 @@
3
3
  * provider login policy: the local runtime, the TUI login registry and the Hub
4
4
  * read it; none of them decides visibility on its own.
5
5
  *
6
- * Stance: like Pi and OpenCode, offer a person's own subscription locally and
7
- * hosted, and turn a provider off through `subscriptionKillSwitch` if it
8
- * objects or blocks tokens. A subscription is always one person's: never
9
- * Team-scoped, shared or pooled. The new OpenAI ChatGPT plan flow is
10
- * local-only; hosted use awaits OpenAI approval. The retired Codex provider
11
- * is forbidden on both hosts.
6
+ * Offer a person's own Claude or ChatGPT subscription locally, never on
7
+ * hosted Fungi. A subscription is always one person's: never Team-scoped,
8
+ * shared or pooled. `subscriptionKillSwitch` can turn off a provider if it
9
+ * objects or blocks tokens. Hosted BYOK uses API keys. The retired Codex
10
+ * provider is forbidden on both hosts.
12
11
  * See wiki/3-resources/decisions/adr-byok-model-keys-v1.md.
13
12
  */
14
13
  /** The owner's own machine, or Fungi's servers acting for a Team. */
@@ -69,7 +68,9 @@ export declare const modelCredentialPolicy: Readonly<{
69
68
  status: "allowed";
70
69
  }>;
71
70
  hosted: Readonly<{
72
- status: "allowed";
71
+ status: "forbidden";
72
+ reason: string;
73
+ source: string;
73
74
  }>;
74
75
  };
75
76
  };
@@ -3,17 +3,21 @@
3
3
  * provider login policy: the local runtime, the TUI login registry and the Hub
4
4
  * read it; none of them decides visibility on its own.
5
5
  *
6
- * Stance: like Pi and OpenCode, offer a person's own subscription locally and
7
- * hosted, and turn a provider off through `subscriptionKillSwitch` if it
8
- * objects or blocks tokens. A subscription is always one person's: never
9
- * Team-scoped, shared or pooled. The new OpenAI ChatGPT plan flow is
10
- * local-only; hosted use awaits OpenAI approval. The retired Codex provider
11
- * is forbidden on both hosts.
6
+ * Offer a person's own Claude or ChatGPT subscription locally, never on
7
+ * hosted Fungi. A subscription is always one person's: never Team-scoped,
8
+ * shared or pooled. `subscriptionKillSwitch` can turn off a provider if it
9
+ * objects or blocks tokens. Hosted BYOK uses API keys. The retired Codex
10
+ * provider is forbidden on both hosts.
12
11
  * See wiki/3-resources/decisions/adr-byok-model-keys-v1.md.
13
12
  */
14
13
  const allowed = { status: "allowed" };
15
14
  const unreviewed = { status: "unreviewed" };
16
15
  const notOffered = {
16
+ status: "forbidden",
17
+ reason: "Fungi does not offer this credential method on this host",
18
+ source: "wiki/3-resources/decisions/adr-byok-model-keys-v1.md",
19
+ };
20
+ const providerNotOffered = {
17
21
  status: "forbidden",
18
22
  reason: "The provider does not offer this credential method",
19
23
  source: "provider documentation",
@@ -25,7 +29,7 @@ const retiredCodex = {
25
29
  };
26
30
  const apiKeyOnly = {
27
31
  api_key: { local: allowed, hosted: allowed },
28
- subscription: { local: notOffered, hosted: notOffered },
32
+ subscription: { local: providerNotOffered, hosted: providerNotOffered },
29
33
  };
30
34
  /**
31
35
  * Reviewed providers, keyed by Pi provider id. `openai-compatible` is a
@@ -38,9 +42,8 @@ export const modelCredentialPolicy = Object.freeze({
38
42
  },
39
43
  anthropic: {
40
44
  api_key: { local: allowed, hosted: allowed },
41
- // Local: Pi's Claude Pro/Max OAuth, as Pi and OpenCode ship it. Hosted: the
42
- // unmodified Claude Code binary on the owner's Computer, signed in by them.
43
- subscription: { local: allowed, hosted: allowed },
45
+ // Local: Pi's Claude Pro/Max OAuth. Hosted: no Claude subscription login.
46
+ subscription: { local: allowed, hosted: notOffered },
44
47
  },
45
48
  "openai-codex": {
46
49
  api_key: { local: retiredCodex, hosted: retiredCodex },
@@ -52,8 +55,8 @@ export const modelCredentialPolicy = Object.freeze({
52
55
  local: allowed,
53
56
  hosted: {
54
57
  status: "forbidden",
55
- reason: "ChatGPT plan usage in a hosted app requires OpenAI approval",
56
- source: "https://developers.openai.com/siwc/token-sharing-open-source",
58
+ reason: "Fungi does not offer ChatGPT subscription sign-in on hosted Fungi; approval alone would not enable it",
59
+ source: "wiki/3-resources/decisions/adr-byok-model-keys-v1.md",
57
60
  },
58
61
  },
59
62
  },
@@ -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");
@@ -14,6 +14,9 @@
14
14
  * `/parallel` route, with `x-api-key`.
15
15
  * - `ai-gateway-logs`: Cloudflare's API log read used for usage
16
16
  * reconciliation, `Authorization: Bearer` with the gateway token.
17
+ * - `solana-rpc` / `solana-rpc-fallback`: finalized snapshot reads through
18
+ * Helius / QuickNode, with private query / path credentials.
19
+ * - `sanctions-screen`: the official OFAC SDN XML export, without a key.
17
20
  */
18
21
  /** Private Worker bindings read only here; never Team storage or responses. */
19
22
  export type ProviderKeyBindings = Readonly<{
@@ -24,8 +27,16 @@ export type ProviderKeyBindings = Readonly<{
24
27
  /** Cloudflare account that owns the gateway; routes are scoped to it. */
25
28
  AI_GATEWAY_ACCOUNT_ID?: string;
26
29
  AI_GATEWAY_ID?: string;
30
+ /** Credential-free Helius mainnet root; the key is a separate Worker secret. */
31
+ SOLANA_RPC_URL?: string;
32
+ SOLANA_RPC_API_KEY?: string;
33
+ /** Credential-free QuickNode Solana mainnet root; token is a Worker secret. */
34
+ SOLANA_RPC_FALLBACK_URL?: string;
35
+ SOLANA_RPC_FALLBACK_API_KEY?: string;
36
+ /** Opt-in to the pinned public OFAC SDN XML export. No API key is needed. */
37
+ SANCTIONS_SDN_URL?: string;
27
38
  }>;
28
- export type ProviderRoute = "camel" | "ai-gateway" | "parallel" | "ai-gateway-logs";
39
+ export type ProviderRoute = "camel" | "ai-gateway" | "parallel" | "ai-gateway-logs" | "solana-rpc" | "solana-rpc-fallback" | "sanctions-screen";
29
40
  /**
30
41
  * The provider-key door. `fetch` is the transport the authorized request is
31
42
  * finally sent on (a test double, or the Hub's telemetry-wrapped fetch).
@@ -33,16 +44,16 @@ export type ProviderRoute = "camel" | "ai-gateway" | "parallel" | "ai-gateway-lo
33
44
  export declare function createProviderEgress(env: ProviderKeyBindings, options?: Readonly<{
34
45
  fetch?: typeof globalThis.fetch;
35
46
  }>): {
36
- /** Whether this route's credential is configured; reveals no value. */
47
+ /** Whether required custody/configuration is present; reveals no value. */
37
48
  credentialed(route: ProviderRoute): boolean;
38
49
  /** The route's public base URL, or `null` while it is not configured. */
39
50
  baseUrl(route: ProviderRoute): string | null;
40
51
  /**
41
52
  * Send a credential-free request to a route. Refuses (`denied`) any
42
53
  * 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).
54
+ * credential. Credentialed routes never follow redirects (workerd has no
55
+ * `error` mode; `manual` hands the 3xx back instead). Only the anonymous
56
+ * sanctions GET may follow one pinned OFAC government-S3 302.
46
57
  */
47
58
  fetch(route: ProviderRoute, input: string | URL | Request, init?: RequestInit): Promise<Response>;
48
59
  };