@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 +9 -6
- package/dist/index.d.ts +26 -55
- package/dist/index.js +38 -56
- package/dist/providers.js +37 -53
- package/dist/schema.d.ts +1 -3
- package/dist/schema.js +43 -54
- package/package.json +2 -2
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
|
|
6
|
-
|
|
7
|
-
a
|
|
8
|
-
|
|
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.
|
|
60
|
-
|
|
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
|
-
* ##
|
|
19
|
+
* ## Stable API origin
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
/**
|
|
121
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
159
|
+
* Custody derivation is intentionally not a general application-key action.
|
|
183
160
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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
|
-
*
|
|
192
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 = {}
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 = {}
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 = {}
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
52
|
+
"description": "ForgeZero vault client. Credential discovery and versioned secrets through one stable API origin.",
|
|
53
53
|
"keywords": [
|
|
54
54
|
"secrets",
|
|
55
55
|
"vault",
|