@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
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|