@forgezero/vault 0.1.0 → 0.1.1

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,19 +9,111 @@ 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
  }
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
+ });
21
113
  var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
22
- var DEFAULT_ASSIGN_URL = "https://api.forgezero.net/v1/assign";
114
+ var DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
115
+ var DEFAULT_REQUEST_TIMEOUT_MS = 15000;
116
+ var isForgeZeroNode = (hostname) => /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+forgezero\.net$/.test(hostname);
23
117
 
24
118
  class ForgeZero {
25
119
  credential;
@@ -28,14 +122,24 @@ class ForgeZero {
28
122
  doFetch;
29
123
  now;
30
124
  signer;
125
+ agentTransport;
126
+ nodeAllowed;
127
+ requestTimeoutMs;
31
128
  project;
32
129
  environment;
33
130
  constructor(options = {}) {
34
- this.credential = options.credential ?? discover(options.discovery);
131
+ const credential = options.credential ?? discover(options.discovery);
132
+ this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
35
133
  this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
36
134
  this.doFetch = options.fetch ?? globalThis.fetch;
37
135
  this.now = options.now ?? Date.now;
38
- this.signer = options.signer;
136
+ this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
137
+ this.agentTransport = options.agentTransport ?? requestAgent;
138
+ this.nodeAllowed = options.nodeAllowed ?? isForgeZeroNode;
139
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
140
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
141
+ throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
142
+ }
39
143
  this.project = options.project ?? "default";
40
144
  this.environment = options.environment ?? "production";
41
145
  }
@@ -43,17 +147,28 @@ class ForgeZero {
43
147
  const now = this.now();
44
148
  if (this.assignment && this.assignment.expiresAt > now)
45
149
  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 })
150
+ const assignmentUrl = new URL(this.assignUrl);
151
+ if (this.signer?.keyId && !assignmentUrl.searchParams.has("client")) {
152
+ assignmentUrl.searchParams.set("client", this.signer.keyId);
153
+ }
154
+ const response = await this.doFetch(assignmentUrl, {
155
+ method: "GET",
156
+ headers: { accept: "application/json" },
157
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
50
158
  });
51
159
  if (!response.ok)
52
160
  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;
161
+ const payload = await response.json().catch(() => null);
162
+ const hostname = typeof payload?.node === "string" ? payload.node : payload?.node && typeof payload.node === "object" && ("hostname" in payload.node) ? payload.node.hostname : undefined;
163
+ if (typeof hostname !== "string" || !this.nodeAllowed(hostname)) {
164
+ throw new VaultError("UNTRUSTED_NODE", "The directory returned an untrusted vault node hostname.");
165
+ }
166
+ const ttl = payload?.ttl ?? 60;
167
+ if (typeof ttl !== "number" || !Number.isFinite(ttl) || ttl < 1 || ttl > 300) {
168
+ throw new VaultError("INVALID_ASSIGNMENT", "The directory returned an invalid vault-node lease.");
169
+ }
170
+ this.assignment = { node: hostname, ttl, expiresAt: now + ttl * 1000 };
171
+ return hostname;
57
172
  }
58
173
  reassign() {
59
174
  this.assignment = undefined;
@@ -62,17 +177,29 @@ class ForgeZero {
62
177
  return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
63
178
  }
64
179
  async request(path, init = {}, retried = false) {
180
+ if (this.credential.mode === "managed")
181
+ return this.managedRequest(path, init);
65
182
  const node = await this.node();
66
183
  const body = typeof init.body === "string" ? init.body : "";
184
+ const target = new URL(path, "https://vault.invalid");
67
185
  const headers = {
68
186
  "content-type": "application/json",
69
187
  ...init.headers
70
188
  };
71
189
  if (this.signer) {
72
190
  headers["x-fz-key"] = this.signer.keyId;
73
- headers["x-fz-signature"] = await this.signer.sign(`${init.method ?? "GET"}:${path}:${body}`);
191
+ headers["x-fz-signature"] = await this.signer.sign({
192
+ method: init.method ?? "GET",
193
+ path: target.pathname,
194
+ query: target.search.replace(/^\?/, ""),
195
+ body
196
+ });
74
197
  }
75
- const response = await this.doFetch(`https://${node}${path}`, { ...init, headers });
198
+ const response = await this.doFetch(`https://${node}${path}`, {
199
+ ...init,
200
+ headers,
201
+ signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
202
+ });
76
203
  if (REASSIGN_ON.has(response.status) && !retried) {
77
204
  this.reassign();
78
205
  return this.request(path, init, true);
@@ -85,7 +212,66 @@ class ForgeZero {
85
212
  }
86
213
  return payload;
87
214
  }
215
+ async managedRequest(path, init) {
216
+ const socketPath = this.credential.socketPath;
217
+ const method = init.method ?? "GET";
218
+ const target = new URL(path, "https://vault.invalid");
219
+ const entry = /\/entries\/([^/]+)$/.exec(target.pathname);
220
+ let response;
221
+ if (method === "GET" && entry) {
222
+ response = await this.agentTransport(socketPath, {
223
+ op: "get",
224
+ name: decodeURIComponent(entry[1])
225
+ });
226
+ if (!response.ok)
227
+ throw new VaultError(response.error.code, response.error.message);
228
+ if (response.op !== "get")
229
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
230
+ return { value: response.value };
231
+ }
232
+ if (method === "GET" && target.pathname.endsWith("/list")) {
233
+ response = await this.agentTransport(socketPath, { op: "held" });
234
+ if (!response.ok)
235
+ throw new VaultError(response.error.code, response.error.message);
236
+ if (response.op !== "held")
237
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
238
+ return { entries: response.names.map((name) => ({ name })) };
239
+ }
240
+ if (method === "GET" && target.pathname.endsWith("/entries")) {
241
+ response = await this.agentTransport(socketPath, { op: "held" });
242
+ if (!response.ok)
243
+ throw new VaultError(response.error.code, response.error.message);
244
+ if (response.op !== "held")
245
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
246
+ const values = {};
247
+ for (const name of response.names) {
248
+ const read = await this.agentTransport(socketPath, { op: "get", name });
249
+ if (!read.ok)
250
+ throw new VaultError(read.error.code, read.error.message);
251
+ if (read.op !== "get")
252
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
253
+ values[name] = read.value;
254
+ }
255
+ return { values };
256
+ }
257
+ if (method === "GET" && target.pathname.endsWith("/changes")) {
258
+ response = await this.agentTransport(socketPath, { op: "sync" });
259
+ if (!response.ok)
260
+ throw new VaultError(response.error.code, response.error.message);
261
+ if (response.op !== "sync")
262
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
263
+ return {
264
+ version: response.cursor,
265
+ changed: response.invalidated.map((name) => ({ name, version: 0 })),
266
+ resync: response.resync
267
+ };
268
+ }
269
+ throw new VaultError("MANAGED_READ_ONLY", "This operation is not exposed to an application through the managed agent socket.");
270
+ }
88
271
  async get(name, options = {}) {
272
+ if (this.credential.mode === "managed" && options.version !== undefined) {
273
+ throw new VaultError("MANAGED_CURRENT_ONLY", "The managed agent RAM replica serves the current value only; use an API key for a historical version.");
274
+ }
89
275
  const query = options.version ? `?version=${options.version}` : "";
90
276
  const result = await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}${query}`);
91
277
  return result.value;
@@ -112,16 +298,10 @@ class ForgeZero {
112
298
  return result.entries;
113
299
  }
114
300
  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;
301
+ throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot write secrets; use the authenticated platform vault UI.");
120
302
  }
121
303
  async remove(name) {
122
- await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}`, {
123
- method: "DELETE"
124
- });
304
+ throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot delete secrets; use the authenticated platform vault UI.");
125
305
  }
126
306
  async* watch(options = {}) {
127
307
  const interval = options.intervalMs ?? 15000;
@@ -146,7 +326,43 @@ function vaultCredentials(vault) {
146
326
  get: (reference, field) => vault.get(`${reference}.${field}`)
147
327
  };
148
328
  }
149
- var VERSION = "0.1.0";
329
+ function directCredentials(read) {
330
+ return { name: "platform-direct", get: read };
331
+ }
332
+ var readRuntimeCredential = (path) => {
333
+ if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
334
+ return;
335
+ try {
336
+ const fs = process.getBuiltinModule("node:fs");
337
+ const stat = fs.statSync(path);
338
+ if (!stat.isFile() || stat.size > 1024 * 1024)
339
+ return;
340
+ return fs.readFileSync(path, "utf8");
341
+ } catch {
342
+ return;
343
+ }
344
+ };
345
+ function systemdCredentials(options = {}) {
346
+ return {
347
+ name: "systemd",
348
+ async get(reference, field) {
349
+ const directory = options.directory ?? (typeof process !== "undefined" ? process.env?.CREDENTIALS_DIRECTORY : undefined);
350
+ if (!directory) {
351
+ throw new VaultError("CREDENTIAL_MISSING", "systemd did not provide CREDENTIALS_DIRECTORY to this unit.");
352
+ }
353
+ const name = (options.nameFor ?? ((ref, key) => `${ref}.${key}`))(reference, field);
354
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name)) {
355
+ throw new VaultError("CREDENTIAL_NAME_INVALID", "A systemd credential name may contain only letters, digits, dot, underscore and hyphen.");
356
+ }
357
+ const value = (options.read ?? readRuntimeCredential)(`${directory}/${name}`);
358
+ if (value === undefined || value.length === 0) {
359
+ throw new VaultError("CREDENTIAL_MISSING", `The systemd credential ${name} is not available to this unit.`);
360
+ }
361
+ return value;
362
+ }
363
+ };
364
+ }
365
+ var VERSION = "0.1.1";
150
366
 
151
367
  // src/schema.ts
152
368
  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.1",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -41,6 +41,14 @@
41
41
  "typescript": "^5.6.0",
42
42
  "@types/bun": "latest"
43
43
  },
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
+ },
44
52
  "description": "ForgeZero vault client. Credential discovery, versioned secrets, node assignment.",
45
53
  "keywords": [
46
54
  "secrets",
@@ -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",