@forgezero/vault 0.1.0 → 0.1.2

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/dist/schema.js CHANGED
@@ -1,4 +1,6 @@
1
1
  // src/index.ts
2
+ import { deriveKeysFromSeed, signRequest } from "@forgezero/runtime/identity";
3
+
2
4
  class VaultError extends Error {
3
5
  code;
4
6
  constructor(code, message) {
@@ -7,76 +9,171 @@ class VaultError extends Error {
7
9
  this.name = "VaultError";
8
10
  }
9
11
  }
10
- var DEFAULT_SOCKET = "/run/forgezero.sock";
12
+ var runtimeEnvironment = () => typeof process !== "undefined" && process.env ? process.env : {};
13
+ var runtimeSocketExists = (path) => {
14
+ if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
15
+ return false;
16
+ try {
17
+ const fs = process.getBuiltinModule("node:fs");
18
+ return fs.existsSync(path);
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+ var DEFAULT_SOCKET = "/run/forgezero/vault.sock";
11
24
  function discover(environment = {}) {
12
- const env = environment.env ?? {};
25
+ const env = environment.env ?? runtimeEnvironment();
13
26
  const socketPath = environment.socketPath ?? env.FORGEZERO_SOCKET ?? DEFAULT_SOCKET;
14
- if (environment.socketExists?.(socketPath))
27
+ if ((environment.socketExists ?? runtimeSocketExists)(socketPath))
15
28
  return { mode: "managed", socketPath };
16
29
  const apiKey = env.FORGEZERO_API_KEY;
17
30
  if (apiKey)
18
31
  return { mode: "external", apiKey };
19
32
  throw new VaultError("NO_CREDENTIAL", `No credential found. On managed compute the agent socket at ${socketPath} provides one; ` + "elsewhere set FORGEZERO_API_KEY.");
20
33
  }
21
- var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
22
- var DEFAULT_ASSIGN_URL = "https://api.forgezero.net/v1/assign";
34
+ var unbase64url = (encoded) => {
35
+ const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - encoded.length % 4) % 4);
36
+ let binary;
37
+ try {
38
+ binary = atob(padded);
39
+ } catch {
40
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
41
+ }
42
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
43
+ };
44
+ var base64url = (value) => {
45
+ const bytes = new TextEncoder().encode(value);
46
+ let binary = "";
47
+ for (const byte of bytes)
48
+ binary += String.fromCharCode(byte);
49
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
50
+ };
51
+ function signerFromApiKey(secret) {
52
+ const parts = secret.split(".");
53
+ const legacy = parts.length === 3 && parts[0] === "fz";
54
+ const current = parts.length === 4 && parts[0] === "fz" && (parts[1] === "live" || parts[1] === "test");
55
+ if (!legacy && !current)
56
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
57
+ const keyId = parts[current ? 2 : 1];
58
+ const encodedSeed = parts[current ? 3 : 2];
59
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(keyId) || !/^[A-Za-z0-9_-]{43}$/.test(encodedSeed)) {
60
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
61
+ }
62
+ const seed = unbase64url(encodedSeed);
63
+ if (seed.length !== 32)
64
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
65
+ const keys = deriveKeysFromSeed(seed);
66
+ seed.fill(0);
67
+ return {
68
+ keyId,
69
+ async sign(request) {
70
+ const envelope = signRequest(keys, keyId, request);
71
+ return base64url(JSON.stringify({
72
+ timestamp: envelope.timestamp,
73
+ nonce: envelope.nonce,
74
+ edSignature: envelope.edSignature,
75
+ mlDsaSignature: envelope.mlDsaSignature
76
+ }));
77
+ }
78
+ };
79
+ }
80
+ var requestAgent = (socketPath, request) => new Promise((resolveRequest, reject) => {
81
+ if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function") {
82
+ reject(new VaultError("MANAGED_RUNTIME_UNAVAILABLE", "The managed agent socket needs a Node or Bun server runtime."));
83
+ return;
84
+ }
85
+ const net = process.getBuiltinModule("node:net");
86
+ const socket = net.connect(socketPath, () => socket.write(`${JSON.stringify(request)}
87
+ `));
88
+ let buffer = "";
89
+ socket.setTimeout(15000, () => {
90
+ socket.destroy();
91
+ reject(new VaultError("AGENT_TIMEOUT", "The local ForgeZero agent did not answer."));
92
+ });
93
+ socket.on("data", (chunk) => {
94
+ buffer += chunk.toString("utf8");
95
+ if (new TextEncoder().encode(buffer).byteLength > 256 * 1024) {
96
+ socket.destroy();
97
+ reject(new VaultError("AGENT_RESPONSE_TOO_LARGE", "The local ForgeZero agent response was too large."));
98
+ return;
99
+ }
100
+ const newline = buffer.indexOf(`
101
+ `);
102
+ if (newline < 0)
103
+ return;
104
+ socket.end();
105
+ try {
106
+ resolveRequest(JSON.parse(buffer.slice(0, newline)));
107
+ } catch {
108
+ reject(new VaultError("AGENT_RESPONSE_MALFORMED", "The local ForgeZero agent response was malformed."));
109
+ }
110
+ });
111
+ socket.on("error", (cause) => reject(new VaultError("AGENT_UNAVAILABLE", cause.message)));
112
+ });
113
+ var DEFAULT_API_URL = "https://api.forgezero.net";
114
+ var DEFAULT_REQUEST_TIMEOUT_MS = 15000;
115
+ function apiOrigin(value) {
116
+ let url;
117
+ try {
118
+ url = new URL(value);
119
+ } catch {
120
+ throw new VaultError("API_URL_INVALID", "apiUrl must be an absolute HTTPS origin.");
121
+ }
122
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
123
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:") || url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "")
124
+ throw new VaultError("API_URL_INVALID", "apiUrl must be an HTTPS origin without credentials, path, query or fragment.");
125
+ return url;
126
+ }
23
127
 
24
128
  class ForgeZero {
25
129
  credential;
26
- assignment;
27
- assignUrl;
130
+ apiUrl;
28
131
  doFetch;
29
- now;
30
132
  signer;
133
+ agentTransport;
134
+ requestTimeoutMs;
31
135
  project;
32
136
  environment;
33
137
  constructor(options = {}) {
34
- this.credential = options.credential ?? discover(options.discovery);
35
- this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
138
+ const credential = options.credential ?? discover(options.discovery);
139
+ this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
140
+ this.apiUrl = apiOrigin(options.apiUrl ?? DEFAULT_API_URL);
36
141
  this.doFetch = options.fetch ?? globalThis.fetch;
37
- this.now = options.now ?? Date.now;
38
- this.signer = options.signer;
142
+ this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
143
+ this.agentTransport = options.agentTransport ?? requestAgent;
144
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
145
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
146
+ throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
147
+ }
39
148
  this.project = options.project ?? "default";
40
149
  this.environment = options.environment ?? "production";
41
150
  }
42
- async node() {
43
- const now = this.now();
44
- if (this.assignment && this.assignment.expiresAt > now)
45
- return this.assignment.node;
46
- const response = await this.doFetch(this.assignUrl, {
47
- method: "POST",
48
- headers: { "content-type": "application/json" },
49
- body: JSON.stringify({ keyId: this.signer?.keyId })
50
- });
51
- if (!response.ok)
52
- throw new VaultError("NO_NODE", "No vault node is available right now.");
53
- const payload = await response.json();
54
- const ttl = payload.ttl ?? 60;
55
- this.assignment = { node: payload.node, ttl, expiresAt: now + ttl * 1000 };
56
- return payload.node;
57
- }
58
- reassign() {
59
- this.assignment = undefined;
60
- }
61
151
  scope() {
62
152
  return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
63
153
  }
64
- async request(path, init = {}, retried = false) {
65
- const node = await this.node();
154
+ async request(path, init = {}) {
155
+ if (this.credential.mode === "managed")
156
+ return this.managedRequest(path, init);
66
157
  const body = typeof init.body === "string" ? init.body : "";
158
+ const target = new URL(path, this.apiUrl);
67
159
  const headers = {
68
160
  "content-type": "application/json",
69
161
  ...init.headers
70
162
  };
71
163
  if (this.signer) {
72
164
  headers["x-fz-key"] = this.signer.keyId;
73
- headers["x-fz-signature"] = await this.signer.sign(`${init.method ?? "GET"}:${path}:${body}`);
74
- }
75
- const response = await this.doFetch(`https://${node}${path}`, { ...init, headers });
76
- if (REASSIGN_ON.has(response.status) && !retried) {
77
- this.reassign();
78
- return this.request(path, init, true);
165
+ headers["x-fz-signature"] = await this.signer.sign({
166
+ method: init.method ?? "GET",
167
+ path: target.pathname,
168
+ query: target.search.replace(/^\?/, ""),
169
+ body
170
+ });
79
171
  }
172
+ const response = await this.doFetch(target, {
173
+ ...init,
174
+ headers,
175
+ signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
176
+ });
80
177
  const payload = await response.json().catch(() => {
81
178
  return;
82
179
  });
@@ -85,7 +182,66 @@ class ForgeZero {
85
182
  }
86
183
  return payload;
87
184
  }
185
+ async managedRequest(path, init) {
186
+ const socketPath = this.credential.socketPath;
187
+ const method = init.method ?? "GET";
188
+ const target = new URL(path, "https://vault.invalid");
189
+ const entry = /\/entries\/([^/]+)$/.exec(target.pathname);
190
+ let response;
191
+ if (method === "GET" && entry) {
192
+ response = await this.agentTransport(socketPath, {
193
+ op: "get",
194
+ name: decodeURIComponent(entry[1])
195
+ });
196
+ if (!response.ok)
197
+ throw new VaultError(response.error.code, response.error.message);
198
+ if (response.op !== "get")
199
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
200
+ return { value: response.value };
201
+ }
202
+ if (method === "GET" && target.pathname.endsWith("/list")) {
203
+ response = await this.agentTransport(socketPath, { op: "held" });
204
+ if (!response.ok)
205
+ throw new VaultError(response.error.code, response.error.message);
206
+ if (response.op !== "held")
207
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
208
+ return { entries: response.names.map((name) => ({ name })) };
209
+ }
210
+ if (method === "GET" && target.pathname.endsWith("/entries")) {
211
+ response = await this.agentTransport(socketPath, { op: "held" });
212
+ if (!response.ok)
213
+ throw new VaultError(response.error.code, response.error.message);
214
+ if (response.op !== "held")
215
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
216
+ const values = {};
217
+ for (const name of response.names) {
218
+ const read = await this.agentTransport(socketPath, { op: "get", name });
219
+ if (!read.ok)
220
+ throw new VaultError(read.error.code, read.error.message);
221
+ if (read.op !== "get")
222
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
223
+ values[name] = read.value;
224
+ }
225
+ return { values };
226
+ }
227
+ if (method === "GET" && target.pathname.endsWith("/changes")) {
228
+ response = await this.agentTransport(socketPath, { op: "sync" });
229
+ if (!response.ok)
230
+ throw new VaultError(response.error.code, response.error.message);
231
+ if (response.op !== "sync")
232
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
233
+ return {
234
+ version: response.cursor,
235
+ changed: response.invalidated.map((name) => ({ name, version: 0 })),
236
+ resync: response.resync
237
+ };
238
+ }
239
+ throw new VaultError("MANAGED_READ_ONLY", "This operation is not exposed to an application through the managed agent socket.");
240
+ }
88
241
  async get(name, options = {}) {
242
+ if (this.credential.mode === "managed" && options.version !== undefined) {
243
+ throw new VaultError("MANAGED_CURRENT_ONLY", "The managed agent RAM replica serves the current value only; use an API key for a historical version.");
244
+ }
89
245
  const query = options.version ? `?version=${options.version}` : "";
90
246
  const result = await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}${query}`);
91
247
  return result.value;
@@ -112,16 +268,10 @@ class ForgeZero {
112
268
  return result.entries;
113
269
  }
114
270
  async set(name, value) {
115
- const result = await this.request(`/v1/vault/${this.scope()}/entries`, {
116
- method: "POST",
117
- body: JSON.stringify({ name, value })
118
- });
119
- return result.version;
271
+ throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot write secrets; use the authenticated platform vault UI.");
120
272
  }
121
273
  async remove(name) {
122
- await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}`, {
123
- method: "DELETE"
124
- });
274
+ throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot delete secrets; use the authenticated platform vault UI.");
125
275
  }
126
276
  async* watch(options = {}) {
127
277
  const interval = options.intervalMs ?? 15000;
@@ -146,7 +296,43 @@ function vaultCredentials(vault) {
146
296
  get: (reference, field) => vault.get(`${reference}.${field}`)
147
297
  };
148
298
  }
149
- var VERSION = "0.1.0";
299
+ function directCredentials(read) {
300
+ return { name: "platform-direct", get: read };
301
+ }
302
+ var readRuntimeCredential = (path) => {
303
+ if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
304
+ return;
305
+ try {
306
+ const fs = process.getBuiltinModule("node:fs");
307
+ const stat = fs.statSync(path);
308
+ if (!stat.isFile() || stat.size > 1024 * 1024)
309
+ return;
310
+ return fs.readFileSync(path, "utf8");
311
+ } catch {
312
+ return;
313
+ }
314
+ };
315
+ function systemdCredentials(options = {}) {
316
+ return {
317
+ name: "systemd",
318
+ async get(reference, field) {
319
+ const directory = options.directory ?? (typeof process !== "undefined" ? process.env?.CREDENTIALS_DIRECTORY : undefined);
320
+ if (!directory) {
321
+ throw new VaultError("CREDENTIAL_MISSING", "systemd did not provide CREDENTIALS_DIRECTORY to this unit.");
322
+ }
323
+ const name = (options.nameFor ?? ((ref, key) => `${ref}.${key}`))(reference, field);
324
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name)) {
325
+ throw new VaultError("CREDENTIAL_NAME_INVALID", "A systemd credential name may contain only letters, digits, dot, underscore and hyphen.");
326
+ }
327
+ const value = (options.read ?? readRuntimeCredential)(`${directory}/${name}`);
328
+ if (value === undefined || value.length === 0) {
329
+ throw new VaultError("CREDENTIAL_MISSING", `The systemd credential ${name} is not available to this unit.`);
330
+ }
331
+ return value;
332
+ }
333
+ };
334
+ }
335
+ var VERSION = "0.1.2";
150
336
 
151
337
  // src/schema.ts
152
338
  function managedSchemas(vault, options = {}) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/vault",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -41,7 +41,15 @@
41
41
  "typescript": "^5.6.0",
42
42
  "@types/bun": "latest"
43
43
  },
44
- "description": "ForgeZero vault client. Credential discovery, versioned secrets, node assignment.",
44
+ "dependencies": {
45
+ "@forgezero/runtime": "^0.1.2"
46
+ },
47
+ "peerDependencies": {
48
+ "@noble/curves": "^2.2.0",
49
+ "@noble/post-quantum": "^0.6.1",
50
+ "@noble/hashes": "^2.2.0"
51
+ },
52
+ "description": "ForgeZero vault client. Credential discovery and versioned secrets through one stable API origin.",
45
53
  "keywords": [
46
54
  "secrets",
47
55
  "vault",
@@ -55,7 +63,7 @@
55
63
  "nextjs"
56
64
  ],
57
65
  "license": "MIT",
58
- "homepage": "https://forgezero.net/docs/vault-package",
66
+ "homepage": "https://www.forgezero.net/docs/vault-package",
59
67
  "repository": {
60
68
  "type": "git",
61
69
  "url": "git+https://github.com/axxra/forgezero.git",