@forgezero/vault 0.1.0 → 0.1.1
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/README.md +15 -14
- package/dist/index.d.ts +109 -13
- package/dist/index.js +246 -25
- package/dist/providers.js +241 -25
- package/dist/schema.js +241 -25
- package/package.json +10 -2
package/dist/schema.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
+
import { deriveKeysFromSeed, signRequest } from "@forgezero/runtime/identity";
|
|
3
|
+
|
|
2
4
|
class VaultError extends Error {
|
|
3
5
|
code;
|
|
4
6
|
constructor(code, message) {
|
|
@@ -7,19 +9,111 @@ class VaultError extends Error {
|
|
|
7
9
|
this.name = "VaultError";
|
|
8
10
|
}
|
|
9
11
|
}
|
|
10
|
-
var
|
|
12
|
+
var runtimeEnvironment = () => typeof process !== "undefined" && process.env ? process.env : {};
|
|
13
|
+
var runtimeSocketExists = (path) => {
|
|
14
|
+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const fs = process.getBuiltinModule("node:fs");
|
|
18
|
+
return fs.existsSync(path);
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var DEFAULT_SOCKET = "/run/forgezero/vault.sock";
|
|
11
24
|
function discover(environment = {}) {
|
|
12
|
-
const env = environment.env ??
|
|
25
|
+
const env = environment.env ?? runtimeEnvironment();
|
|
13
26
|
const socketPath = environment.socketPath ?? env.FORGEZERO_SOCKET ?? DEFAULT_SOCKET;
|
|
14
|
-
if (environment.socketExists
|
|
27
|
+
if ((environment.socketExists ?? runtimeSocketExists)(socketPath))
|
|
15
28
|
return { mode: "managed", socketPath };
|
|
16
29
|
const apiKey = env.FORGEZERO_API_KEY;
|
|
17
30
|
if (apiKey)
|
|
18
31
|
return { mode: "external", apiKey };
|
|
19
32
|
throw new VaultError("NO_CREDENTIAL", `No credential found. On managed compute the agent socket at ${socketPath} provides one; ` + "elsewhere set FORGEZERO_API_KEY.");
|
|
20
33
|
}
|
|
34
|
+
var unbase64url = (encoded) => {
|
|
35
|
+
const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - encoded.length % 4) % 4);
|
|
36
|
+
let binary;
|
|
37
|
+
try {
|
|
38
|
+
binary = atob(padded);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
41
|
+
}
|
|
42
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
43
|
+
};
|
|
44
|
+
var base64url = (value) => {
|
|
45
|
+
const bytes = new TextEncoder().encode(value);
|
|
46
|
+
let binary = "";
|
|
47
|
+
for (const byte of bytes)
|
|
48
|
+
binary += String.fromCharCode(byte);
|
|
49
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
50
|
+
};
|
|
51
|
+
function signerFromApiKey(secret) {
|
|
52
|
+
const parts = secret.split(".");
|
|
53
|
+
const legacy = parts.length === 3 && parts[0] === "fz";
|
|
54
|
+
const current = parts.length === 4 && parts[0] === "fz" && (parts[1] === "live" || parts[1] === "test");
|
|
55
|
+
if (!legacy && !current)
|
|
56
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
57
|
+
const keyId = parts[current ? 2 : 1];
|
|
58
|
+
const encodedSeed = parts[current ? 3 : 2];
|
|
59
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(keyId) || !/^[A-Za-z0-9_-]{43}$/.test(encodedSeed)) {
|
|
60
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
61
|
+
}
|
|
62
|
+
const seed = unbase64url(encodedSeed);
|
|
63
|
+
if (seed.length !== 32)
|
|
64
|
+
throw new VaultError("API_KEY_MALFORMED", "FORGEZERO_API_KEY is malformed.");
|
|
65
|
+
const keys = deriveKeysFromSeed(seed);
|
|
66
|
+
seed.fill(0);
|
|
67
|
+
return {
|
|
68
|
+
keyId,
|
|
69
|
+
async sign(request) {
|
|
70
|
+
const envelope = signRequest(keys, keyId, request);
|
|
71
|
+
return base64url(JSON.stringify({
|
|
72
|
+
timestamp: envelope.timestamp,
|
|
73
|
+
nonce: envelope.nonce,
|
|
74
|
+
edSignature: envelope.edSignature,
|
|
75
|
+
mlDsaSignature: envelope.mlDsaSignature
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
var requestAgent = (socketPath, request) => new Promise((resolveRequest, reject) => {
|
|
81
|
+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function") {
|
|
82
|
+
reject(new VaultError("MANAGED_RUNTIME_UNAVAILABLE", "The managed agent socket needs a Node or Bun server runtime."));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const net = process.getBuiltinModule("node:net");
|
|
86
|
+
const socket = net.connect(socketPath, () => socket.write(`${JSON.stringify(request)}
|
|
87
|
+
`));
|
|
88
|
+
let buffer = "";
|
|
89
|
+
socket.setTimeout(15000, () => {
|
|
90
|
+
socket.destroy();
|
|
91
|
+
reject(new VaultError("AGENT_TIMEOUT", "The local ForgeZero agent did not answer."));
|
|
92
|
+
});
|
|
93
|
+
socket.on("data", (chunk) => {
|
|
94
|
+
buffer += chunk.toString("utf8");
|
|
95
|
+
if (new TextEncoder().encode(buffer).byteLength > 256 * 1024) {
|
|
96
|
+
socket.destroy();
|
|
97
|
+
reject(new VaultError("AGENT_RESPONSE_TOO_LARGE", "The local ForgeZero agent response was too large."));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const newline = buffer.indexOf(`
|
|
101
|
+
`);
|
|
102
|
+
if (newline < 0)
|
|
103
|
+
return;
|
|
104
|
+
socket.end();
|
|
105
|
+
try {
|
|
106
|
+
resolveRequest(JSON.parse(buffer.slice(0, newline)));
|
|
107
|
+
} catch {
|
|
108
|
+
reject(new VaultError("AGENT_RESPONSE_MALFORMED", "The local ForgeZero agent response was malformed."));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
socket.on("error", (cause) => reject(new VaultError("AGENT_UNAVAILABLE", cause.message)));
|
|
112
|
+
});
|
|
21
113
|
var REASSIGN_ON = new Set([410, 421, 502, 503, 504, 530]);
|
|
22
|
-
var DEFAULT_ASSIGN_URL = "https://
|
|
114
|
+
var DEFAULT_ASSIGN_URL = "https://assign.forgezero.net/v1/assign";
|
|
115
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
116
|
+
var isForgeZeroNode = (hostname) => /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+forgezero\.net$/.test(hostname);
|
|
23
117
|
|
|
24
118
|
class ForgeZero {
|
|
25
119
|
credential;
|
|
@@ -28,14 +122,24 @@ class ForgeZero {
|
|
|
28
122
|
doFetch;
|
|
29
123
|
now;
|
|
30
124
|
signer;
|
|
125
|
+
agentTransport;
|
|
126
|
+
nodeAllowed;
|
|
127
|
+
requestTimeoutMs;
|
|
31
128
|
project;
|
|
32
129
|
environment;
|
|
33
130
|
constructor(options = {}) {
|
|
34
|
-
|
|
131
|
+
const credential = options.credential ?? discover(options.discovery);
|
|
132
|
+
this.credential = credential.mode === "managed" && !credential.socketPath ? { ...credential, socketPath: DEFAULT_SOCKET } : credential;
|
|
35
133
|
this.assignUrl = options.assignUrl ?? DEFAULT_ASSIGN_URL;
|
|
36
134
|
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
37
135
|
this.now = options.now ?? Date.now;
|
|
38
|
-
this.signer = options.signer;
|
|
136
|
+
this.signer = options.signer ?? (this.credential.mode === "external" ? signerFromApiKey(this.credential.apiKey) : undefined);
|
|
137
|
+
this.agentTransport = options.agentTransport ?? requestAgent;
|
|
138
|
+
this.nodeAllowed = options.nodeAllowed ?? isForgeZeroNode;
|
|
139
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
140
|
+
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 100 || this.requestTimeoutMs > 120000) {
|
|
141
|
+
throw new VaultError("INVALID_TIMEOUT", "requestTimeoutMs must be between 100 and 120000 milliseconds.");
|
|
142
|
+
}
|
|
39
143
|
this.project = options.project ?? "default";
|
|
40
144
|
this.environment = options.environment ?? "production";
|
|
41
145
|
}
|
|
@@ -43,17 +147,28 @@ class ForgeZero {
|
|
|
43
147
|
const now = this.now();
|
|
44
148
|
if (this.assignment && this.assignment.expiresAt > now)
|
|
45
149
|
return this.assignment.node;
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
150
|
+
const assignmentUrl = new URL(this.assignUrl);
|
|
151
|
+
if (this.signer?.keyId && !assignmentUrl.searchParams.has("client")) {
|
|
152
|
+
assignmentUrl.searchParams.set("client", this.signer.keyId);
|
|
153
|
+
}
|
|
154
|
+
const response = await this.doFetch(assignmentUrl, {
|
|
155
|
+
method: "GET",
|
|
156
|
+
headers: { accept: "application/json" },
|
|
157
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
50
158
|
});
|
|
51
159
|
if (!response.ok)
|
|
52
160
|
throw new VaultError("NO_NODE", "No vault node is available right now.");
|
|
53
|
-
const payload = await response.json();
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
161
|
+
const payload = await response.json().catch(() => null);
|
|
162
|
+
const hostname = typeof payload?.node === "string" ? payload.node : payload?.node && typeof payload.node === "object" && ("hostname" in payload.node) ? payload.node.hostname : undefined;
|
|
163
|
+
if (typeof hostname !== "string" || !this.nodeAllowed(hostname)) {
|
|
164
|
+
throw new VaultError("UNTRUSTED_NODE", "The directory returned an untrusted vault node hostname.");
|
|
165
|
+
}
|
|
166
|
+
const ttl = payload?.ttl ?? 60;
|
|
167
|
+
if (typeof ttl !== "number" || !Number.isFinite(ttl) || ttl < 1 || ttl > 300) {
|
|
168
|
+
throw new VaultError("INVALID_ASSIGNMENT", "The directory returned an invalid vault-node lease.");
|
|
169
|
+
}
|
|
170
|
+
this.assignment = { node: hostname, ttl, expiresAt: now + ttl * 1000 };
|
|
171
|
+
return hostname;
|
|
57
172
|
}
|
|
58
173
|
reassign() {
|
|
59
174
|
this.assignment = undefined;
|
|
@@ -62,17 +177,29 @@ class ForgeZero {
|
|
|
62
177
|
return `${encodeURIComponent(this.project)}/${encodeURIComponent(this.environment)}`;
|
|
63
178
|
}
|
|
64
179
|
async request(path, init = {}, retried = false) {
|
|
180
|
+
if (this.credential.mode === "managed")
|
|
181
|
+
return this.managedRequest(path, init);
|
|
65
182
|
const node = await this.node();
|
|
66
183
|
const body = typeof init.body === "string" ? init.body : "";
|
|
184
|
+
const target = new URL(path, "https://vault.invalid");
|
|
67
185
|
const headers = {
|
|
68
186
|
"content-type": "application/json",
|
|
69
187
|
...init.headers
|
|
70
188
|
};
|
|
71
189
|
if (this.signer) {
|
|
72
190
|
headers["x-fz-key"] = this.signer.keyId;
|
|
73
|
-
headers["x-fz-signature"] = await this.signer.sign(
|
|
191
|
+
headers["x-fz-signature"] = await this.signer.sign({
|
|
192
|
+
method: init.method ?? "GET",
|
|
193
|
+
path: target.pathname,
|
|
194
|
+
query: target.search.replace(/^\?/, ""),
|
|
195
|
+
body
|
|
196
|
+
});
|
|
74
197
|
}
|
|
75
|
-
const response = await this.doFetch(`https://${node}${path}`, {
|
|
198
|
+
const response = await this.doFetch(`https://${node}${path}`, {
|
|
199
|
+
...init,
|
|
200
|
+
headers,
|
|
201
|
+
signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs)
|
|
202
|
+
});
|
|
76
203
|
if (REASSIGN_ON.has(response.status) && !retried) {
|
|
77
204
|
this.reassign();
|
|
78
205
|
return this.request(path, init, true);
|
|
@@ -85,7 +212,66 @@ class ForgeZero {
|
|
|
85
212
|
}
|
|
86
213
|
return payload;
|
|
87
214
|
}
|
|
215
|
+
async managedRequest(path, init) {
|
|
216
|
+
const socketPath = this.credential.socketPath;
|
|
217
|
+
const method = init.method ?? "GET";
|
|
218
|
+
const target = new URL(path, "https://vault.invalid");
|
|
219
|
+
const entry = /\/entries\/([^/]+)$/.exec(target.pathname);
|
|
220
|
+
let response;
|
|
221
|
+
if (method === "GET" && entry) {
|
|
222
|
+
response = await this.agentTransport(socketPath, {
|
|
223
|
+
op: "get",
|
|
224
|
+
name: decodeURIComponent(entry[1])
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok)
|
|
227
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
228
|
+
if (response.op !== "get")
|
|
229
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
230
|
+
return { value: response.value };
|
|
231
|
+
}
|
|
232
|
+
if (method === "GET" && target.pathname.endsWith("/list")) {
|
|
233
|
+
response = await this.agentTransport(socketPath, { op: "held" });
|
|
234
|
+
if (!response.ok)
|
|
235
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
236
|
+
if (response.op !== "held")
|
|
237
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
238
|
+
return { entries: response.names.map((name) => ({ name })) };
|
|
239
|
+
}
|
|
240
|
+
if (method === "GET" && target.pathname.endsWith("/entries")) {
|
|
241
|
+
response = await this.agentTransport(socketPath, { op: "held" });
|
|
242
|
+
if (!response.ok)
|
|
243
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
244
|
+
if (response.op !== "held")
|
|
245
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
246
|
+
const values = {};
|
|
247
|
+
for (const name of response.names) {
|
|
248
|
+
const read = await this.agentTransport(socketPath, { op: "get", name });
|
|
249
|
+
if (!read.ok)
|
|
250
|
+
throw new VaultError(read.error.code, read.error.message);
|
|
251
|
+
if (read.op !== "get")
|
|
252
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
253
|
+
values[name] = read.value;
|
|
254
|
+
}
|
|
255
|
+
return { values };
|
|
256
|
+
}
|
|
257
|
+
if (method === "GET" && target.pathname.endsWith("/changes")) {
|
|
258
|
+
response = await this.agentTransport(socketPath, { op: "sync" });
|
|
259
|
+
if (!response.ok)
|
|
260
|
+
throw new VaultError(response.error.code, response.error.message);
|
|
261
|
+
if (response.op !== "sync")
|
|
262
|
+
throw new VaultError("AGENT_RESPONSE_MALFORMED", "The agent answered with the wrong operation.");
|
|
263
|
+
return {
|
|
264
|
+
version: response.cursor,
|
|
265
|
+
changed: response.invalidated.map((name) => ({ name, version: 0 })),
|
|
266
|
+
resync: response.resync
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
throw new VaultError("MANAGED_READ_ONLY", "This operation is not exposed to an application through the managed agent socket.");
|
|
270
|
+
}
|
|
88
271
|
async get(name, options = {}) {
|
|
272
|
+
if (this.credential.mode === "managed" && options.version !== undefined) {
|
|
273
|
+
throw new VaultError("MANAGED_CURRENT_ONLY", "The managed agent RAM replica serves the current value only; use an API key for a historical version.");
|
|
274
|
+
}
|
|
89
275
|
const query = options.version ? `?version=${options.version}` : "";
|
|
90
276
|
const result = await this.request(`/v1/vault/${this.scope()}/entries/${encodeURIComponent(name)}${query}`);
|
|
91
277
|
return result.value;
|
|
@@ -112,16 +298,10 @@ class ForgeZero {
|
|
|
112
298
|
return result.entries;
|
|
113
299
|
}
|
|
114
300
|
async set(name, value) {
|
|
115
|
-
|
|
116
|
-
method: "POST",
|
|
117
|
-
body: JSON.stringify({ name, value })
|
|
118
|
-
});
|
|
119
|
-
return result.version;
|
|
301
|
+
throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot write secrets; use the authenticated platform vault UI.");
|
|
120
302
|
}
|
|
121
303
|
async remove(name) {
|
|
122
|
-
|
|
123
|
-
method: "DELETE"
|
|
124
|
-
});
|
|
304
|
+
throw new VaultError("APPLICATION_READ_ONLY", "Application vault credentials cannot delete secrets; use the authenticated platform vault UI.");
|
|
125
305
|
}
|
|
126
306
|
async* watch(options = {}) {
|
|
127
307
|
const interval = options.intervalMs ?? 15000;
|
|
@@ -146,7 +326,43 @@ function vaultCredentials(vault) {
|
|
|
146
326
|
get: (reference, field) => vault.get(`${reference}.${field}`)
|
|
147
327
|
};
|
|
148
328
|
}
|
|
149
|
-
|
|
329
|
+
function directCredentials(read) {
|
|
330
|
+
return { name: "platform-direct", get: read };
|
|
331
|
+
}
|
|
332
|
+
var readRuntimeCredential = (path) => {
|
|
333
|
+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function")
|
|
334
|
+
return;
|
|
335
|
+
try {
|
|
336
|
+
const fs = process.getBuiltinModule("node:fs");
|
|
337
|
+
const stat = fs.statSync(path);
|
|
338
|
+
if (!stat.isFile() || stat.size > 1024 * 1024)
|
|
339
|
+
return;
|
|
340
|
+
return fs.readFileSync(path, "utf8");
|
|
341
|
+
} catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function systemdCredentials(options = {}) {
|
|
346
|
+
return {
|
|
347
|
+
name: "systemd",
|
|
348
|
+
async get(reference, field) {
|
|
349
|
+
const directory = options.directory ?? (typeof process !== "undefined" ? process.env?.CREDENTIALS_DIRECTORY : undefined);
|
|
350
|
+
if (!directory) {
|
|
351
|
+
throw new VaultError("CREDENTIAL_MISSING", "systemd did not provide CREDENTIALS_DIRECTORY to this unit.");
|
|
352
|
+
}
|
|
353
|
+
const name = (options.nameFor ?? ((ref, key) => `${ref}.${key}`))(reference, field);
|
|
354
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name)) {
|
|
355
|
+
throw new VaultError("CREDENTIAL_NAME_INVALID", "A systemd credential name may contain only letters, digits, dot, underscore and hyphen.");
|
|
356
|
+
}
|
|
357
|
+
const value = (options.read ?? readRuntimeCredential)(`${directory}/${name}`);
|
|
358
|
+
if (value === undefined || value.length === 0) {
|
|
359
|
+
throw new VaultError("CREDENTIAL_MISSING", `The systemd credential ${name} is not available to this unit.`);
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
var VERSION = "0.1.1";
|
|
150
366
|
|
|
151
367
|
// src/schema.ts
|
|
152
368
|
function managedSchemas(vault, options = {}) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
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
3
|
"name": "@forgezero/vault",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -41,6 +41,14 @@
|
|
|
41
41
|
"typescript": "^5.6.0",
|
|
42
42
|
"@types/bun": "latest"
|
|
43
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@forgezero/runtime": "^0.1.2"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@noble/curves": "^2.2.0",
|
|
49
|
+
"@noble/post-quantum": "^0.6.1",
|
|
50
|
+
"@noble/hashes": "^2.2.0"
|
|
51
|
+
},
|
|
44
52
|
"description": "ForgeZero vault client. Credential discovery, versioned secrets, node assignment.",
|
|
45
53
|
"keywords": [
|
|
46
54
|
"secrets",
|
|
@@ -55,7 +63,7 @@
|
|
|
55
63
|
"nextjs"
|
|
56
64
|
],
|
|
57
65
|
"license": "MIT",
|
|
58
|
-
"homepage": "https://forgezero.net/docs/vault-package",
|
|
66
|
+
"homepage": "https://www.forgezero.net/docs/vault-package",
|
|
59
67
|
"repository": {
|
|
60
68
|
"type": "git",
|
|
61
69
|
"url": "git+https://github.com/axxra/forgezero.git",
|