@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 +21 -0
- package/README.md +82 -0
- package/dist/config.d.ts +110 -0
- package/dist/config.js +120 -0
- package/dist/env.d.ts +101 -0
- package/dist/env.js +192 -0
- package/dist/frameworks.d.ts +91 -0
- package/dist/frameworks.js +240 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +160 -0
- package/dist/providers.d.ts +66 -0
- package/dist/providers.js +199 -0
- package/dist/schema.d.ts +79 -0
- package/dist/schema.js +222 -0
- package/package.json +72 -0
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.
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
};
|