@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @forgezero/vault
2
+
3
+ **Read your secrets at runtime instead of shipping them in a file.**
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.
8
+
9
+ This one talks to a ForgeZero vault, so it needs an account. The other three
10
+ packages do not.
11
+
12
+ ```bash
13
+ bun add @forgezero/vault
14
+ ```
15
+
16
+ ```ts
17
+ import { ForgeZero } from '@forgezero/vault';
18
+
19
+ const fz = new ForgeZero({ project: 'altpilot', environment: 'production' });
20
+
21
+ const key = await fz.get('STRIPE_KEY');
22
+ const names = await fz.list(); // names and metadata, never values
23
+ ```
24
+
25
+ ## Envless
26
+
27
+ Fill `process.env` at boot and change nothing else in your application:
28
+
29
+ ```ts
30
+ import '@forgezero/vault/env'; // refuses to run during a build
31
+ ```
32
+
33
+ Rotation stops being a project. A new version is written, readers pick it up on
34
+ their next fetch, and nothing is redeployed.
35
+
36
+ ## Two transports, and the socket wins
37
+
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.
42
+
43
+ ## The API key is a seed, not a password
44
+
45
+ The key handed to you **is** the seed for a hybrid Ed25519 + ML-DSA-65 key pair.
46
+ The client re-derives that pair on every start; the platform stores only the
47
+ public halves. A dump of the key table yields nothing that can sign — which is
48
+ not true of any scheme where the server holds something it compares against.
49
+
50
+ ## Schema-declared secrets
51
+
52
+ An entry can declare fields the tenant never holds. Two custody modes:
53
+
54
+ - **derived** — the vault generates the key material from the master seed and it
55
+ exists in plaintext only inside a signing call.
56
+ - **supplied** — you generate it, the vault seals it.
57
+
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.
60
+
61
+ ## Subpaths
62
+
63
+ | import | what it is |
64
+ |---|---|
65
+ | `@forgezero/vault` | the client — get, list, write, rotate, `derived`, `sign` |
66
+ | `/env` | envless: fill `process.env` at boot |
67
+ | `/config` | read `.fz/config.json` — project, environment, which secrets |
68
+ | `/schema` | where an entry's shape comes from: pulled from the platform, or local |
69
+ | `/frameworks` | SvelteKit and Next.js wiring, at the one place that runs once |
70
+
71
+ ## Errors you will actually hit
72
+
73
+ `VAULT_LOCKED` (423) — a custodian quorum must unlock it; retrying will not
74
+ help. `BLOCKED` — access was cut deliberately and lifts just as fast.
75
+ `NO_CREDENTIAL` — neither a socket nor an API key was found.
76
+
77
+ Full documentation: **https://forgezero.net/docs/vault-package**
78
+
79
+ ## Licence
80
+
81
+ MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
82
+ deploys.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `.fz/config.json` — what a project declares once, so nothing else needs flags.
3
+ *
4
+ * Every command that touches a project needs the same four facts: which project,
5
+ * which environment, which secrets, and what to call them in the process. Passed
6
+ * as flags they get typed differently by every person and every CI job; put in a
7
+ * file they are reviewed like code and diff like code.
8
+ *
9
+ * ## JSON, not YAML
10
+ *
11
+ * A YAML parser is a third-party dependency, and this package claims to have
12
+ * none — it ends up inside a tenant's production application, where every edge
13
+ * in its dependency graph is one they did not choose. JSON is in the runtime.
14
+ * (`.fz/deploy.yaml` is a different file read by a different tool on our side of
15
+ * the wire, where the dependency is ours to carry.)
16
+ *
17
+ * ## What is deliberately NOT in here
18
+ *
19
+ * No secrets, no API key, no endpoint. This file is committed. The credential
20
+ * comes from the agent socket or `FORGEZERO_API_KEY`, and the day somebody adds
21
+ * a `token` field here is the day the file starts leaking in pull requests.
22
+ * `assertNoSecrets` fails the load rather than trusting that nobody will.
23
+ */
24
+ export declare class ConfigError extends Error {
25
+ readonly code: 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'CONFIG_HAS_SECRET' | 'UNKNOWN_ENVIRONMENT';
26
+ constructor(code: 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'CONFIG_HAS_SECRET' | 'UNKNOWN_ENVIRONMENT', message: string);
27
+ }
28
+ /** How a secret is named in the process, when it differs from the entry key. */
29
+ export interface SecretBinding {
30
+ /** Vault entry key. */
31
+ entry: string;
32
+ /** Environment variable name. Defaults to `entry`. */
33
+ as?: string;
34
+ /**
35
+ * Absent is a hard failure at boot rather than an undefined at first use.
36
+ *
37
+ * Default true, because the alternative is a service that starts, looks
38
+ * healthy, and fails on whichever request first touches the missing value.
39
+ */
40
+ required?: boolean;
41
+ }
42
+ export interface FzConfig {
43
+ /** Project slug in ForgeZero. */
44
+ project: string;
45
+ /** Default environment. Overridden by `FORGEZERO_ENVIRONMENT` or a flag. */
46
+ environment: string;
47
+ /**
48
+ * Which secrets to load, and what to call them.
49
+ *
50
+ * An explicit list rather than "everything in the project". A process that
51
+ * pulls every secret it can reach holds credentials it never uses, and the
52
+ * blast radius of a memory dump becomes the whole project rather than the
53
+ * five things this service actually needs.
54
+ */
55
+ secrets: readonly (string | SecretBinding)[];
56
+ /**
57
+ * Let the vault overwrite a variable that is already set.
58
+ *
59
+ * Default FALSE, matching dotenv: a value already in the environment wins, so
60
+ * a developer can override one thing for an afternoon without editing config.
61
+ * Anything shadowed is REPORTED at boot — a production secret silently losing
62
+ * to a stale export is otherwise the hardest class of bug in this area.
63
+ */
64
+ override?: boolean;
65
+ /** Prefix applied to every variable name. `FZ_` → `FZ_DATABASE_URL`. */
66
+ prefix?: string;
67
+ /** Per-environment overrides, merged over the top level. */
68
+ environments?: Record<string, Partial<Omit<FzConfig, 'environments'>>>;
69
+ /** Framework, when auto-setup needs to know. */
70
+ framework?: 'sveltekit' | 'nextjs' | 'node' | 'bun';
71
+ }
72
+ export declare const CONFIG_PATHS: readonly [".fz/config.json", "fz.config.json"];
73
+ export declare function assertNoSecrets(raw: Record<string, unknown>, path?: string): void;
74
+ export declare function parseConfig(text: string): FzConfig;
75
+ /**
76
+ * Resolve for one environment.
77
+ *
78
+ * Environment blocks are merged over the top level rather than replacing it, so
79
+ * `production` can change one field without restating the list — restating it is
80
+ * how one environment ends up missing a secret the others have.
81
+ */
82
+ export declare function resolveConfig(config: FzConfig, environment?: string): FzConfig;
83
+ /** Normalise the two spellings into one, with the prefix applied. */
84
+ export declare function bindingsOf(config: FzConfig): readonly Required<SecretBinding>[];
85
+ export interface LoadOptions {
86
+ /** Where to start looking. Walks up to the filesystem root. */
87
+ cwd?: string;
88
+ readFile?: (path: string) => string | undefined;
89
+ env?: Record<string, string | undefined>;
90
+ }
91
+ /**
92
+ * Find the config by walking up from `cwd`.
93
+ *
94
+ * Walking up rather than requiring an exact path, because the same command is
95
+ * run from the repository root, from a workspace package and from a CI step with
96
+ * a different working directory — and a tool that only works from one of those
97
+ * gets wrapped in a script that hard-codes a path.
98
+ *
99
+ * `readFile` is injected so this stays runtime-agnostic: the same code has to
100
+ * work under Bun, Node and a bundler that has no `fs` at all.
101
+ */
102
+ export declare function loadConfig(options?: LoadOptions): {
103
+ config: FzConfig;
104
+ path: string;
105
+ };
106
+ /** What `fz init` writes. Kept here so the tool and the reader agree on the shape. */
107
+ export declare function exampleConfig(args: {
108
+ project: string;
109
+ framework?: FzConfig['framework'];
110
+ }): string;
package/dist/config.js ADDED
@@ -0,0 +1,120 @@
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
+ export {
112
+ resolveConfig,
113
+ parseConfig,
114
+ loadConfig,
115
+ exampleConfig,
116
+ bindingsOf,
117
+ assertNoSecrets,
118
+ ConfigError,
119
+ CONFIG_PATHS
120
+ };
package/dist/env.d.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { type FzConfig } from './config';
2
+ /**
3
+ * Envless — `process.env` filled from the vault, and nothing written to disk.
4
+ *
5
+ * The point is that existing code does not change. A service reading
6
+ * `process.env.DATABASE_URL` keeps reading it; what changes is where the value
7
+ * came from and, crucially, that it never existed as a file. A `.env` on a box
8
+ * is the single most common way a credential leaves a building: it survives the
9
+ * process, it lands in backups, it gets copied to a laptop for debugging, and it
10
+ * is readable by every other process running as that user.
11
+ *
12
+ * ## Three rules, all of them the reason this is a module and not four lines
13
+ *
14
+ * **Fail at boot, never at first use.** Every required secret is fetched before
15
+ * the process is allowed to serve anything. The alternative is a container that
16
+ * starts, passes its health check, and fails on whichever request first touches
17
+ * the missing value — at 3am, on one endpoint, looking like an application bug.
18
+ *
19
+ * **Never at build time.** This is the rule that actually protects anything. A
20
+ * bundler that inlines `process.env.X` bakes the value into a JavaScript file
21
+ * that ships to a CDN. SvelteKit's `$env/static/private` and Next's
22
+ * `next.config.js` `env` block both do exactly that. So `hydrateEnv` refuses to
23
+ * run when it can tell it is inside a build, rather than trusting the caller to
24
+ * only wire it up in the right place.
25
+ *
26
+ * **Report what was shadowed.** By default an existing variable wins, matching
27
+ * dotenv, so a developer can override one value for an afternoon. The failure
28
+ * mode of that default is a stale `export DATABASE_URL` in a shell profile
29
+ * silently beating production — so anything shadowed is named in the result and
30
+ * logged. A quiet precedence rule is the hardest bug in this area.
31
+ */
32
+ export declare class EnvError extends Error {
33
+ readonly code: 'MISSING_SECRET' | 'BUILD_TIME_REFUSED' | 'FETCH_FAILED';
34
+ readonly missing?: readonly string[] | undefined;
35
+ constructor(code: 'MISSING_SECRET' | 'BUILD_TIME_REFUSED' | 'FETCH_FAILED', message: string, missing?: readonly string[] | undefined);
36
+ }
37
+ /** Just enough of `ForgeZero` to fetch. Structural, so tests need no client. */
38
+ export interface SecretReader {
39
+ get(entry: string, options?: {
40
+ environment?: string;
41
+ }): Promise<string | undefined>;
42
+ }
43
+ export interface HydrateOptions {
44
+ config: FzConfig;
45
+ vault: SecretReader;
46
+ /** Defaults to `globalThis.process.env`. */
47
+ env?: Record<string, string | undefined>;
48
+ /** Escape hatch for a caller that genuinely knows better. See `isBuildTime`. */
49
+ allowDuringBuild?: boolean;
50
+ onReport?: (report: HydrateReport) => void;
51
+ }
52
+ export interface HydrateReport {
53
+ /** Names now set from the vault. */
54
+ loaded: readonly string[];
55
+ /**
56
+ * Names the vault had a value for, which an existing variable beat.
57
+ *
58
+ * Never empty silently — this is the list somebody needs when production is
59
+ * reading the wrong database.
60
+ */
61
+ shadowed: readonly string[];
62
+ /** Optional secrets that were absent. Required ones throw instead. */
63
+ skipped: readonly string[];
64
+ environment: string;
65
+ project: string;
66
+ }
67
+ /**
68
+ * Whether we are inside a build rather than a running server.
69
+ *
70
+ * Detected from the signals each framework actually sets, because the
71
+ * consequence of getting it wrong is a secret in a client bundle — a failure
72
+ * that is silent, permanent once published, and not fixed by rotating alone.
73
+ *
74
+ * Deliberately conservative: any one signal is enough to refuse. A false
75
+ * positive costs a developer one explicit flag; a false negative costs a
76
+ * credential.
77
+ */
78
+ export declare function isBuildTime(env?: Record<string, string | undefined>): boolean;
79
+ /**
80
+ * Fetch every declared secret and put it in the environment.
81
+ *
82
+ * Fetched in parallel and applied together: a partial application leaves the
83
+ * process holding half its configuration, which is strictly worse than holding
84
+ * none because it will start.
85
+ */
86
+ export declare function hydrateEnv(options: HydrateOptions): Promise<HydrateReport>;
87
+ /**
88
+ * The default boot line.
89
+ *
90
+ * Names counts and variable NAMES, never a value or any part of one. A log line
91
+ * that helpfully prints the first four characters of a secret is a log line that
92
+ * puts a secret in a log aggregator with weaker access rules than the vault.
93
+ */
94
+ export declare function describeReport(report: HydrateReport): string;
95
+ /**
96
+ * Remove what we set.
97
+ *
98
+ * For a test that must not leak state into the next one, and for a process
99
+ * dropping privileges before exec'ing a child that should not inherit them.
100
+ */
101
+ export declare function clearEnv(report: HydrateReport, env?: Record<string, string | undefined>): void;
package/dist/env.js ADDED
@@ -0,0 +1,192 @@
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
+ export {
187
+ isBuildTime,
188
+ hydrateEnv,
189
+ describeReport,
190
+ clearEnv,
191
+ EnvError
192
+ };