@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,66 @@
|
|
|
1
|
+
import { ForgeZero } from './index';
|
|
2
|
+
/** Mirrors `ProviderConfig` in `@forgezero/providers`, matched structurally. */
|
|
3
|
+
export interface StoredProvider {
|
|
4
|
+
providerId: string;
|
|
5
|
+
/** Names one instance when a provider is configured more than once. */
|
|
6
|
+
instanceKey?: string;
|
|
7
|
+
/** 1 is tried first. */
|
|
8
|
+
priority: number;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
config: Record<string, unknown>;
|
|
11
|
+
/** Names the vault entry holding the secret. Never the secret. */
|
|
12
|
+
secretRef: string;
|
|
13
|
+
health?: {
|
|
14
|
+
strikes: number;
|
|
15
|
+
status: 'ok' | 'degraded' | 'offline';
|
|
16
|
+
lastFailureAtTs?: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface VaultConfigOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Entry name for a service's provider list. `email` → `providers.email`.
|
|
22
|
+
*
|
|
23
|
+
* Overridable because a tenant already using `providers.*` for something else
|
|
24
|
+
* should not have to rename it to adopt this.
|
|
25
|
+
*/
|
|
26
|
+
entryFor?: (serviceKey: string) => string;
|
|
27
|
+
/**
|
|
28
|
+
* Persist health back to the vault.
|
|
29
|
+
*
|
|
30
|
+
* Off by default, and the default is the load-bearing choice. Health is
|
|
31
|
+
* written on every failure, and a vault write per failed send turns one dead
|
|
32
|
+
* relay into a write storm against the store you need most when things are
|
|
33
|
+
* already going wrong. Most deployments want strikes in memory and the list
|
|
34
|
+
* in the vault; turn this on when several processes must agree that a
|
|
35
|
+
* provider is dead.
|
|
36
|
+
*/
|
|
37
|
+
persistHealth?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Read the ordered provider list for a service out of the vault.
|
|
41
|
+
*
|
|
42
|
+
* A missing entry is an EMPTY LIST, not an error. A service nobody has
|
|
43
|
+
* configured yet is a normal state — the registry then falls through to
|
|
44
|
+
* whatever else is chained behind it, which is how bootstrap works before the
|
|
45
|
+
* first provider is ever added.
|
|
46
|
+
*
|
|
47
|
+
* A malformed entry is NOT empty. Silently treating unparseable JSON as "no
|
|
48
|
+
* providers" turns a typo into a service that appears configured and quietly
|
|
49
|
+
* sends nothing.
|
|
50
|
+
*/
|
|
51
|
+
export declare function vaultConfig(vault: ForgeZero, options?: VaultConfigOptions): {
|
|
52
|
+
name: string;
|
|
53
|
+
list(serviceKey: string): Promise<readonly StoredProvider[]>;
|
|
54
|
+
recordHealth(serviceKey: string, providerId: string, health: NonNullable<StoredProvider["health"]>): Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Credentials out of the vault, by the reference a provider config names.
|
|
58
|
+
*
|
|
59
|
+
* `secretRef: 'jetemail'` plus field `token` reads the entry `jetemail.token`.
|
|
60
|
+
* The reference is stored on the provider row and the secret is not, so a dump
|
|
61
|
+
* of the configuration yields the NAMES of secrets and none of their values.
|
|
62
|
+
*/
|
|
63
|
+
export declare function vaultCredentials(vault: ForgeZero): {
|
|
64
|
+
name: string;
|
|
65
|
+
get: (reference: string, field: string) => Promise<string>;
|
|
66
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
|
|
151
|
+
// src/providers.ts
|
|
152
|
+
var MISSING = new Set(["ENTRY_NOT_FOUND", "VERSION_NOT_FOUND"]);
|
|
153
|
+
function vaultConfig(vault, options = {}) {
|
|
154
|
+
const entryFor = options.entryFor ?? ((serviceKey) => `providers.${serviceKey}`);
|
|
155
|
+
const read = async (serviceKey) => {
|
|
156
|
+
const name = entryFor(serviceKey);
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = await vault.get(name);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof VaultError && MISSING.has(error.code))
|
|
162
|
+
return [];
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(raw);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new VaultError("INVALID", `Vault entry "${name}" is not valid JSON.`);
|
|
170
|
+
}
|
|
171
|
+
if (!Array.isArray(parsed)) {
|
|
172
|
+
throw new VaultError("INVALID", `Vault entry "${name}" must be an array of providers.`);
|
|
173
|
+
}
|
|
174
|
+
return parsed;
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
name: "vault",
|
|
178
|
+
async list(serviceKey) {
|
|
179
|
+
return (await read(serviceKey)).slice().sort((a, b) => a.priority - b.priority);
|
|
180
|
+
},
|
|
181
|
+
async recordHealth(serviceKey, providerId, health) {
|
|
182
|
+
if (!options.persistHealth)
|
|
183
|
+
return;
|
|
184
|
+
const providers = await read(serviceKey);
|
|
185
|
+
const next = providers.map((entry) => entry.providerId === providerId ? { ...entry, health } : entry);
|
|
186
|
+
await vault.set(entryFor(serviceKey), JSON.stringify(next));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function vaultCredentials2(vault) {
|
|
191
|
+
return {
|
|
192
|
+
name: "vault",
|
|
193
|
+
get: (reference, field) => vault.get(`${reference}.${field}`)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export {
|
|
197
|
+
vaultCredentials2 as vaultCredentials,
|
|
198
|
+
vaultConfig
|
|
199
|
+
};
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type ForgeZero } from './index';
|
|
2
|
+
/**
|
|
3
|
+
* Where an entry's SHAPE comes from, and what a tenant may hold themselves.
|
|
4
|
+
*
|
|
5
|
+
* Two modes, and the choice is genuinely the tenant's:
|
|
6
|
+
*
|
|
7
|
+
* managed the schema lives in ForgeZero and is pulled at runtime. Pick this
|
|
8
|
+
* when ForgeZero has to RENDER the thing — an admin screen cannot
|
|
9
|
+
* draw a form for a shape it has never seen. Changing the schema is
|
|
10
|
+
* then a platform operation and every tenant process picks it up
|
|
11
|
+
* without deploying.
|
|
12
|
+
*
|
|
13
|
+
* local the tenant declares it in their own code. Pick this when nothing
|
|
14
|
+
* on the platform needs to display it, which is most config. The
|
|
15
|
+
* shape stays in their repository, reviewed with their code, and
|
|
16
|
+
* ForgeZero stores values it never has to understand.
|
|
17
|
+
*
|
|
18
|
+
* The same pattern either way, which is the point. A tenant that starts local
|
|
19
|
+
* and later wants an admin screen changes where the schema is read from and
|
|
20
|
+
* nothing else — the validation, the derived fields and the signing calls are
|
|
21
|
+
* identical.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this is not just "always managed"
|
|
24
|
+
*
|
|
25
|
+
* A schema held centrally is a shape the platform can change under a running
|
|
26
|
+
* tenant. That is exactly what you want for something the platform renders and
|
|
27
|
+
* exactly what you do not want for a tenant's private config, where a change
|
|
28
|
+
* they did not review is a deployment they did not make.
|
|
29
|
+
*/
|
|
30
|
+
export interface SchemaRef {
|
|
31
|
+
name: string;
|
|
32
|
+
version: number;
|
|
33
|
+
}
|
|
34
|
+
export interface SchemaSource {
|
|
35
|
+
readonly mode: 'managed' | 'local';
|
|
36
|
+
/** The JSON Schema for an entry name. */
|
|
37
|
+
get(name: string): Promise<{
|
|
38
|
+
schema: unknown;
|
|
39
|
+
version: number;
|
|
40
|
+
}>;
|
|
41
|
+
/** Names this source can describe. Managed sources answer from the platform. */
|
|
42
|
+
list(): Promise<readonly SchemaRef[]>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Schemas pulled from ForgeZero, cached per process.
|
|
46
|
+
*
|
|
47
|
+
* Cached because an entry is read far more often than its shape changes, and a
|
|
48
|
+
* network round trip per read turns a config lookup into a dependency on the
|
|
49
|
+
* platform being up. `refresh` exists so a long-lived process can pick up a
|
|
50
|
+
* change without restarting.
|
|
51
|
+
*/
|
|
52
|
+
export declare function managedSchemas(vault: Pick<ForgeZero, 'get'> & {
|
|
53
|
+
schemas?: () => Promise<readonly SchemaRef[]>;
|
|
54
|
+
}, options?: {
|
|
55
|
+
ttlMs?: number;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}): SchemaSource & {
|
|
58
|
+
refresh(): void;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Schemas the tenant declares in their own code.
|
|
62
|
+
*
|
|
63
|
+
* No network, no cache, no failure mode. The shape is whatever they wrote, and
|
|
64
|
+
* an unknown name is an error at the call site rather than a lookup that
|
|
65
|
+
* returns nothing useful.
|
|
66
|
+
*/
|
|
67
|
+
export declare function localSchemas(schemas: Record<string, {
|
|
68
|
+
schema: unknown;
|
|
69
|
+
version?: number;
|
|
70
|
+
}>): SchemaSource;
|
|
71
|
+
/**
|
|
72
|
+
* Fall back to a local schema when the platform has none.
|
|
73
|
+
*
|
|
74
|
+
* The migration path, and the only combination that makes sense: managed FIRST,
|
|
75
|
+
* because if the platform has an opinion about a shape it renders, that opinion
|
|
76
|
+
* wins. The local copy is what keeps a tenant running while a schema is being
|
|
77
|
+
* moved, not a way to override one.
|
|
78
|
+
*/
|
|
79
|
+
export declare function preferManaged(managed: SchemaSource, local: SchemaSource): SchemaSource;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
|
|
151
|
+
// src/schema.ts
|
|
152
|
+
function managedSchemas(vault, options = {}) {
|
|
153
|
+
const ttl = options.ttlMs ?? 300000;
|
|
154
|
+
const now = options.now ?? (() => Date.now());
|
|
155
|
+
const cache = new Map;
|
|
156
|
+
return {
|
|
157
|
+
mode: "managed",
|
|
158
|
+
refresh() {
|
|
159
|
+
cache.clear();
|
|
160
|
+
},
|
|
161
|
+
async get(name) {
|
|
162
|
+
const hit = cache.get(name);
|
|
163
|
+
if (hit && now() - hit.at < ttl)
|
|
164
|
+
return hit.value;
|
|
165
|
+
const raw = await vault.get(`__schema__${name}`);
|
|
166
|
+
let value;
|
|
167
|
+
try {
|
|
168
|
+
value = JSON.parse(raw);
|
|
169
|
+
} catch {
|
|
170
|
+
throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} is not JSON.`);
|
|
171
|
+
}
|
|
172
|
+
if (!value?.schema) {
|
|
173
|
+
throw new VaultError("SCHEMA_MALFORMED", `The schema for ${name} has no shape.`);
|
|
174
|
+
}
|
|
175
|
+
cache.set(name, { at: now(), value });
|
|
176
|
+
return value;
|
|
177
|
+
},
|
|
178
|
+
async list() {
|
|
179
|
+
return await vault.schemas?.() ?? [];
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function localSchemas(schemas) {
|
|
184
|
+
return {
|
|
185
|
+
mode: "local",
|
|
186
|
+
async get(name) {
|
|
187
|
+
const entry = schemas[name];
|
|
188
|
+
if (!entry) {
|
|
189
|
+
throw new VaultError("SCHEMA_UNKNOWN", `No local schema is declared for ${name}. Declare it, or use a managed source.`);
|
|
190
|
+
}
|
|
191
|
+
return { schema: entry.schema, version: entry.version ?? 1 };
|
|
192
|
+
},
|
|
193
|
+
async list() {
|
|
194
|
+
return Object.entries(schemas).map(([name, entry]) => ({
|
|
195
|
+
name,
|
|
196
|
+
version: entry.version ?? 1
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function preferManaged(managed, local) {
|
|
202
|
+
return {
|
|
203
|
+
mode: "managed",
|
|
204
|
+
async get(name) {
|
|
205
|
+
try {
|
|
206
|
+
return await managed.get(name);
|
|
207
|
+
} catch {
|
|
208
|
+
return local.get(name);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async list() {
|
|
212
|
+
const [a, b] = await Promise.all([managed.list().catch(() => []), local.list()]);
|
|
213
|
+
const seen = new Set(a.map((entry) => entry.name));
|
|
214
|
+
return [...a, ...b.filter((entry) => !seen.has(entry.name))];
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export {
|
|
219
|
+
preferManaged,
|
|
220
|
+
managedSchemas,
|
|
221
|
+
localSchemas
|
|
222
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
|
+
"name": "@forgezero/vault",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./config": {
|
|
15
|
+
"types": "./dist/config.d.ts",
|
|
16
|
+
"default": "./dist/config.js"
|
|
17
|
+
},
|
|
18
|
+
"./schema": {
|
|
19
|
+
"types": "./dist/schema.d.ts",
|
|
20
|
+
"default": "./dist/schema.js"
|
|
21
|
+
},
|
|
22
|
+
"./env": {
|
|
23
|
+
"types": "./dist/env.d.ts",
|
|
24
|
+
"default": "./dist/env.js"
|
|
25
|
+
},
|
|
26
|
+
"./frameworks": {
|
|
27
|
+
"types": "./dist/frameworks.d.ts",
|
|
28
|
+
"default": "./dist/frameworks.js"
|
|
29
|
+
},
|
|
30
|
+
"./providers": {
|
|
31
|
+
"types": "./dist/providers.d.ts",
|
|
32
|
+
"default": "./dist/providers.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"check": "tsc --noEmit",
|
|
37
|
+
"build": "bun build src/index.ts src/config.ts src/schema.ts src/env.ts src/frameworks.ts src/providers.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
38
|
+
"prepublishOnly": "bun run check && bun run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.6.0",
|
|
42
|
+
"@types/bun": "latest"
|
|
43
|
+
},
|
|
44
|
+
"description": "ForgeZero vault client. Credential discovery, versioned secrets, node assignment.",
|
|
45
|
+
"keywords": [
|
|
46
|
+
"secrets",
|
|
47
|
+
"vault",
|
|
48
|
+
"secrets-management",
|
|
49
|
+
"env",
|
|
50
|
+
"post-quantum",
|
|
51
|
+
"sdk",
|
|
52
|
+
"envless",
|
|
53
|
+
"dotenv",
|
|
54
|
+
"sveltekit",
|
|
55
|
+
"nextjs"
|
|
56
|
+
],
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"homepage": "https://forgezero.net/docs/vault-package",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/axxra/forgezero.git",
|
|
62
|
+
"directory": "packages/vault"
|
|
63
|
+
},
|
|
64
|
+
"bugs": "https://github.com/axxra/forgezero/issues",
|
|
65
|
+
"sideEffects": false,
|
|
66
|
+
"types": "./dist/index.d.ts",
|
|
67
|
+
"files": [
|
|
68
|
+
"dist",
|
|
69
|
+
"README.md",
|
|
70
|
+
"LICENSE"
|
|
71
|
+
]
|
|
72
|
+
}
|