@forgezero/vault 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  **Read your secrets at runtime instead of shipping them in a file.**
4
4
 
5
- The client discovers its own credential and, for an API key, asks a directory
6
- which node to talk to. There is no endpoint to configure. The same code runs on
7
- a laptop and in production: outside managed compute the key is a local signing
8
- seed; on managed compute there is no remote credential in the application.
5
+ The client discovers its own credential and uses one stable API origin. Node
6
+ routing and failover stay behind that edge contract; the SDK never discovers or
7
+ stores a node hostname. The same code runs on a laptop and in production:
8
+ outside managed compute the key is a local signing seed; on managed compute
9
+ there is no remote credential in the application.
9
10
 
10
11
  This one talks to a ForgeZero vault, so it needs an account. The other three
11
12
  packages do not.
@@ -56,8 +57,10 @@ An entry can declare fields the tenant never holds. Two custody modes:
56
57
  exists in plaintext only inside a signing call.
57
58
  - **supplied** — you generate it, the vault seals it.
58
59
 
59
- Either way there is no call that returns a private key. Custody operations stay
60
- on the authenticated platform surface; the application credential is read-only.
60
+ Either way there is no call that returns a private key. Derivation and signing
61
+ stay inside explicit trusted platform workflows; the general application
62
+ credential remains a read-only secret client and is not a transaction-signing
63
+ oracle.
61
64
 
62
65
  ## Subpaths
63
66
 
package/dist/index.d.ts CHANGED
@@ -16,12 +16,13 @@
16
16
  * places, and the stronger posture is the one requiring less configuration
17
17
  * rather than more.
18
18
  *
19
- * ## Node assignment
19
+ * ## Stable API origin
20
20
  *
21
- * A directory answers which node to use; the client then talks to it directly
22
- * for a short window. Reassignment includes 530 a node whose TUNNEL is down
23
- * returns Cloudflare 1033, not an application status, so a retry list of only
24
- * 5xx misses it entirely.
21
+ * External clients always call one API origin. Node selection and failover are
22
+ * edge responsibilities; exposing node hostnames here would couple every SDK
23
+ * to platform topology and can route a tenant to a process that does not hold
24
+ * its realm seed. Managed applications continue to use the local agent socket
25
+ * and make no remote request at all.
25
26
  *
26
27
  * This package must not import `@forgezero/providers` either. `vaultCredentials`
27
28
  * returns the shape structurally, because a dependency edge in either direction
@@ -104,23 +105,9 @@ export type AgentResponse = {
104
105
  };
105
106
  };
106
107
  export type AgentTransport = (socketPath: string, request: AgentRequest) => Promise<AgentResponse>;
107
- export interface Assignment {
108
- node: string;
109
- ttl: number;
110
- expiresAt: number;
111
- }
112
- /**
113
- * Statuses meaning "ask for a different node", not "this failed".
114
- *
115
- * 530 is the one people miss: a node whose tunnel is down never reaches the
116
- * application, so Cloudflare answers 1033 with 530.
117
- */
118
- export declare const REASSIGN_ON: Set<number>;
119
108
  export interface VaultOptions {
120
- /** Directory endpoint. Unauthenticated a node hostname is not a secret. */
121
- assignUrl?: string;
122
- /** Override only for a private ForgeZero installation with its own node domain. */
123
- nodeAllowed?: (hostname: string) => boolean;
109
+ /** Stable public API origin. Node topology is never exposed to the client. */
110
+ apiUrl?: string;
124
111
  requestTimeoutMs?: number;
125
112
  project?: string;
126
113
  environment?: string;
@@ -129,7 +116,6 @@ export interface VaultOptions {
129
116
  agentTransport?: AgentTransport;
130
117
  credential?: Credential;
131
118
  discovery?: DiscoveryEnvironment;
132
- now?: () => number;
133
119
  }
134
120
  export interface EntryMeta {
135
121
  name: string;
@@ -142,27 +128,18 @@ export interface Change {
142
128
  name: string;
143
129
  version: number;
144
130
  }
145
- export declare const DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
131
+ export declare const DEFAULT_API_URL = "https://api.forgezero.net";
146
132
  export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
147
- /** A compromised directory must not turn a signed secret read into an open relay. */
148
- export declare const isForgeZeroNode: (hostname: string) => boolean;
149
133
  export declare class ForgeZero {
150
134
  readonly credential: Credential;
151
- private assignment;
152
- private readonly assignUrl;
135
+ private readonly apiUrl;
153
136
  private readonly doFetch;
154
- private readonly now;
155
137
  private readonly signer?;
156
138
  private readonly agentTransport;
157
- private readonly nodeAllowed;
158
139
  private readonly requestTimeoutMs;
159
140
  private readonly project;
160
141
  private readonly environment;
161
142
  constructor(options?: VaultOptions);
162
- /** The node to talk to, asking the directory only when the lease expired. */
163
- node(): Promise<string>;
164
- /** Drop the lease so the next call asks the directory again. */
165
- reassign(): void;
166
143
  private scope;
167
144
  private request;
168
145
  private managedRequest;
@@ -179,41 +156,35 @@ export declare class ForgeZero {
179
156
  */
180
157
  getAll(): Promise<Record<string, string>>;
181
158
  /**
182
- * The PUBLIC half of a value ForgeZero derived from the realm master seed.
159
+ * Custody derivation is intentionally not a general application-key action.
183
160
  *
184
- * There is no call that returns the private half, and that is the whole
185
- * design rather than an omission. A tenant that can fetch a signing key holds
186
- * the full risk of holding one: it lands in their backups, their logs, their
187
- * heap dumps and their environment. Here they declare which chain they
188
- * activated, the platform derives from the seed, and an ADDRESS is the only
189
- * thing that crosses back.
161
+ * Kept as a typed refusal for compatibility with the early package preview,
162
+ * which exposed this method before a secured custody workflow existed. A
163
+ * compromised secret-reading process must not silently become a transaction
164
+ * signer merely because both operations happen to involve the vault.
190
165
  *
191
- * Deterministic, so losing the row loses the metadata and not the money —
192
- * re-declaring the same chain and index derives the same address.
166
+ * @deprecated Use an explicit platform custody workflow with its own policy,
167
+ * approval and audit contract.
193
168
  */
194
169
  derived(name: string, field: string): Promise<{
195
170
  address: string;
196
171
  path: string;
197
172
  }>;
198
173
  /**
199
- * Sign a digest with a derived key, without the key ever leaving.
200
- *
201
- * The tenant builds the unsigned transaction, hashes it, and sends the
202
- * digest. What comes back is a signature. A compromised tenant process can
203
- * ASK for signatures — which is bounded, logged and revocable — and cannot
204
- * take the key, which would be none of those things.
205
- *
206
- * A digest rather than a transaction on purpose: the platform is not a
207
- * transaction builder for every chain a tenant might use, and pretending
208
- * otherwise would make ForgeZero the thing that has to understand every
209
- * chain format before a tenant can support one.
174
+ * @deprecated A general application vault credential is not a signing oracle.
210
175
  */
211
176
  sign(name: string, field: string, digest: string): Promise<{
212
177
  signature: string;
213
178
  recovery?: number;
214
179
  path: string;
215
180
  }>;
216
- /** Schemas the platform holds, for a managed source. */
181
+ /**
182
+ * List schemas represented by reserved vault entries.
183
+ *
184
+ * Kept for compatibility; `managedSchemas(vault).list()` is the higher-level
185
+ * interface. It deliberately uses `list` and `get`, so both the local agent
186
+ * and signed HTTPS transports follow the same contract.
187
+ */
217
188
  schemas(): Promise<readonly {
218
189
  name: string;
219
190
  version: number;
@@ -296,4 +267,4 @@ export declare function systemdCredentials(options?: SystemdCredentialOptions):
296
267
  readonly name: 'systemd';
297
268
  get(reference: string, field: string): Promise<string>;
298
269
  };
299
- export declare const VERSION = "0.1.1";
270
+ export declare const VERSION = "0.1.2";
package/dist/index.js CHANGED
@@ -110,32 +110,37 @@ var requestAgent = (socketPath, request) => new Promise((resolveRequest, reject)
110
110
  });
111
111
  socket.on("error", (cause) => reject(new VaultError("AGENT_UNAVAILABLE", cause.message)));
112
112
  });
113
- var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
114
- var DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
113
+ var DEFAULT_API_URL = "https://api.forgezero.net";
115
114
  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);
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
+ }
117
127
 
118
128
  class ForgeZero {
119
129
  credential;
120
- assignment;
121
- assignUrl;
130
+ apiUrl;
122
131
  doFetch;
123
- now;
124
132
  signer;
125
133
  agentTransport;
126
- nodeAllowed;
127
134
  requestTimeoutMs;
128
135
  project;
129
136
  environment;
130
137
  constructor(options = {}) {
131
138
  const credential = options.credential ?? discover(options.discovery);
132
139
  this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
133
- this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
140
+ this.apiUrl = apiOrigin(options.apiUrl ?? DEFAULT_API_URL);
134
141
  this.doFetch = options.fetch ?? globalThis.fetch;
135
- this.now = options.now ?? Date.now;
136
142
  this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
137
143
  this.agentTransport = options.agentTransport ?? requestAgent;
138
- this.nodeAllowed = options.nodeAllowed ?? isForgeZeroNode;
139
144
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
140
145
  if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
141
146
  throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
@@ -143,45 +148,14 @@ class ForgeZero {
143
148
  this.project = options.project ?? "default";
144
149
  this.environment = options.environment ?? "production";
145
150
  }
146
- async node() {
147
- const now = this.now();
148
- if (this.assignment && this.assignment.expiresAt > now)
149
- return this.assignment.node;
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)
158
- });
159
- if (!response.ok)
160
- throw new VaultError("NO_NODE", "No vault node is available right now.");
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;
172
- }
173
- reassign() {
174
- this.assignment = undefined;
175
- }
176
151
  scope() {
177
152
  return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
178
153
  }
179
- async request(path, init = {}, retried = false) {
154
+ async request(path, init = {}) {
180
155
  if (this.credential.mode === "managed")
181
156
  return this.managedRequest(path, init);
182
- const node = await this.node();
183
157
  const body = typeof init.body === "string" ? init.body : "";
184
- const target = new URL(path, "https://vault.invalid");
158
+ const target = new URL(path, this.apiUrl);
185
159
  const headers = {
186
160
  "content-type": "application/json",
187
161
  ...init.headers
@@ -195,15 +169,11 @@ class ForgeZero {
195
169
  body
196
170
  });
197
171
  }
198
- const response = await this.doFetch(`https://${node}${path}`, {
172
+ const response = await this.doFetch(target, {
199
173
  ...init,
200
174
  headers,
201
175
  signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
202
176
  });
203
- if (REASSIGN_ON.has(response.status) && !retried) {
204
- this.reassign();
205
- return this.request(path, init, true);
206
- }
207
177
  const payload = await response.json().catch(() => {
208
178
  return;
209
179
  });
@@ -284,14 +254,28 @@ class ForgeZero {
284
254
  return result.values;
285
255
  }
286
256
  async derived(name, field) {
287
- return this.request(`/v1/entries/${encodeURIComponent(name)}/derived/${encodeURIComponent(field)}${this.scope()}`);
257
+ throw new VaultError("CUSTODY_WORKFLOW_REQUIRED", "Derived custody keys require an explicit platform custody workflow; an application vault credential cannot request them.");
288
258
  }
289
259
  async sign(name, field, digest) {
290
- return this.request(`/v1/entries/${encodeURIComponent(name)}/sign/${encodeURIComponent(field)}${this.scope()}`, { method: "POST", body: JSON.stringify({ digest }) });
260
+ throw new VaultError("CUSTODY_WORKFLOW_REQUIRED", "Signing requires an explicit platform custody workflow with its own policy, approval and audit contract.");
291
261
  }
292
262
  async schemas() {
293
- const payload = await this.request(`/v1/schemas${this.scope()}`);
294
- return payload.schemas ?? [];
263
+ const entries = await this.list();
264
+ const names = entries.map((entry) => entry.name).filter((name) => name.startsWith("__schema__")).map((name) => name.slice("__schema__".length)).filter(Boolean);
265
+ return Promise.all(names.map(async (name) => {
266
+ let value;
267
+ try {
268
+ value = JSON.parse(await this.get(`__schema__${name}`));
269
+ } catch (cause) {
270
+ if (cause instanceof VaultError)
271
+ throw cause;
272
+ throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} is not JSON.`);
273
+ }
274
+ if (!Number.isInteger(value.version) || Number(value.version) < 1) {
275
+ throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} has no valid version.`);
276
+ }
277
+ return { name, version: Number(value.version) };
278
+ }));
295
279
  }
296
280
  async list() {
297
281
  const result = await this.request(`/v1/vault/${this.scope()}/list`);
@@ -362,20 +346,18 @@ function systemdCredentials(options = {}) {
362
346
  }
363
347
  };
364
348
  }
365
- var VERSION = "0.1.1";
349
+ var VERSION = "0.1.2";
366
350
  export {
367
351
  vaultCredentials,
368
352
  systemdCredentials,
369
353
  signerFromApiKey,
370
- isForgeZeroNode,
371
354
  discover,
372
355
  directCredentials,
373
356
  createVault,
374
357
  VaultError,
375
358
  VERSION,
376
- REASSIGN_ON,
377
359
  ForgeZero,
378
360
  DEFAULT_SOCKET,
379
361
  DEFAULT_REQUEST_TIMEOUT_MS,
380
- DEFAULT_ASSIGN_URL
362
+ DEFAULT_API_URL
381
363
  };
package/dist/providers.js CHANGED
@@ -110,32 +110,37 @@ var requestAgent = (socketPath, request) => new Promise((resolveRequest, reject)
110
110
  });
111
111
  socket.on("error", (cause) => reject(new VaultError("AGENT_UNAVAILABLE", cause.message)));
112
112
  });
113
- var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
114
- var DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
113
+ var DEFAULT_API_URL = "https://api.forgezero.net";
115
114
  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);
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
+ }
117
127
 
118
128
  class ForgeZero {
119
129
  credential;
120
- assignment;
121
- assignUrl;
130
+ apiUrl;
122
131
  doFetch;
123
- now;
124
132
  signer;
125
133
  agentTransport;
126
- nodeAllowed;
127
134
  requestTimeoutMs;
128
135
  project;
129
136
  environment;
130
137
  constructor(options = {}) {
131
138
  const credential = options.credential ?? discover(options.discovery);
132
139
  this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
133
- this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
140
+ this.apiUrl = apiOrigin(options.apiUrl ?? DEFAULT_API_URL);
134
141
  this.doFetch = options.fetch ?? globalThis.fetch;
135
- this.now = options.now ?? Date.now;
136
142
  this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
137
143
  this.agentTransport = options.agentTransport ?? requestAgent;
138
- this.nodeAllowed = options.nodeAllowed ?? isForgeZeroNode;
139
144
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
140
145
  if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
141
146
  throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
@@ -143,45 +148,14 @@ class ForgeZero {
143
148
  this.project = options.project ?? "default";
144
149
  this.environment = options.environment ?? "production";
145
150
  }
146
- async node() {
147
- const now = this.now();
148
- if (this.assignment && this.assignment.expiresAt > now)
149
- return this.assignment.node;
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)
158
- });
159
- if (!response.ok)
160
- throw new VaultError("NO_NODE", "No vault node is available right now.");
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;
172
- }
173
- reassign() {
174
- this.assignment = undefined;
175
- }
176
151
  scope() {
177
152
  return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
178
153
  }
179
- async request(path, init = {}, retried = false) {
154
+ async request(path, init = {}) {
180
155
  if (this.credential.mode === "managed")
181
156
  return this.managedRequest(path, init);
182
- const node = await this.node();
183
157
  const body = typeof init.body === "string" ? init.body : "";
184
- const target = new URL(path, "https://vault.invalid");
158
+ const target = new URL(path, this.apiUrl);
185
159
  const headers = {
186
160
  "content-type": "application/json",
187
161
  ...init.headers
@@ -195,15 +169,11 @@ class ForgeZero {
195
169
  body
196
170
  });
197
171
  }
198
- const response = await this.doFetch(`https://${node}${path}`, {
172
+ const response = await this.doFetch(target, {
199
173
  ...init,
200
174
  headers,
201
175
  signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
202
176
  });
203
- if (REASSIGN_ON.has(response.status) && !retried) {
204
- this.reassign();
205
- return this.request(path, init, true);
206
- }
207
177
  const payload = await response.json().catch(() => {
208
178
  return;
209
179
  });
@@ -284,14 +254,28 @@ class ForgeZero {
284
254
  return result.values;
285
255
  }
286
256
  async derived(name, field) {
287
- return this.request(`/v1/entries/${encodeURIComponent(name)}/derived/${encodeURIComponent(field)}${this.scope()}`);
257
+ throw new VaultError("CUSTODY_WORKFLOW_REQUIRED", "Derived custody keys require an explicit platform custody workflow; an application vault credential cannot request them.");
288
258
  }
289
259
  async sign(name, field, digest) {
290
- return this.request(`/v1/entries/${encodeURIComponent(name)}/sign/${encodeURIComponent(field)}${this.scope()}`, { method: "POST", body: JSON.stringify({ digest }) });
260
+ throw new VaultError("CUSTODY_WORKFLOW_REQUIRED", "Signing requires an explicit platform custody workflow with its own policy, approval and audit contract.");
291
261
  }
292
262
  async schemas() {
293
- const payload = await this.request(`/v1/schemas${this.scope()}`);
294
- return payload.schemas ?? [];
263
+ const entries = await this.list();
264
+ const names = entries.map((entry) => entry.name).filter((name) => name.startsWith("__schema__")).map((name) => name.slice("__schema__".length)).filter(Boolean);
265
+ return Promise.all(names.map(async (name) => {
266
+ let value;
267
+ try {
268
+ value = JSON.parse(await this.get(`__schema__${name}`));
269
+ } catch (cause) {
270
+ if (cause instanceof VaultError)
271
+ throw cause;
272
+ throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} is not JSON.`);
273
+ }
274
+ if (!Number.isInteger(value.version) || Number(value.version) < 1) {
275
+ throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} has no valid version.`);
276
+ }
277
+ return { name, version: Number(value.version) };
278
+ }));
295
279
  }
296
280
  async list() {
297
281
  const result = await this.request(`/v1/vault/${this.scope()}/list`);
@@ -362,7 +346,7 @@ function systemdCredentials(options = {}) {
362
346
  }
363
347
  };
364
348
  }
365
- var VERSION = "0.1.1";
349
+ var VERSION = "0.1.2";
366
350
 
367
351
  // src/providers.ts
368
352
  var MISSING = new Set(["ENTRY_NOT_FOUND", "VERSION_NOT_FOUND"]);
package/dist/schema.d.ts CHANGED
@@ -49,9 +49,7 @@ export interface SchemaSource {
49
49
  * platform being up. `refresh` exists so a long-lived process can pick up a
50
50
  * change without restarting.
51
51
  */
52
- export declare function managedSchemas(vault: Pick<ForgeZero, 'get'> & {
53
- schemas?: () => Promise<readonly SchemaRef[]>;
54
- }, options?: {
52
+ export declare function managedSchemas(vault: Pick<ForgeZero, 'get' | 'list'>, options?: {
55
53
  ttlMs?: number;
56
54
  now?: () => number;
57
55
  }): SchemaSource & {
package/dist/schema.js CHANGED
@@ -110,32 +110,37 @@ var requestAgent = (socketPath, request) => new Promise((resolveRequest, reject)
110
110
  });
111
111
  socket.on("error", (cause) => reject(new VaultError("AGENT_UNAVAILABLE", cause.message)));
112
112
  });
113
- var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
114
- var DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
113
+ var DEFAULT_API_URL = "https://api.forgezero.net";
115
114
  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);
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
+ }
117
127
 
118
128
  class ForgeZero {
119
129
  credential;
120
- assignment;
121
- assignUrl;
130
+ apiUrl;
122
131
  doFetch;
123
- now;
124
132
  signer;
125
133
  agentTransport;
126
- nodeAllowed;
127
134
  requestTimeoutMs;
128
135
  project;
129
136
  environment;
130
137
  constructor(options = {}) {
131
138
  const credential = options.credential ?? discover(options.discovery);
132
139
  this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
133
- this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
140
+ this.apiUrl = apiOrigin(options.apiUrl ?? DEFAULT_API_URL);
134
141
  this.doFetch = options.fetch ?? globalThis.fetch;
135
- this.now = options.now ?? Date.now;
136
142
  this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
137
143
  this.agentTransport = options.agentTransport ?? requestAgent;
138
- this.nodeAllowed = options.nodeAllowed ?? isForgeZeroNode;
139
144
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
140
145
  if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
141
146
  throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
@@ -143,45 +148,14 @@ class ForgeZero {
143
148
  this.project = options.project ?? "default";
144
149
  this.environment = options.environment ?? "production";
145
150
  }
146
- async node() {
147
- const now = this.now();
148
- if (this.assignment && this.assignment.expiresAt > now)
149
- return this.assignment.node;
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)
158
- });
159
- if (!response.ok)
160
- throw new VaultError("NO_NODE", "No vault node is available right now.");
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;
172
- }
173
- reassign() {
174
- this.assignment = undefined;
175
- }
176
151
  scope() {
177
152
  return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
178
153
  }
179
- async request(path, init = {}, retried = false) {
154
+ async request(path, init = {}) {
180
155
  if (this.credential.mode === "managed")
181
156
  return this.managedRequest(path, init);
182
- const node = await this.node();
183
157
  const body = typeof init.body === "string" ? init.body : "";
184
- const target = new URL(path, "https://vault.invalid");
158
+ const target = new URL(path, this.apiUrl);
185
159
  const headers = {
186
160
  "content-type": "application/json",
187
161
  ...init.headers
@@ -195,15 +169,11 @@ class ForgeZero {
195
169
  body
196
170
  });
197
171
  }
198
- const response = await this.doFetch(`https://${node}${path}`, {
172
+ const response = await this.doFetch(target, {
199
173
  ...init,
200
174
  headers,
201
175
  signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
202
176
  });
203
- if (REASSIGN_ON.has(response.status) && !retried) {
204
- this.reassign();
205
- return this.request(path, init, true);
206
- }
207
177
  const payload = await response.json().catch(() => {
208
178
  return;
209
179
  });
@@ -284,14 +254,28 @@ class ForgeZero {
284
254
  return result.values;
285
255
  }
286
256
  async derived(name, field) {
287
- return this.request(`/v1/entries/${encodeURIComponent(name)}/derived/${encodeURIComponent(field)}${this.scope()}`);
257
+ throw new VaultError("CUSTODY_WORKFLOW_REQUIRED", "Derived custody keys require an explicit platform custody workflow; an application vault credential cannot request them.");
288
258
  }
289
259
  async sign(name, field, digest) {
290
- return this.request(`/v1/entries/${encodeURIComponent(name)}/sign/${encodeURIComponent(field)}${this.scope()}`, { method: "POST", body: JSON.stringify({ digest }) });
260
+ throw new VaultError("CUSTODY_WORKFLOW_REQUIRED", "Signing requires an explicit platform custody workflow with its own policy, approval and audit contract.");
291
261
  }
292
262
  async schemas() {
293
- const payload = await this.request(`/v1/schemas${this.scope()}`);
294
- return payload.schemas ?? [];
263
+ const entries = await this.list();
264
+ const names = entries.map((entry) => entry.name).filter((name) => name.startsWith("__schema__")).map((name) => name.slice("__schema__".length)).filter(Boolean);
265
+ return Promise.all(names.map(async (name) => {
266
+ let value;
267
+ try {
268
+ value = JSON.parse(await this.get(`__schema__${name}`));
269
+ } catch (cause) {
270
+ if (cause instanceof VaultError)
271
+ throw cause;
272
+ throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} is not JSON.`);
273
+ }
274
+ if (!Number.isInteger(value.version) || Number(value.version) < 1) {
275
+ throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} has no valid version.`);
276
+ }
277
+ return { name, version: Number(value.version) };
278
+ }));
295
279
  }
296
280
  async list() {
297
281
  const result = await this.request(`/v1/vault/${this.scope()}/list`);
@@ -362,7 +346,7 @@ function systemdCredentials(options = {}) {
362
346
  }
363
347
  };
364
348
  }
365
- var VERSION = "0.1.1";
349
+ var VERSION = "0.1.2";
366
350
 
367
351
  // src/schema.ts
368
352
  function managedSchemas(vault, options = {}) {
@@ -392,7 +376,12 @@ function managedSchemas(vault, options = {}) {
392
376
  return value;
393
377
  },
394
378
  async list() {
395
- return await vault.schemas?.() ?? [];
379
+ const entries = await vault.list();
380
+ const names = entries.map((entry) => entry.name).filter((name) => name.startsWith("__schema__")).map((name) => name.slice("__schema__".length)).filter(Boolean);
381
+ return Promise.all(names.map(async (name) => {
382
+ const resolved = await this.get(name);
383
+ return { name, version: resolved.version };
384
+ }));
396
385
  }
397
386
  };
398
387
  }
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.1",
4
+ "version": "0.1.3",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -49,7 +49,7 @@
49
49
  "@noble/post-quantum": "^0.6.1",
50
50
  "@noble/hashes": "^2.2.0"
51
51
  },
52
- "description": "ForgeZero vault client. Credential discovery, versioned secrets, node assignment.",
52
+ "description": "ForgeZero vault client. Credential discovery and versioned secrets through one stable API origin.",
53
53
  "keywords": [
54
54
  "secrets",
55
55
  "vault",