@forgezero/vault 0.1.0

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.
@@ -0,0 +1,91 @@
1
+ import { type SecretReader, type HydrateReport } from './env';
2
+ import { type FzConfig } from './config';
3
+ /** Tests, and a process that has genuinely rotated its own credential. */
4
+ export declare const resetHydration: () => void;
5
+ export interface SetupOptions {
6
+ /** Pre-loaded config. Omit to find `.fz/config.json` by walking up. */
7
+ config?: FzConfig;
8
+ vault: SecretReader;
9
+ cwd?: string;
10
+ readFile?: (path: string) => string | undefined;
11
+ env?: Record<string, string | undefined>;
12
+ /** Defaults to a single line naming counts and variable names, never values. */
13
+ log?: (line: string) => void;
14
+ onReport?: (report: HydrateReport) => void;
15
+ }
16
+ /**
17
+ * Hydrate exactly once per process, whoever asks first.
18
+ *
19
+ * The memo is on the PROMISE, not on a completed flag: two requests arriving
20
+ * during startup would otherwise both see "not done yet" and both fetch.
21
+ */
22
+ export declare function setupEnv(options: SetupOptions): Promise<HydrateReport>;
23
+ export interface SvelteKitHandleEvent {
24
+ event: unknown;
25
+ resolve: (event: unknown) => unknown;
26
+ }
27
+ /**
28
+ * A SvelteKit `handle` hook.
29
+ *
30
+ * // src/hooks.server.ts
31
+ * import { forgeZeroHandle } from '@forgezero/vault/frameworks';
32
+ * import { createVault } from '@forgezero/vault';
33
+ * import { readFileSync, existsSync } from 'node:fs';
34
+ *
35
+ * export const handle = forgeZeroHandle({
36
+ * vault: createVault(),
37
+ * readFile: (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined)
38
+ * });
39
+ *
40
+ * Compose it with `sequence()` if you already have hooks — it must come FIRST,
41
+ * because everything after it expects the environment to be populated.
42
+ *
43
+ * Read secrets through `$env/dynamic/private`, never `$env/static/private`. The
44
+ * static form is substituted at build and the value ends up in a file on disk;
45
+ * the dynamic form reads `process.env` at request time, which is what this
46
+ * fills.
47
+ */
48
+ export declare function forgeZeroHandle(options: SetupOptions): ({ event, resolve }: SvelteKitHandleEvent) => Promise<unknown>;
49
+ /**
50
+ * A SvelteKit `init` hook, for versions that have one.
51
+ *
52
+ * Better than `handle` where available: it runs before the first request rather
53
+ * than during it, so a missing secret fails the server at start instead of
54
+ * turning the first visitor into the person who discovers it.
55
+ */
56
+ export declare function forgeZeroInit(options: SetupOptions): () => Promise<void>;
57
+ /**
58
+ * A Next.js instrumentation hook.
59
+ *
60
+ * // instrumentation.ts
61
+ * import { forgeZeroRegister } from '@forgezero/vault/frameworks';
62
+ * import { createVault } from '@forgezero/vault';
63
+ * import { readFileSync, existsSync } from 'node:fs';
64
+ *
65
+ * export const register = forgeZeroRegister({
66
+ * vault: createVault(),
67
+ * readFile: (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined)
68
+ * });
69
+ *
70
+ * `instrumentation.ts` is the only place Next.js runs code once per server
71
+ * process before any request. Do NOT use the `env` block in `next.config.js`:
72
+ * it is substituted at build time and, for anything a client component touches,
73
+ * substituted into the CLIENT bundle.
74
+ *
75
+ * The guard below is why this is not just a re-export of `setupEnv`. Next runs
76
+ * `register` in the edge runtime too, where there is no filesystem and no vault
77
+ * socket — hydrating there would fail the whole build with an error pointing at
78
+ * the wrong thing entirely.
79
+ */
80
+ export declare function forgeZeroRegister(options: SetupOptions): () => Promise<void>;
81
+ /**
82
+ * For a plain Bun or Node process, before anything else imports.
83
+ *
84
+ * await bootstrapEnv({ vault: createVault(), readFile });
85
+ * const { start } = await import('./server');
86
+ *
87
+ * The dynamic import is the point: a top-level `import './server'` is hoisted
88
+ * above this call, so the server module would read `process.env` before it was
89
+ * filled. That ordering trap is subtle enough to be worth the example.
90
+ */
91
+ export declare function bootstrapEnv(options: SetupOptions): Promise<HydrateReport>;
@@ -0,0 +1,240 @@
1
+ // src/config.ts
2
+ class ConfigError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "ConfigError";
8
+ }
9
+ }
10
+ var CONFIG_PATHS = [".fz/config.json", "fz.config.json"];
11
+ var FORBIDDEN_KEYS = ["token", "apikey", "api_key", "secret", "password", "credential", "key"];
12
+ function assertNoSecrets(raw, path = "") {
13
+ for (const [name, value] of Object.entries(raw)) {
14
+ const lowered = name.toLowerCase();
15
+ if (name !== "secrets" && FORBIDDEN_KEYS.some((word) => lowered.includes(word))) {
16
+ throw new ConfigError("CONFIG_HAS_SECRET", `"${path}${name}" looks like a credential. This file is committed — the credential comes from the agent socket or FORGEZERO_API_KEY, never from here.`);
17
+ }
18
+ if (value && typeof value === "object" && !Array.isArray(value)) {
19
+ assertNoSecrets(value, `${path}${name}.`);
20
+ }
21
+ }
22
+ }
23
+ function parseConfig(text) {
24
+ let raw;
25
+ try {
26
+ raw = JSON.parse(text);
27
+ } catch (cause) {
28
+ throw new ConfigError("CONFIG_INVALID", `Not valid JSON: ${cause instanceof Error ? cause.message : "parse failed"}`);
29
+ }
30
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
31
+ throw new ConfigError("CONFIG_INVALID", "The config must be a JSON object.");
32
+ }
33
+ const config = raw;
34
+ assertNoSecrets(config);
35
+ for (const field of ["project", "environment"]) {
36
+ if (typeof config[field] !== "string" || config[field].length === 0) {
37
+ throw new ConfigError("CONFIG_INVALID", `"${field}" is required and must be a non-empty string.`);
38
+ }
39
+ }
40
+ if (!Array.isArray(config.secrets)) {
41
+ throw new ConfigError("CONFIG_INVALID", '"secrets" is required. Naming what this service needs is the point — a process that pulls everything it can reach holds credentials it never uses.');
42
+ }
43
+ for (const entry of config.secrets) {
44
+ const key = typeof entry === "string" ? entry : entry?.entry;
45
+ if (typeof key !== "string" || key.length === 0) {
46
+ throw new ConfigError("CONFIG_INVALID", "Every secret must be a name, or an object with `entry`.");
47
+ }
48
+ }
49
+ return config;
50
+ }
51
+ function resolveConfig(config, environment) {
52
+ const target = environment ?? config.environment;
53
+ const overrides = config.environments?.[target];
54
+ if (environment && config.environments && !overrides) {
55
+ const known = Object.keys(config.environments).join(", ");
56
+ throw new ConfigError("UNKNOWN_ENVIRONMENT", `"${target}" is not declared in this config. Known environments: ${known || "none"}.`);
57
+ }
58
+ const { environments: _dropped, ...base } = config;
59
+ return { ...base, ...overrides, environment: target };
60
+ }
61
+ function bindingsOf(config) {
62
+ const prefix = config.prefix ?? "";
63
+ return config.secrets.map((entry) => {
64
+ const binding = typeof entry === "string" ? { entry } : entry;
65
+ return {
66
+ entry: binding.entry,
67
+ as: `${prefix}${binding.as ?? binding.entry}`,
68
+ required: binding.required ?? true
69
+ };
70
+ });
71
+ }
72
+ function loadConfig(options = {}) {
73
+ const read = options.readFile;
74
+ if (!read) {
75
+ throw new ConfigError("CONFIG_NOT_FOUND", "loadConfig needs a readFile implementation.");
76
+ }
77
+ let directory = options.cwd ?? ".";
78
+ const seen = [];
79
+ for (let depth = 0;depth < 32; depth += 1) {
80
+ for (const candidate of CONFIG_PATHS) {
81
+ const path = `${directory}/${candidate}`.replace(/\/+/g, "/");
82
+ seen.push(path);
83
+ const text = read(path);
84
+ if (text !== undefined) {
85
+ const parsed = parseConfig(text);
86
+ const environment = options.env?.FORGEZERO_ENVIRONMENT;
87
+ return { config: resolveConfig(parsed, environment), path };
88
+ }
89
+ }
90
+ const parent = directory.replace(/\/[^/]+\/?$/, "");
91
+ if (parent === directory || parent === "")
92
+ break;
93
+ directory = parent;
94
+ }
95
+ throw new ConfigError("CONFIG_NOT_FOUND", `No .fz/config.json found from ${options.cwd ?? "."} upwards. Run \`fz init\` to create one.`);
96
+ }
97
+ function exampleConfig(args) {
98
+ return `${JSON.stringify({
99
+ project: args.project,
100
+ environment: "development",
101
+ framework: args.framework ?? "node",
102
+ secrets: ["DATABASE_URL", { entry: "STRIPE_SECRET", as: "STRIPE_SECRET_KEY" }],
103
+ environments: {
104
+ development: {},
105
+ staging: {},
106
+ production: { override: true }
107
+ }
108
+ }, null, 2)}
109
+ `;
110
+ }
111
+
112
+ // src/env.ts
113
+ class EnvError extends Error {
114
+ code;
115
+ missing;
116
+ constructor(code, message, missing) {
117
+ super(message);
118
+ this.code = code;
119
+ this.missing = missing;
120
+ this.name = "EnvError";
121
+ }
122
+ }
123
+ function isBuildTime(env = {}) {
124
+ return Boolean(env.SVELTEKIT_BUILD || env.NEXT_PHASE?.includes("build") || env.VITE_BUILD || env.npm_lifecycle_event === "build" || env.FORGEZERO_BUILD === "1");
125
+ }
126
+ async function hydrateEnv(options) {
127
+ const env = options.env ?? globalThis.process?.env ?? {};
128
+ if (!options.allowDuringBuild && isBuildTime(env)) {
129
+ throw new EnvError("BUILD_TIME_REFUSED", "Refusing to load secrets during a build. A bundler that inlines process.env writes the value into a JavaScript file that ships to a browser. Load them at RUNTIME — a SvelteKit `handle` hook, or Next.js `instrumentation.ts`.");
130
+ }
131
+ const bindings = bindingsOf(options.config);
132
+ const results = await Promise.all(bindings.map(async (binding) => {
133
+ try {
134
+ const value = await options.vault.get(binding.entry, {
135
+ environment: options.config.environment
136
+ });
137
+ return { binding, value };
138
+ } catch (cause) {
139
+ throw new EnvError("FETCH_FAILED", `Could not read "${binding.entry}": ${cause instanceof Error ? cause.message : "unknown error"}`);
140
+ }
141
+ }));
142
+ const missing = results.filter((result) => result.value === undefined && result.binding.required).map((result) => result.binding.entry);
143
+ if (missing.length > 0) {
144
+ throw new EnvError("MISSING_SECRET", `Missing in ${options.config.project}/${options.config.environment}: ${missing.join(", ")}. Add them, or mark them \`"required": false\` if the service can genuinely start without them.`, missing);
145
+ }
146
+ const loaded = [];
147
+ const shadowed = [];
148
+ const skipped = [];
149
+ for (const { binding, value } of results) {
150
+ if (value === undefined) {
151
+ skipped.push(binding.entry);
152
+ continue;
153
+ }
154
+ if (env[binding.as] !== undefined && !options.config.override) {
155
+ shadowed.push(binding.as);
156
+ continue;
157
+ }
158
+ env[binding.as] = value;
159
+ loaded.push(binding.as);
160
+ }
161
+ const report = {
162
+ loaded,
163
+ shadowed,
164
+ skipped,
165
+ environment: options.config.environment,
166
+ project: options.config.project
167
+ };
168
+ options.onReport?.(report);
169
+ return report;
170
+ }
171
+ function describeReport(report) {
172
+ const parts = [`[forgezero] ${report.loaded.length} secret(s) → env`];
173
+ parts.push(`${report.project}/${report.environment}`);
174
+ if (report.shadowed.length > 0) {
175
+ parts.push(`SHADOWED by existing env: ${report.shadowed.join(", ")}`);
176
+ }
177
+ if (report.skipped.length > 0) {
178
+ parts.push(`absent (optional): ${report.skipped.join(", ")}`);
179
+ }
180
+ return parts.join(" · ");
181
+ }
182
+ function clearEnv(report, env = globalThis.process?.env ?? {}) {
183
+ for (const name of report.loaded)
184
+ delete env[name];
185
+ }
186
+
187
+ // src/frameworks.ts
188
+ var pending = null;
189
+ var resetHydration = () => {
190
+ pending = null;
191
+ };
192
+ function setupEnv(options) {
193
+ if (pending)
194
+ return pending;
195
+ const env = options.env ?? globalThis.process?.env ?? {};
196
+ const log = options.log ?? ((line) => console.info(line));
197
+ pending = (async () => {
198
+ const config = options.config ?? loadConfig({ cwd: options.cwd, readFile: options.readFile, env }).config;
199
+ const report = await hydrateEnv({ config, vault: options.vault, env, onReport: options.onReport });
200
+ log(describeReport(report));
201
+ return report;
202
+ })();
203
+ pending.catch(() => {
204
+ pending = null;
205
+ });
206
+ return pending;
207
+ }
208
+ function forgeZeroHandle(options) {
209
+ return async ({ event, resolve }) => {
210
+ await setupEnv(options);
211
+ return resolve(event);
212
+ };
213
+ }
214
+ function forgeZeroInit(options) {
215
+ return async () => {
216
+ await setupEnv(options);
217
+ };
218
+ }
219
+ function forgeZeroRegister(options) {
220
+ return async () => {
221
+ const env = options.env ?? globalThis.process?.env ?? {};
222
+ if (env.NEXT_RUNTIME === "edge") {
223
+ return;
224
+ }
225
+ if (isBuildTime(env))
226
+ return;
227
+ await setupEnv({ ...options, env });
228
+ };
229
+ }
230
+ async function bootstrapEnv(options) {
231
+ return setupEnv(options);
232
+ }
233
+ export {
234
+ setupEnv,
235
+ resetHydration,
236
+ forgeZeroRegister,
237
+ forgeZeroInit,
238
+ forgeZeroHandle,
239
+ bootstrapEnv
240
+ };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @forgezero/vault — the client.
3
+ *
4
+ * Zero runtime dependencies. Credential discovery, versioned reads and writes,
5
+ * rotation via `watch`.
6
+ *
7
+ * ## Discovery, and why it matters more than it looks
8
+ *
9
+ * /run/forgezero.sock exists → MANAGED: the agent signs, the app holds
10
+ * nothing at all
11
+ * FORGEZERO_API_KEY present → EXTERNAL: derive a keypair locally, sign here
12
+ * neither → throw, naming BOTH so the fix is obvious
13
+ *
14
+ * Moving an application onto managed compute means DELETING an environment
15
+ * variable. There is no code path that differs — the same file runs in both
16
+ * places, and the stronger posture is the one requiring less configuration
17
+ * rather than more.
18
+ *
19
+ * ## Node assignment
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.
25
+ *
26
+ * This package must not import `@forgezero/providers` either. `vaultCredentials`
27
+ * returns the shape structurally, because a dependency edge in either direction
28
+ * would make each package need the other.
29
+ */
30
+ export declare class VaultError extends Error {
31
+ readonly code: string;
32
+ constructor(code: string, message: string);
33
+ }
34
+ export type CredentialMode = 'managed' | 'external';
35
+ export interface Credential {
36
+ mode: CredentialMode;
37
+ /** MANAGED only — the agent socket that signs on our behalf. */
38
+ socketPath?: string;
39
+ /** EXTERNAL only — a seed, never transmitted; a keypair derives from it. */
40
+ apiKey?: string;
41
+ }
42
+ export interface DiscoveryEnvironment {
43
+ env?: Record<string, string | undefined>;
44
+ socketPath?: string;
45
+ socketExists?: (path: string) => boolean;
46
+ }
47
+ export declare const DEFAULT_SOCKET = "/run/forgezero.sock";
48
+ /**
49
+ * Find a credential, preferring the stronger posture.
50
+ *
51
+ * The agent socket wins when both are present. On managed compute an API key in
52
+ * the environment is a leftover, and honouring it would silently downgrade a
53
+ * machine that holds nothing into one holding a signing seed.
54
+ */
55
+ export declare function discover(environment?: DiscoveryEnvironment): Credential;
56
+ /** Signs a request. Supplied by the host, so this package holds no crypto. */
57
+ export interface Signer {
58
+ readonly keyId: string;
59
+ sign(payload: string): Promise<string>;
60
+ }
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>;
73
+ export interface VaultOptions {
74
+ /** Directory endpoint. Unauthenticated — a node hostname is not a secret. */
75
+ assignUrl?: string;
76
+ project?: string;
77
+ environment?: string;
78
+ fetch?: typeof globalThis.fetch;
79
+ signer?: Signer;
80
+ credential?: Credential;
81
+ discovery?: DiscoveryEnvironment;
82
+ now?: () => number;
83
+ }
84
+ export interface EntryMeta {
85
+ name: string;
86
+ version: number;
87
+ updatedAtTs: number;
88
+ }
89
+ export interface Change {
90
+ name: string;
91
+ version: number;
92
+ }
93
+ export declare const DEFAULT_ASSIGN_URL = "https://api.forgezero.net/v1/assign";
94
+ export declare class ForgeZero {
95
+ readonly credential: Credential;
96
+ private assignment;
97
+ private readonly assignUrl;
98
+ private readonly doFetch;
99
+ private readonly now;
100
+ private readonly signer?;
101
+ private readonly project;
102
+ private readonly environment;
103
+ 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
+ private scope;
109
+ private request;
110
+ /** One value, current version unless asked otherwise. */
111
+ get(name: string, options?: {
112
+ version?: number;
113
+ }): Promise<string>;
114
+ /**
115
+ * Every value in scope. MANAGED only.
116
+ *
117
+ * An API key holder gets `list` plus per-name reads instead, because one
118
+ * compromised key should not hand over an entire environment in a single
119
+ * call — and on managed compute the app holds no key to compromise.
120
+ */
121
+ getAll(): Promise<Record<string, string>>;
122
+ /**
123
+ * The PUBLIC half of a value ForgeZero derived from the realm master seed.
124
+ *
125
+ * There is no call that returns the private half, and that is the whole
126
+ * design rather than an omission. A tenant that can fetch a signing key holds
127
+ * the full risk of holding one: it lands in their backups, their logs, their
128
+ * heap dumps and their environment. Here they declare which chain they
129
+ * activated, the platform derives from the seed, and an ADDRESS is the only
130
+ * thing that crosses back.
131
+ *
132
+ * Deterministic, so losing the row loses the metadata and not the money —
133
+ * re-declaring the same chain and index derives the same address.
134
+ */
135
+ derived(name: string, field: string): Promise<{
136
+ address: string;
137
+ path: string;
138
+ }>;
139
+ /**
140
+ * Sign a digest with a derived key, without the key ever leaving.
141
+ *
142
+ * The tenant builds the unsigned transaction, hashes it, and sends the
143
+ * digest. What comes back is a signature. A compromised tenant process can
144
+ * ASK for signatures — which is bounded, logged and revocable — and cannot
145
+ * take the key, which would be none of those things.
146
+ *
147
+ * A digest rather than a transaction on purpose: the platform is not a
148
+ * transaction builder for every chain a tenant might use, and pretending
149
+ * otherwise would make ForgeZero the thing that has to understand every
150
+ * chain format before a tenant can support one.
151
+ */
152
+ sign(name: string, field: string, digest: string): Promise<{
153
+ signature: string;
154
+ recovery?: number;
155
+ path: string;
156
+ }>;
157
+ /** Schemas the platform holds, for a managed source. */
158
+ schemas(): Promise<readonly {
159
+ name: string;
160
+ version: number;
161
+ }[]>;
162
+ /** Names and metadata. Never values. */
163
+ list(): Promise<readonly EntryMeta[]>;
164
+ /** Write a new version. Nothing is overwritten; the previous stays readable. */
165
+ set(name: string, value: string): Promise<number>;
166
+ remove(name: string): Promise<void>;
167
+ /**
168
+ * Changes since a cursor.
169
+ *
170
+ * Polling rather than a socket: an edge runtime cannot hold one open, and a
171
+ * client that only works on a long-lived server is not the client this
172
+ * package promises. A gap answers `resync`, and the caller restarts from zero
173
+ * rather than silently missing a rotation.
174
+ */
175
+ watch(options?: {
176
+ intervalMs?: number;
177
+ signal?: AbortSignal;
178
+ }): AsyncGenerator<Change>;
179
+ }
180
+ /**
181
+ * Adapts a vault to the `CredentialSource` shape `@forgezero/providers` expects.
182
+ *
183
+ * Structural rather than imported: neither package may depend on the other, so
184
+ * the shape is duplicated. Two tiny interfaces beat a dependency cycle.
185
+ */
186
+ /**
187
+ * The documented entry point.
188
+ *
189
+ * `new ForgeZero()` works and is what this wraps, but every guide, every doc
190
+ * page and every framework example says `createVault()` — and a quickstart that
191
+ * does not run is worse than none, because the reader concludes the package is
192
+ * broken rather than that the docs are.
193
+ *
194
+ * A factory is also the right shape for what this does: discovery may throw, so
195
+ * a caller reads `createVault()` as "give me one, working" rather than as a
196
+ * constructor that happens to do I/O-adjacent work.
197
+ */
198
+ export declare const createVault: (options?: VaultOptions) => ForgeZero;
199
+ export declare function vaultCredentials(vault: ForgeZero): {
200
+ readonly name: string;
201
+ get(reference: string, field: string): Promise<string>;
202
+ };
203
+ export declare const VERSION = "0.1.0";
package/dist/index.js ADDED
@@ -0,0 +1,160 @@
1
+ // src/index.ts
2
+ class VaultError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "VaultError";
8
+ }
9
+ }
10
+ var DEFAULT_SOCKET = "/run/forgezero.sock";
11
+ function discover(environment = {}) {
12
+ const env = environment.env ?? {};
13
+ const socketPath = environment.socketPath ?? env.FORGEZERO_SOCKET ?? DEFAULT_SOCKET;
14
+ if (environment.socketExists?.(socketPath))
15
+ return { mode: "managed", socketPath };
16
+ const apiKey = env.FORGEZERO_API_KEY;
17
+ if (apiKey)
18
+ return { mode: "external", apiKey };
19
+ throw new VaultError("NO_CREDENTIAL", `No credential found. On managed compute the agent socket at ${socketPath} provides one; ` + "elsewhere set FORGEZERO_API_KEY.");
20
+ }
21
+ var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
22
+ var DEFAULT_ASSIGN_URL = "https://api.forgezero.net/v1/assign";
23
+
24
+ class ForgeZero {
25
+ credential;
26
+ assignment;
27
+ assignUrl;
28
+ doFetch;
29
+ now;
30
+ signer;
31
+ project;
32
+ environment;
33
+ constructor(options = {}) {
34
+ this.credential = options.credential ?? discover(options.discovery);
35
+ this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
36
+ this.doFetch = options.fetch ?? globalThis.fetch;
37
+ this.now = options.now ?? Date.now;
38
+ this.signer = options.signer;
39
+ this.project = options.project ?? "default";
40
+ this.environment = options.environment ?? "production";
41
+ }
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
+ scope() {
62
+ return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
63
+ }
64
+ async request(path, init = {}, retried = false) {
65
+ const node = await this.node();
66
+ const body = typeof init.body === "string" ? init.body : "";
67
+ const headers = {
68
+ "content-type": "application/json",
69
+ ...init.headers
70
+ };
71
+ if (this.signer) {
72
+ 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);
79
+ }
80
+ const payload = await response.json().catch(() => {
81
+ return;
82
+ });
83
+ if (!response.ok) {
84
+ throw new VaultError(payload?.error?.code ?? "REQUEST_FAILED", payload?.error?.message ?? `The vault refused with ${response.status}.`);
85
+ }
86
+ return payload;
87
+ }
88
+ async get(name, options = {}) {
89
+ const query = options.version ? `?version=${options.version}` : "";
90
+ const result = await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}${query}`);
91
+ return result.value;
92
+ }
93
+ async getAll() {
94
+ if (this.credential.mode !== "managed") {
95
+ throw new VaultError("MANAGED_ONLY", "getAll is available on managed compute only. Read entries by name with an API key.");
96
+ }
97
+ const result = await this.request(`/v1/vault/${this.scope()}/entries`);
98
+ return result.values;
99
+ }
100
+ async derived(name, field) {
101
+ return this.request(`/v1/entries/${encodeURIComponent(name)}/derived/${encodeURIComponent(field)}${this.scope()}`);
102
+ }
103
+ async sign(name, field, digest) {
104
+ return this.request(`/v1/entries/${encodeURIComponent(name)}/sign/${encodeURIComponent(field)}${this.scope()}`, { method: "POST", body: JSON.stringify({ digest }) });
105
+ }
106
+ async schemas() {
107
+ const payload = await this.request(`/v1/schemas${this.scope()}`);
108
+ return payload.schemas ?? [];
109
+ }
110
+ async list() {
111
+ const result = await this.request(`/v1/vault/${this.scope()}/list`);
112
+ return result.entries;
113
+ }
114
+ 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;
120
+ }
121
+ async remove(name) {
122
+ await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}`, {
123
+ method: "DELETE"
124
+ });
125
+ }
126
+ async* watch(options = {}) {
127
+ const interval = options.intervalMs ?? 15000;
128
+ let since = 0;
129
+ while (!options.signal?.aborted) {
130
+ const result = await this.request(`/v1/vault/${this.scope()}/changes?since=${since}`);
131
+ if (result.resync) {
132
+ since = 0;
133
+ continue;
134
+ }
135
+ for (const change of result.changed)
136
+ yield change;
137
+ since = result.version;
138
+ await new Promise((resolve) => setTimeout(resolve, interval));
139
+ }
140
+ }
141
+ }
142
+ var createVault = (options = {}) => new ForgeZero(options);
143
+ function vaultCredentials(vault) {
144
+ return {
145
+ name: "vault",
146
+ get: (reference, field) => vault.get(`${reference}.${field}`)
147
+ };
148
+ }
149
+ var VERSION = "0.1.0";
150
+ export {
151
+ vaultCredentials,
152
+ discover,
153
+ createVault,
154
+ VaultError,
155
+ VERSION,
156
+ REASSIGN_ON,
157
+ ForgeZero,
158
+ DEFAULT_SOCKET,
159
+ DEFAULT_ASSIGN_URL
160
+ };