@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/README.md CHANGED
@@ -2,9 +2,10 @@
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 asks a directory which node to talk
6
- to. There is no endpoint to configure and no `.env` to leak — the same code runs
7
- on a laptop and in production, and the laptop holds nothing worth stealing.
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.
8
9
 
9
10
  This one talks to a ForgeZero vault, so it needs an account. The other three
10
11
  packages do not.
@@ -19,7 +20,7 @@ import { ForgeZero } from '@forgezero/vault';
19
20
  const fz = new ForgeZero({ project: 'altpilot', environment: 'production' });
20
21
 
21
22
  const key = await fz.get('STRIPE_KEY');
22
- const names = await fz.list(); // names and metadata, never values
23
+ const names = await fz.list(); // names; remote reads also include metadata
23
24
  ```
24
25
 
25
26
  ## Envless
@@ -33,12 +34,12 @@ import '@forgezero/vault/env'; // refuses to run during a build
33
34
  Rotation stops being a project. A new version is written, readers pick it up on
34
35
  their next fetch, and nothing is redeployed.
35
36
 
36
- ## Two transports, and the socket wins
37
+ ## Two application transports, and the socket wins
37
38
 
38
- On a ForgeZero compute the client speaks to a unix socket held by the local
39
- agent, which is attested and never leaves the machine. Anywhere else it uses an
40
- API key over HTTPS. When both are present the socket wins, because the one that
41
- never crosses a network is the one to prefer.
39
+ On a ForgeZero compute the client reads the environment's in-memory replica over
40
+ a Unix socket held by the local agent. Anywhere else it derives a hybrid signer
41
+ from an API key and reads over HTTPS. When both are present the socket wins,
42
+ because the application then holds no reusable remote credential.
42
43
 
43
44
  ## The API key is a seed, not a password
44
45
 
@@ -55,14 +56,14 @@ An entry can declare fields the tenant never holds. Two custody modes:
55
56
  exists in plaintext only inside a signing call.
56
57
  - **supplied** — you generate it, the vault seals it.
57
58
 
58
- Either way there is no call that returns a private key. `derived()` gives you an
59
- address or a public key; `sign()` gives you a signature.
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
61
 
61
62
  ## Subpaths
62
63
 
63
64
  | import | what it is |
64
65
  |---|---|
65
- | `@forgezero/vault` | the client — get, list, write, rotate, `derived`, `sign` |
66
+ | `@forgezero/vault` | the read-only application client — get, list and watch rotation |
66
67
  | `/env` | envless: fill `process.env` at boot |
67
68
  | `/config` | read `.fz/config.json` — project, environment, which secrets |
68
69
  | `/schema` | where an entry's shape comes from: pulled from the platform, or local |
@@ -74,9 +75,9 @@ address or a public key; `sign()` gives you a signature.
74
75
  help. `BLOCKED` — access was cut deliberately and lifts just as fast.
75
76
  `NO_CREDENTIAL` — neither a socket nor an API key was found.
76
77
 
77
- Full documentation: **https://forgezero.net/docs/vault-package**
78
+ Full documentation: **https://www.forgezero.net/docs/vault-package**
78
79
 
79
80
  ## Licence
80
81
 
81
- MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
82
+ MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
82
83
  deploys.
package/dist/index.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @forgezero/vault — the client.
3
3
  *
4
- * Zero runtime dependencies. Credential discovery, versioned reads and writes,
5
- * rotation via `watch`.
4
+ * One audited ForgeZero identity dependency. Credential discovery, current and
5
+ * versioned reads, and rotation via `watch`.
6
6
  *
7
7
  * ## Discovery, and why it matters more than it looks
8
8
  *
9
- * /run/forgezero.sock exists → MANAGED: the agent signs, the app holds
10
- * nothing at all
9
+ * /run/forgezero/vault.sock exists → MANAGED: read the agent's RAM replica;
10
+ * the app holds no remote credential
11
11
  * FORGEZERO_API_KEY present → EXTERNAL: derive a keypair locally, sign here
12
12
  * neither → throw, naming BOTH so the fix is obvious
13
13
  *
@@ -34,7 +34,7 @@ export declare class VaultError extends Error {
34
34
  export type CredentialMode = 'managed' | 'external';
35
35
  export interface Credential {
36
36
  mode: CredentialMode;
37
- /** MANAGED only — the agent socket that signs on our behalf. */
37
+ /** MANAGED only — the agent socket serving the environment's RAM replica. */
38
38
  socketPath?: string;
39
39
  /** EXTERNAL only — a seed, never transmitted; a keypair derives from it. */
40
40
  apiKey?: string;
@@ -44,7 +44,14 @@ export interface DiscoveryEnvironment {
44
44
  socketPath?: string;
45
45
  socketExists?: (path: string) => boolean;
46
46
  }
47
- export declare const DEFAULT_SOCKET = "/run/forgezero.sock";
47
+ /**
48
+ * Inside the RuntimeDirectory created by the agent's systemd unit.
49
+ *
50
+ * The old path was `/run/forgezero.sock`, directly under `/run`. The hardened
51
+ * unit runs as an unprivileged user and only receives `/run/forgezero`, so a
52
+ * fresh service could never create the socket its client was looking for.
53
+ */
54
+ export declare const DEFAULT_SOCKET = "/run/forgezero/vault.sock";
48
55
  /**
49
56
  * Find a credential, preferring the stronger posture.
50
57
  *
@@ -53,11 +60,50 @@ export declare const DEFAULT_SOCKET = "/run/forgezero.sock";
53
60
  * machine that holds nothing into one holding a signing seed.
54
61
  */
55
62
  export declare function discover(environment?: DiscoveryEnvironment): Credential;
56
- /** Signs a request. Supplied by the host, so this package holds no crypto. */
63
+ export interface SignRequest {
64
+ method: string;
65
+ path: string;
66
+ query: string;
67
+ body: string;
68
+ }
69
+ /** Hybrid-signs one exact HTTP request. */
57
70
  export interface Signer {
58
71
  readonly keyId: string;
59
- sign(payload: string): Promise<string>;
72
+ sign(request: SignRequest): Promise<string>;
60
73
  }
74
+ /** Derive both private halves locally; only the public key id crosses the wire. */
75
+ export declare function signerFromApiKey(secret: string): Signer;
76
+ export type AgentRequest = {
77
+ op: 'get';
78
+ name: string;
79
+ } | {
80
+ op: 'held';
81
+ } | {
82
+ op: 'sync';
83
+ };
84
+ export type AgentResponse = {
85
+ ok: true;
86
+ op: 'get';
87
+ value: string;
88
+ } | {
89
+ ok: true;
90
+ op: 'held';
91
+ names: string[];
92
+ staleForMs: number;
93
+ } | {
94
+ ok: true;
95
+ op: 'sync';
96
+ invalidated: string[];
97
+ cursor: number;
98
+ resync: boolean;
99
+ } | {
100
+ ok: false;
101
+ error: {
102
+ code: string;
103
+ message: string;
104
+ };
105
+ };
106
+ export type AgentTransport = (socketPath: string, request: AgentRequest) => Promise<AgentResponse>;
61
107
  export interface Assignment {
62
108
  node: string;
63
109
  ttl: number;
@@ -73,24 +119,33 @@ export declare const REASSIGN_ON: Set<number>;
73
119
  export interface VaultOptions {
74
120
  /** Directory endpoint. Unauthenticated — a node hostname is not a secret. */
75
121
  assignUrl?: string;
122
+ /** Override only for a private ForgeZero installation with its own node domain. */
123
+ nodeAllowed?: (hostname: string) => boolean;
124
+ requestTimeoutMs?: number;
76
125
  project?: string;
77
126
  environment?: string;
78
127
  fetch?: typeof globalThis.fetch;
79
128
  signer?: Signer;
129
+ agentTransport?: AgentTransport;
80
130
  credential?: Credential;
81
131
  discovery?: DiscoveryEnvironment;
82
132
  now?: () => number;
83
133
  }
84
134
  export interface EntryMeta {
85
135
  name: string;
86
- version: number;
87
- updatedAtTs: number;
136
+ /** Present on remote API-key reads; the local RAM replica exposes names only. */
137
+ version?: number;
138
+ /** Present on remote API-key reads; the local RAM replica exposes names only. */
139
+ updatedAtTs?: number;
88
140
  }
89
141
  export interface Change {
90
142
  name: string;
91
143
  version: number;
92
144
  }
93
- export declare const DEFAULT_ASSIGN_URL = "https://api.forgezero.net/v1/assign";
145
+ export declare const DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
146
+ 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;
94
149
  export declare class ForgeZero {
95
150
  readonly credential: Credential;
96
151
  private assignment;
@@ -98,6 +153,9 @@ export declare class ForgeZero {
98
153
  private readonly doFetch;
99
154
  private readonly now;
100
155
  private readonly signer?;
156
+ private readonly agentTransport;
157
+ private readonly nodeAllowed;
158
+ private readonly requestTimeoutMs;
101
159
  private readonly project;
102
160
  private readonly environment;
103
161
  constructor(options?: VaultOptions);
@@ -107,6 +165,7 @@ export declare class ForgeZero {
107
165
  reassign(): void;
108
166
  private scope;
109
167
  private request;
168
+ private managedRequest;
110
169
  /** One value, current version unless asked otherwise. */
111
170
  get(name: string, options?: {
112
171
  version?: number;
@@ -161,7 +220,10 @@ export declare class ForgeZero {
161
220
  }[]>;
162
221
  /** Names and metadata. Never values. */
163
222
  list(): Promise<readonly EntryMeta[]>;
164
- /** Write a new version. Nothing is overwritten; the previous stays readable. */
223
+ /**
224
+ * Application credentials are read-only. Secret writes require a human
225
+ * session, step-up proof and a named audit actor on the platform surface.
226
+ */
165
227
  set(name: string, value: string): Promise<number>;
166
228
  remove(name: string): Promise<void>;
167
229
  /**
@@ -200,4 +262,38 @@ export declare function vaultCredentials(vault: ForgeZero): {
200
262
  readonly name: string;
201
263
  get(reference: string, field: string): Promise<string>;
202
264
  };
203
- export declare const VERSION = "0.1.0";
265
+ /**
266
+ * Trusted platform code injects its realm-scoped reader here.
267
+ *
268
+ * The package does not import a database or a master-seed implementation: that
269
+ * would force every tenant application to depend on ForgeZero internals. The
270
+ * API already has the unlocked realm seed and supplies a function that opens
271
+ * exactly the requested provider field in-process, without calling itself over
272
+ * HTTP or replicating plaintext beside itself.
273
+ */
274
+ export declare function directCredentials(read: (reference: string, field: string) => Promise<string>): {
275
+ readonly name: 'platform-direct';
276
+ get(reference: string, field: string): Promise<string>;
277
+ };
278
+ export interface SystemdCredentialOptions {
279
+ /** Defaults to the private tmpfs directory PID 1 gives this unit. */
280
+ directory?: string;
281
+ /** Map a provider reference/field to the credential name declared by the unit. */
282
+ nameFor?: (reference: string, field: string) => string;
283
+ /** Test/runtime injection; returns undefined when absent. */
284
+ read?: (path: string) => string | undefined;
285
+ }
286
+ /**
287
+ * Bootstrap/recovery credentials loaded by systemd into read-only tmpfs.
288
+ *
289
+ * Explicit rather than part of `discover()`: an application must never
290
+ * downgrade from an attested agent to whatever unrelated credential files its
291
+ * unit happens to expose. Callers compose this behind the direct vault source
292
+ * with `chainCredentials()` only for values deliberately declared as bootstrap
293
+ * roots.
294
+ */
295
+ export declare function systemdCredentials(options?: SystemdCredentialOptions): {
296
+ readonly name: 'systemd';
297
+ get(reference: string, field: string): Promise<string>;
298
+ };
299
+ export declare const VERSION = "0.1.1";
package/dist/index.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,15 +326,56 @@ 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
  export {
151
367
  vaultCredentials,
368
+ systemdCredentials,
369
+ signerFromApiKey,
370
+ isForgeZeroNode,
152
371
  discover,
372
+ directCredentials,
153
373
  createVault,
154
374
  VaultError,
155
375
  VERSION,
156
376
  REASSIGN_ON,
157
377
  ForgeZero,
158
378
  DEFAULT_SOCKET,
379
+ DEFAULT_REQUEST_TIMEOUT_MS,
159
380
  DEFAULT_ASSIGN_URL
160
381
  };