@forgezero/vault 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  *
@@ -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
@@ -34,7 +35,7 @@ export declare class VaultError extends Error {
34
35
  export type CredentialMode = 'managed' | 'external';
35
36
  export interface Credential {
36
37
  mode: CredentialMode;
37
- /** MANAGED only — the agent socket that signs on our behalf. */
38
+ /** MANAGED only — the agent socket serving the environment's RAM replica. */
38
39
  socketPath?: string;
39
40
  /** EXTERNAL only — a seed, never transmitted; a keypair derives from it. */
40
41
  apiKey?: string;
@@ -44,7 +45,14 @@ export interface DiscoveryEnvironment {
44
45
  socketPath?: string;
45
46
  socketExists?: (path: string) => boolean;
46
47
  }
47
- export declare const DEFAULT_SOCKET = "/run/forgezero.sock";
48
+ /**
49
+ * Inside the RuntimeDirectory created by the agent's systemd unit.
50
+ *
51
+ * The old path was `/run/forgezero.sock`, directly under `/run`. The hardened
52
+ * unit runs as an unprivileged user and only receives `/run/forgezero`, so a
53
+ * fresh service could never create the socket its client was looking for.
54
+ */
55
+ export declare const DEFAULT_SOCKET = "/run/forgezero/vault.sock";
48
56
  /**
49
57
  * Find a credential, preferring the stronger posture.
50
58
  *
@@ -53,60 +61,88 @@ export declare const DEFAULT_SOCKET = "/run/forgezero.sock";
53
61
  * machine that holds nothing into one holding a signing seed.
54
62
  */
55
63
  export declare function discover(environment?: DiscoveryEnvironment): Credential;
56
- /** Signs a request. Supplied by the host, so this package holds no crypto. */
64
+ export interface SignRequest {
65
+ method: string;
66
+ path: string;
67
+ query: string;
68
+ body: string;
69
+ }
70
+ /** Hybrid-signs one exact HTTP request. */
57
71
  export interface Signer {
58
72
  readonly keyId: string;
59
- sign(payload: string): Promise<string>;
73
+ sign(request: SignRequest): Promise<string>;
60
74
  }
61
- export interface Assignment {
62
- node: string;
63
- ttl: number;
64
- expiresAt: number;
65
- }
66
- /**
67
- * Statuses meaning "ask for a different node", not "this failed".
68
- *
69
- * 530 is the one people miss: a node whose tunnel is down never reaches the
70
- * application, so Cloudflare answers 1033 with 530.
71
- */
72
- export declare const REASSIGN_ON: Set<number>;
75
+ /** Derive both private halves locally; only the public key id crosses the wire. */
76
+ export declare function signerFromApiKey(secret: string): Signer;
77
+ export type AgentRequest = {
78
+ op: 'get';
79
+ name: string;
80
+ } | {
81
+ op: 'held';
82
+ } | {
83
+ op: 'sync';
84
+ };
85
+ export type AgentResponse = {
86
+ ok: true;
87
+ op: 'get';
88
+ value: string;
89
+ } | {
90
+ ok: true;
91
+ op: 'held';
92
+ names: string[];
93
+ staleForMs: number;
94
+ } | {
95
+ ok: true;
96
+ op: 'sync';
97
+ invalidated: string[];
98
+ cursor: number;
99
+ resync: boolean;
100
+ } | {
101
+ ok: false;
102
+ error: {
103
+ code: string;
104
+ message: string;
105
+ };
106
+ };
107
+ export type AgentTransport = (socketPath: string, request: AgentRequest) => Promise<AgentResponse>;
73
108
  export interface VaultOptions {
74
- /** Directory endpoint. Unauthenticated a node hostname is not a secret. */
75
- assignUrl?: string;
109
+ /** Stable public API origin. Node topology is never exposed to the client. */
110
+ apiUrl?: string;
111
+ requestTimeoutMs?: number;
76
112
  project?: string;
77
113
  environment?: string;
78
114
  fetch?: typeof globalThis.fetch;
79
115
  signer?: Signer;
116
+ agentTransport?: AgentTransport;
80
117
  credential?: Credential;
81
118
  discovery?: DiscoveryEnvironment;
82
- now?: () => number;
83
119
  }
84
120
  export interface EntryMeta {
85
121
  name: string;
86
- version: number;
87
- updatedAtTs: number;
122
+ /** Present on remote API-key reads; the local RAM replica exposes names only. */
123
+ version?: number;
124
+ /** Present on remote API-key reads; the local RAM replica exposes names only. */
125
+ updatedAtTs?: number;
88
126
  }
89
127
  export interface Change {
90
128
  name: string;
91
129
  version: number;
92
130
  }
93
- export declare const DEFAULT_ASSIGN_URL = "https://api.forgezero.net/v1/assign";
131
+ export declare const DEFAULT_API_URL = "https://api.forgezero.net";
132
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
94
133
  export declare class ForgeZero {
95
134
  readonly credential: Credential;
96
- private assignment;
97
- private readonly assignUrl;
135
+ private readonly apiUrl;
98
136
  private readonly doFetch;
99
- private readonly now;
100
137
  private readonly signer?;
138
+ private readonly agentTransport;
139
+ private readonly requestTimeoutMs;
101
140
  private readonly project;
102
141
  private readonly environment;
103
142
  constructor(options?: VaultOptions);
104
- /** The node to talk to, asking the directory only when the lease expired. */
105
- node(): Promise<string>;
106
- /** Drop the lease so the next call asks the directory again. */
107
- reassign(): void;
108
143
  private scope;
109
144
  private request;
145
+ private managedRequest;
110
146
  /** One value, current version unless asked otherwise. */
111
147
  get(name: string, options?: {
112
148
  version?: number;
@@ -161,7 +197,10 @@ export declare class ForgeZero {
161
197
  }[]>;
162
198
  /** Names and metadata. Never values. */
163
199
  list(): Promise<readonly EntryMeta[]>;
164
- /** Write a new version. Nothing is overwritten; the previous stays readable. */
200
+ /**
201
+ * Application credentials are read-only. Secret writes require a human
202
+ * session, step-up proof and a named audit actor on the platform surface.
203
+ */
165
204
  set(name: string, value: string): Promise<number>;
166
205
  remove(name: string): Promise<void>;
167
206
  /**
@@ -200,4 +239,38 @@ export declare function vaultCredentials(vault: ForgeZero): {
200
239
  readonly name: string;
201
240
  get(reference: string, field: string): Promise<string>;
202
241
  };
203
- export declare const VERSION = "0.1.0";
242
+ /**
243
+ * Trusted platform code injects its realm-scoped reader here.
244
+ *
245
+ * The package does not import a database or a master-seed implementation: that
246
+ * would force every tenant application to depend on ForgeZero internals. The
247
+ * API already has the unlocked realm seed and supplies a function that opens
248
+ * exactly the requested provider field in-process, without calling itself over
249
+ * HTTP or replicating plaintext beside itself.
250
+ */
251
+ export declare function directCredentials(read: (reference: string, field: string) => Promise<string>): {
252
+ readonly name: 'platform-direct';
253
+ get(reference: string, field: string): Promise<string>;
254
+ };
255
+ export interface SystemdCredentialOptions {
256
+ /** Defaults to the private tmpfs directory PID 1 gives this unit. */
257
+ directory?: string;
258
+ /** Map a provider reference/field to the credential name declared by the unit. */
259
+ nameFor?: (reference: string, field: string) => string;
260
+ /** Test/runtime injection; returns undefined when absent. */
261
+ read?: (path: string) => string | undefined;
262
+ }
263
+ /**
264
+ * Bootstrap/recovery credentials loaded by systemd into read-only tmpfs.
265
+ *
266
+ * Explicit rather than part of `discover()`: an application must never
267
+ * downgrade from an attested agent to whatever unrelated credential files its
268
+ * unit happens to expose. Callers compose this behind the direct vault source
269
+ * with `chainCredentials()` only for values deliberately declared as bootstrap
270
+ * roots.
271
+ */
272
+ export declare function systemdCredentials(options?: SystemdCredentialOptions): {
273
+ readonly name: 'systemd';
274
+ get(reference: string, field: string): Promise<string>;
275
+ };
276
+ export declare const VERSION = "0.1.2";
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,76 +9,171 @@ class VaultError extends Error {
7
9
  this.name = "VaultError";
8
10
  }
9
11
  }
10
- var DEFAULT_SOCKET = "/run/forgezero.sock";
12
+ var runtimeEnvironment = () => typeof process !== "undefined" && process.env ? process.env : {};
13
+ var runtimeSocketExists = (path) => {
14
+ if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
15
+ return false;
16
+ try {
17
+ const fs = process.getBuiltinModule("node:fs");
18
+ return fs.existsSync(path);
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+ var DEFAULT_SOCKET = "/run/forgezero/vault.sock";
11
24
  function discover(environment = {}) {
12
- const env = environment.env ?? {};
25
+ const env = environment.env ?? runtimeEnvironment();
13
26
  const socketPath = environment.socketPath ?? env.FORGEZERO_SOCKET ?? DEFAULT_SOCKET;
14
- if (environment.socketExists?.(socketPath))
27
+ if ((environment.socketExists ?? runtimeSocketExists)(socketPath))
15
28
  return { mode: "managed", socketPath };
16
29
  const apiKey = env.FORGEZERO_API_KEY;
17
30
  if (apiKey)
18
31
  return { mode: "external", apiKey };
19
32
  throw new VaultError("NO_CREDENTIAL", `No credential found. On managed compute the agent socket at ${socketPath} provides one; ` + "elsewhere set FORGEZERO_API_KEY.");
20
33
  }
21
- var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
22
- var DEFAULT_ASSIGN_URL = "https://api.forgezero.net/v1/assign";
34
+ var unbase64url = (encoded) => {
35
+ const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - encoded.length % 4) % 4);
36
+ let binary;
37
+ try {
38
+ binary = atob(padded);
39
+ } catch {
40
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
41
+ }
42
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
43
+ };
44
+ var base64url = (value) => {
45
+ const bytes = new TextEncoder().encode(value);
46
+ let binary = "";
47
+ for (const byte of bytes)
48
+ binary += String.fromCharCode(byte);
49
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
50
+ };
51
+ function signerFromApiKey(secret) {
52
+ const parts = secret.split(".");
53
+ const legacy = parts.length === 3 && parts[0] === "fz";
54
+ const current = parts.length === 4 && parts[0] === "fz" && (parts[1] === "live" || parts[1] === "test");
55
+ if (!legacy && !current)
56
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
57
+ const keyId = parts[current ? 2 : 1];
58
+ const encodedSeed = parts[current ? 3 : 2];
59
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(keyId) || !/^[A-Za-z0-9_-]{43}$/.test(encodedSeed)) {
60
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
61
+ }
62
+ const seed = unbase64url(encodedSeed);
63
+ if (seed.length !== 32)
64
+ throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
65
+ const keys = deriveKeysFromSeed(seed);
66
+ seed.fill(0);
67
+ return {
68
+ keyId,
69
+ async sign(request) {
70
+ const envelope = signRequest(keys, keyId, request);
71
+ return base64url(JSON.stringify({
72
+ timestamp: envelope.timestamp,
73
+ nonce: envelope.nonce,
74
+ edSignature: envelope.edSignature,
75
+ mlDsaSignature: envelope.mlDsaSignature
76
+ }));
77
+ }
78
+ };
79
+ }
80
+ var requestAgent = (socketPath, request) => new Promise((resolveRequest, reject) => {
81
+ if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function") {
82
+ reject(new VaultError("MANAGED_RUNTIME_UNAVAILABLE", "The managed agent socket needs a Node or Bun server runtime."));
83
+ return;
84
+ }
85
+ const net = process.getBuiltinModule("node:net");
86
+ const socket = net.connect(socketPath, () => socket.write(`${JSON.stringify(request)}
87
+ `));
88
+ let buffer = "";
89
+ socket.setTimeout(15000, () => {
90
+ socket.destroy();
91
+ reject(new VaultError("AGENT_TIMEOUT", "The local ForgeZero agent did not answer."));
92
+ });
93
+ socket.on("data", (chunk) => {
94
+ buffer += chunk.toString("utf8");
95
+ if (new TextEncoder().encode(buffer).byteLength > 256 * 1024) {
96
+ socket.destroy();
97
+ reject(new VaultError("AGENT_RESPONSE_TOO_LARGE", "The local ForgeZero agent response was too large."));
98
+ return;
99
+ }
100
+ const newline = buffer.indexOf(`
101
+ `);
102
+ if (newline < 0)
103
+ return;
104
+ socket.end();
105
+ try {
106
+ resolveRequest(JSON.parse(buffer.slice(0, newline)));
107
+ } catch {
108
+ reject(new VaultError("AGENT_RESPONSE_MALFORMED", "The local ForgeZero agent response was malformed."));
109
+ }
110
+ });
111
+ socket.on("error", (cause) => reject(new VaultError("AGENT_UNAVAILABLE", cause.message)));
112
+ });
113
+ var DEFAULT_API_URL = "https://api.forgezero.net";
114
+ var DEFAULT_REQUEST_TIMEOUT_MS = 15000;
115
+ function apiOrigin(value) {
116
+ let url;
117
+ try {
118
+ url = new URL(value);
119
+ } catch {
120
+ throw new VaultError("API_URL_INVALID", "apiUrl must be an absolute HTTPS origin.");
121
+ }
122
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
123
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:") || url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "")
124
+ throw new VaultError("API_URL_INVALID", "apiUrl must be an HTTPS origin without credentials, path, query or fragment.");
125
+ return url;
126
+ }
23
127
 
24
128
  class ForgeZero {
25
129
  credential;
26
- assignment;
27
- assignUrl;
130
+ apiUrl;
28
131
  doFetch;
29
- now;
30
132
  signer;
133
+ agentTransport;
134
+ requestTimeoutMs;
31
135
  project;
32
136
  environment;
33
137
  constructor(options = {}) {
34
- this.credential = options.credential ?? discover(options.discovery);
35
- this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
138
+ const credential = options.credential ?? discover(options.discovery);
139
+ this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
140
+ this.apiUrl = apiOrigin(options.apiUrl ?? DEFAULT_API_URL);
36
141
  this.doFetch = options.fetch ?? globalThis.fetch;
37
- this.now = options.now ?? Date.now;
38
- this.signer = options.signer;
142
+ this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
143
+ this.agentTransport = options.agentTransport ?? requestAgent;
144
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
145
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
146
+ throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
147
+ }
39
148
  this.project = options.project ?? "default";
40
149
  this.environment = options.environment ?? "production";
41
150
  }
42
- async node() {
43
- const now = this.now();
44
- if (this.assignment && this.assignment.expiresAt > now)
45
- return this.assignment.node;
46
- const response = await this.doFetch(this.assignUrl, {
47
- method: "POST",
48
- headers: { "content-type": "application/json" },
49
- body: JSON.stringify({ keyId: this.signer?.keyId })
50
- });
51
- if (!response.ok)
52
- throw new VaultError("NO_NODE", "No vault node is available right now.");
53
- const payload = await response.json();
54
- const ttl = payload.ttl ?? 60;
55
- this.assignment = { node: payload.node, ttl, expiresAt: now + ttl * 1000 };
56
- return payload.node;
57
- }
58
- reassign() {
59
- this.assignment = undefined;
60
- }
61
151
  scope() {
62
152
  return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
63
153
  }
64
- async request(path, init = {}, retried = false) {
65
- const node = await this.node();
154
+ async request(path, init = {}) {
155
+ if (this.credential.mode === "managed")
156
+ return this.managedRequest(path, init);
66
157
  const body = typeof init.body === "string" ? init.body : "";
158
+ const target = new URL(path, this.apiUrl);
67
159
  const headers = {
68
160
  "content-type": "application/json",
69
161
  ...init.headers
70
162
  };
71
163
  if (this.signer) {
72
164
  headers["x-fz-key"] = this.signer.keyId;
73
- headers["x-fz-signature"] = await this.signer.sign(`${init.method ?? "GET"}:${path}:${body}`);
74
- }
75
- const response = await this.doFetch(`https://${node}${path}`, { ...init, headers });
76
- if (REASSIGN_ON.has(response.status) && !retried) {
77
- this.reassign();
78
- return this.request(path, init, true);
165
+ headers["x-fz-signature"] = await this.signer.sign({
166
+ method: init.method ?? "GET",
167
+ path: target.pathname,
168
+ query: target.search.replace(/^\?/, ""),
169
+ body
170
+ });
79
171
  }
172
+ const response = await this.doFetch(target, {
173
+ ...init,
174
+ headers,
175
+ signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
176
+ });
80
177
  const payload = await response.json().catch(() => {
81
178
  return;
82
179
  });
@@ -85,7 +182,66 @@ class ForgeZero {
85
182
  }
86
183
  return payload;
87
184
  }
185
+ async managedRequest(path, init) {
186
+ const socketPath = this.credential.socketPath;
187
+ const method = init.method ?? "GET";
188
+ const target = new URL(path, "https://vault.invalid");
189
+ const entry = /\/entries\/([^/]+)$/.exec(target.pathname);
190
+ let response;
191
+ if (method === "GET" && entry) {
192
+ response = await this.agentTransport(socketPath, {
193
+ op: "get",
194
+ name: decodeURIComponent(entry[1])
195
+ });
196
+ if (!response.ok)
197
+ throw new VaultError(response.error.code, response.error.message);
198
+ if (response.op !== "get")
199
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
200
+ return { value: response.value };
201
+ }
202
+ if (method === "GET" && target.pathname.endsWith("/list")) {
203
+ response = await this.agentTransport(socketPath, { op: "held" });
204
+ if (!response.ok)
205
+ throw new VaultError(response.error.code, response.error.message);
206
+ if (response.op !== "held")
207
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
208
+ return { entries: response.names.map((name) => ({ name })) };
209
+ }
210
+ if (method === "GET" && target.pathname.endsWith("/entries")) {
211
+ response = await this.agentTransport(socketPath, { op: "held" });
212
+ if (!response.ok)
213
+ throw new VaultError(response.error.code, response.error.message);
214
+ if (response.op !== "held")
215
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
216
+ const values = {};
217
+ for (const name of response.names) {
218
+ const read = await this.agentTransport(socketPath, { op: "get", name });
219
+ if (!read.ok)
220
+ throw new VaultError(read.error.code, read.error.message);
221
+ if (read.op !== "get")
222
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
223
+ values[name] = read.value;
224
+ }
225
+ return { values };
226
+ }
227
+ if (method === "GET" && target.pathname.endsWith("/changes")) {
228
+ response = await this.agentTransport(socketPath, { op: "sync" });
229
+ if (!response.ok)
230
+ throw new VaultError(response.error.code, response.error.message);
231
+ if (response.op !== "sync")
232
+ throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
233
+ return {
234
+ version: response.cursor,
235
+ changed: response.invalidated.map((name) => ({ name, version: 0 })),
236
+ resync: response.resync
237
+ };
238
+ }
239
+ throw new VaultError("MANAGED_READ_ONLY", "This operation is not exposed to an application through the managed agent socket.");
240
+ }
88
241
  async get(name, options = {}) {
242
+ if (this.credential.mode === "managed" && options.version !== undefined) {
243
+ throw new VaultError("MANAGED_CURRENT_ONLY", "The managed agent RAM replica serves the current value only; use an API key for a historical version.");
244
+ }
89
245
  const query = options.version ? `?version=${options.version}` : "";
90
246
  const result = await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}${query}`);
91
247
  return result.value;
@@ -112,16 +268,10 @@ class ForgeZero {
112
268
  return result.entries;
113
269
  }
114
270
  async set(name, value) {
115
- const result = await this.request(`/v1/vault/${this.scope()}/entries`, {
116
- method: "POST",
117
- body: JSON.stringify({ name, value })
118
- });
119
- return result.version;
271
+ throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot write secrets; use the authenticated platform vault UI.");
120
272
  }
121
273
  async remove(name) {
122
- await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}`, {
123
- method: "DELETE"
124
- });
274
+ throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot delete secrets; use the authenticated platform vault UI.");
125
275
  }
126
276
  async* watch(options = {}) {
127
277
  const interval = options.intervalMs ?? 15000;
@@ -146,15 +296,54 @@ function vaultCredentials(vault) {
146
296
  get: (reference, field) => vault.get(`${reference}.${field}`)
147
297
  };
148
298
  }
149
- var VERSION = "0.1.0";
299
+ function directCredentials(read) {
300
+ return { name: "platform-direct", get: read };
301
+ }
302
+ var readRuntimeCredential = (path) => {
303
+ if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
304
+ return;
305
+ try {
306
+ const fs = process.getBuiltinModule("node:fs");
307
+ const stat = fs.statSync(path);
308
+ if (!stat.isFile() || stat.size > 1024 * 1024)
309
+ return;
310
+ return fs.readFileSync(path, "utf8");
311
+ } catch {
312
+ return;
313
+ }
314
+ };
315
+ function systemdCredentials(options = {}) {
316
+ return {
317
+ name: "systemd",
318
+ async get(reference, field) {
319
+ const directory = options.directory ?? (typeof process !== "undefined" ? process.env?.CREDENTIALS_DIRECTORY : undefined);
320
+ if (!directory) {
321
+ throw new VaultError("CREDENTIAL_MISSING", "systemd did not provide CREDENTIALS_DIRECTORY to this unit.");
322
+ }
323
+ const name = (options.nameFor ?? ((ref, key) => `${ref}.${key}`))(reference, field);
324
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name)) {
325
+ throw new VaultError("CREDENTIAL_NAME_INVALID", "A systemd credential name may contain only letters, digits, dot, underscore and hyphen.");
326
+ }
327
+ const value = (options.read ?? readRuntimeCredential)(`${directory}/${name}`);
328
+ if (value === undefined || value.length === 0) {
329
+ throw new VaultError("CREDENTIAL_MISSING", `The systemd credential ${name} is not available to this unit.`);
330
+ }
331
+ return value;
332
+ }
333
+ };
334
+ }
335
+ var VERSION = "0.1.2";
150
336
  export {
151
337
  vaultCredentials,
338
+ systemdCredentials,
339
+ signerFromApiKey,
152
340
  discover,
341
+ directCredentials,
153
342
  createVault,
154
343
  VaultError,
155
344
  VERSION,
156
- REASSIGN_ON,
157
345
  ForgeZero,
158
346
  DEFAULT_SOCKET,
159
- DEFAULT_ASSIGN_URL
347
+ DEFAULT_REQUEST_TIMEOUT_MS,
348
+ DEFAULT_API_URL
160
349
  };