bulgur 1.1.0 → 1.2.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/README.md +46 -3
- package/bin/bulgur.js +3 -4
- package/binaries/darwin-arm64/bulgur +0 -0
- package/binaries/darwin-x64/bulgur +0 -0
- package/binaries/linux-arm64/bulgur +0 -0
- package/binaries/linux-x64/bulgur +0 -0
- package/dotenv-dsn.js +109 -0
- package/index.d.ts +27 -9
- package/index.js +96 -26
- package/oidc.js +263 -0
- package/package.json +16 -4
- package/scripts/prepack.cjs +24 -0
package/README.md
CHANGED
|
@@ -61,17 +61,60 @@ publish time from the goreleaser build.
|
|
|
61
61
|
|
|
62
62
|
## Runtime SDK
|
|
63
63
|
|
|
64
|
-
This package also exports a tiny
|
|
64
|
+
This package also exports a tiny runtime SDK for loading
|
|
65
65
|
secrets into `process.env` at process startup — no CLI binary involved:
|
|
66
66
|
|
|
67
67
|
```js
|
|
68
68
|
const { load } = require("bulgur");
|
|
69
69
|
// or: import { load } from "bulgur";
|
|
70
70
|
|
|
71
|
-
await load(); // reads BULGUR_DSN
|
|
71
|
+
await load(); // reads BULGUR_DSN from the environment or .env, then loads values
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Set `BULGUR_DSN` to a **runtime-scoped** token DSN:
|
|
75
75
|
`bulgur://TOKEN@HOST/ORG/PROJECT/ENV`. See the repo root `README.md`
|
|
76
76
|
("Runtime (deploy platforms)") for platform examples. `load()` accepts
|
|
77
|
-
`{ dsn, override, optional }` — see `index.d.ts` for details.
|
|
77
|
+
`{ dsn, envFile, override, optional }` — see `index.d.ts` for details. Token mode has no runtime dependencies.
|
|
78
|
+
|
|
79
|
+
In token mode, an explicit `dsn` takes precedence over the `BULGUR_DSN`
|
|
80
|
+
process variable; if neither is set, the SDK reads only `BULGUR_DSN` from
|
|
81
|
+
`.env` in the current working directory. It does not import other file variables
|
|
82
|
+
into `process.env` or expand references. When bootstrapping from a file,
|
|
83
|
+
`load()` also skips returned authentication-setting names so they cannot alter
|
|
84
|
+
later authentication. Use `envFile: "path/to/file"` to
|
|
85
|
+
select another file, or `envFile: false` to disable this lookup. A missing
|
|
86
|
+
implicit `.env` is ignored; an explicitly selected missing file or a malformed
|
|
87
|
+
file fails even with `optional: true`. Keep `.env` out of version control.
|
|
88
|
+
Vercel OIDC never reads this file; its mode is selected by options or the
|
|
89
|
+
process environment only.
|
|
90
|
+
|
|
91
|
+
### Vercel OIDC runtime
|
|
92
|
+
|
|
93
|
+
Vercel OIDC requires Node.js 20 or later and the optional helper:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
npm install @vercel/oidc@^4.0.7
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Create a Vercel workload identity in bulgur, and enable Vercel OIDC for the
|
|
100
|
+
project. In a Vercel Function, call inside the request handler:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const { loadValues } = require("bulgur");
|
|
104
|
+
const values = await loadValues({
|
|
105
|
+
auth: "vercel-oidc",
|
|
106
|
+
identityId: process.env.BULGUR_IDENTITY_ID,
|
|
107
|
+
});
|
|
108
|
+
// Use values.DATABASE_URL in this request.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The identity chooses the environment. An optional tokenless
|
|
112
|
+
`bulgur://api.bulgur.dev/ORG/PROJECT/ENV` DSN can select and verify the target;
|
|
113
|
+
a token-bearing DSN is rejected in this mode. `BULGUR_AUTH=vercel-oidc` and
|
|
114
|
+
`BULGUR_IDENTITY_ID` provide defaults. Explicit OIDC failures are always
|
|
115
|
+
reported, including with `optional: true`; they never fall back to a token.
|
|
116
|
+
|
|
117
|
+
`loadValues()` returns a request-local object and does not change `process.env`.
|
|
118
|
+
`load()` writes process-global environment variables and is intended for
|
|
119
|
+
one-time startup, not concurrent requests that can have different identities.
|
|
120
|
+
The Vercel helper needs request context inside functions; avoid module-level calls.
|
package/bin/bulgur.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
// Thin launcher: pick the bundled bulgur binary that matches the host
|
|
5
|
-
// platform/arch and
|
|
5
|
+
// platform/arch and run it, inheriting stdio and propagating its exit status.
|
|
6
6
|
// The binaries are fat-bundled into this package at pack time (see the repo
|
|
7
7
|
// Makefile `npm-pack` target), so there is NO network/postinstall step —
|
|
8
8
|
// this works even while the GitHub repo is private.
|
|
@@ -40,9 +40,8 @@ if (!fs.existsSync(binPath)) {
|
|
|
40
40
|
process.exit(1);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// handling keeps working.
|
|
43
|
+
// Inherit stdio; if the child terminates by signal, re-raise that signal in
|
|
44
|
+
// the launcher so the parent sees the corresponding termination cause.
|
|
46
45
|
const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
|
|
47
46
|
|
|
48
47
|
if (result.error) {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dotenv-dsn.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const { constants } = require("node:fs");
|
|
5
|
+
const MAX_BYTES = 1024 * 1024;
|
|
6
|
+
const invalid = () => new Error("bulgur: invalid environment file");
|
|
7
|
+
|
|
8
|
+
// Parse assignments without interpolation or modifying process.env. Parsing
|
|
9
|
+
// the whole file prevents a malformed credential from silently falling back.
|
|
10
|
+
function parseDSN(text) {
|
|
11
|
+
if (text.includes("\0")) throw invalid();
|
|
12
|
+
let i = 0;
|
|
13
|
+
let dsn;
|
|
14
|
+
let found = false;
|
|
15
|
+
if (text.charCodeAt(0) === 0xfeff) i++;
|
|
16
|
+
while (i < text.length) {
|
|
17
|
+
while (
|
|
18
|
+
text[i] === " " ||
|
|
19
|
+
text[i] === "\t" ||
|
|
20
|
+
text[i] === "\r" ||
|
|
21
|
+
text[i] === "\n"
|
|
22
|
+
)
|
|
23
|
+
i++;
|
|
24
|
+
if (i >= text.length) break;
|
|
25
|
+
if (text[i] === "#") {
|
|
26
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (text.startsWith("export ", i) || text.startsWith("export\t", i)) {
|
|
30
|
+
i += 7;
|
|
31
|
+
while (text[i] === " " || text[i] === "\t") i++;
|
|
32
|
+
}
|
|
33
|
+
const begin = i;
|
|
34
|
+
while (i < text.length && /[A-Za-z0-9_.-]/.test(text[i])) i++;
|
|
35
|
+
const key = text.slice(begin, i);
|
|
36
|
+
if (!key) throw invalid();
|
|
37
|
+
while (text[i] === " " || text[i] === "\t") i++;
|
|
38
|
+
if (text[i++] !== "=") throw invalid();
|
|
39
|
+
while (text[i] === " " || text[i] === "\t") i++;
|
|
40
|
+
let value = "";
|
|
41
|
+
if (text[i] === '"' || text[i] === "'") {
|
|
42
|
+
const quote = text[i++];
|
|
43
|
+
let closed = false;
|
|
44
|
+
while (i < text.length) {
|
|
45
|
+
const ch = text[i++];
|
|
46
|
+
if (ch === quote) {
|
|
47
|
+
closed = true;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (quote === '"' && ch === "\\" && i < text.length) {
|
|
51
|
+
const next = text[i++];
|
|
52
|
+
if (next === "n") value += "\n";
|
|
53
|
+
else if (next === "r") value += "\r";
|
|
54
|
+
else if (next === '"' || next === "\\") value += next;
|
|
55
|
+
else value += "\\" + next;
|
|
56
|
+
} else value += ch;
|
|
57
|
+
}
|
|
58
|
+
if (!closed) throw invalid();
|
|
59
|
+
while (text[i] === " " || text[i] === "\t" || text[i] === "\r") i++;
|
|
60
|
+
if (i < text.length && text[i] !== "\n" && text[i] !== "#")
|
|
61
|
+
throw invalid();
|
|
62
|
+
} else {
|
|
63
|
+
const start = i;
|
|
64
|
+
while (i < text.length && text[i] !== "\n" && text[i] !== "#") i++;
|
|
65
|
+
value = text.slice(start, i).trim();
|
|
66
|
+
}
|
|
67
|
+
if (text[i] === "#") while (i < text.length && text[i] !== "\n") i++;
|
|
68
|
+
if (i < text.length && text[i] === "\n") i++;
|
|
69
|
+
if (key === "BULGUR_DSN") {
|
|
70
|
+
if (found) throw invalid();
|
|
71
|
+
found = true;
|
|
72
|
+
if (!value || /[\s\x00-\x1f\x7f]/u.test(value)) throw invalid();
|
|
73
|
+
dsn = value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return dsn;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readDSN(path, allowMissing) {
|
|
80
|
+
let file;
|
|
81
|
+
try {
|
|
82
|
+
file = await fs.open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
83
|
+
const stat = await file.stat();
|
|
84
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) throw invalid();
|
|
85
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1);
|
|
86
|
+
let used = 0;
|
|
87
|
+
while (used < bytes.length) {
|
|
88
|
+
const { bytesRead } = await file.read(
|
|
89
|
+
bytes,
|
|
90
|
+
used,
|
|
91
|
+
bytes.length - used,
|
|
92
|
+
null,
|
|
93
|
+
);
|
|
94
|
+
if (bytesRead === 0) break;
|
|
95
|
+
used += bytesRead;
|
|
96
|
+
}
|
|
97
|
+
if (used > MAX_BYTES) throw invalid();
|
|
98
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
99
|
+
bytes.subarray(0, used),
|
|
100
|
+
);
|
|
101
|
+
return parseDSN(text);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (allowMissing && err && err.code === "ENOENT") return undefined;
|
|
104
|
+
throw invalid();
|
|
105
|
+
} finally {
|
|
106
|
+
if (file) await file.close().catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
module.exports = { readDSN };
|
package/index.d.ts
CHANGED
|
@@ -2,14 +2,27 @@
|
|
|
2
2
|
// Hand-written; no build step. Kept in sync with index.js by hand.
|
|
3
3
|
|
|
4
4
|
export interface LoadOptions {
|
|
5
|
+
/** Authentication mode. Defaults to BULGUR_AUTH or "token". */
|
|
6
|
+
auth?: "token" | "vercel-oidc";
|
|
7
|
+
/** Organization workload identity UUID (or BULGUR_IDENTITY_ID). */
|
|
8
|
+
identityId?: string;
|
|
5
9
|
/**
|
|
6
10
|
* The DSN to load, of the form `bulgur://TOKEN@HOST/ORG/PROJECT/ENV`.
|
|
7
|
-
*
|
|
11
|
+
* In token mode, defaults to `process.env.BULGUR_DSN`, then `BULGUR_DSN`
|
|
12
|
+
* from the selected .env file. In Vercel OIDC mode, omit it to
|
|
13
|
+
* use the identity target, or use `bulgur://HOST/ORG/PROJECT/ENV` without a token.
|
|
8
14
|
*/
|
|
9
15
|
dsn?: string;
|
|
10
16
|
|
|
17
|
+
/** Token mode only: custom .env path, or false to disable file lookup.
|
|
18
|
+
* Defaults to .env in the current working directory. An explicitly selected
|
|
19
|
+
* missing or malformed file throws, even when optional is true. When a DSN
|
|
20
|
+
* comes from this file, load() skips returned authentication-setting names.
|
|
21
|
+
*/
|
|
22
|
+
envFile?: string | false;
|
|
23
|
+
|
|
11
24
|
/**
|
|
12
|
-
*
|
|
25
|
+
* For load() only: when true, values fetched from bulgur overwrite any existing
|
|
13
26
|
* `process.env` entry with the same name.
|
|
14
27
|
*
|
|
15
28
|
* Default: false — existing `process.env` values win, and the fetched
|
|
@@ -18,10 +31,10 @@ export interface LoadOptions {
|
|
|
18
31
|
override?: boolean;
|
|
19
32
|
|
|
20
33
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
34
|
+
* In token mode, when true, a missing DSN, network failure, or non-2xx
|
|
35
|
+
* response resolves with no values: load() returns `{ loaded: 0 }` and
|
|
36
|
+
* loadValues() returns an empty object. In Vercel OIDC mode, authentication
|
|
37
|
+
* and request failures always throw regardless of this option.
|
|
25
38
|
* A DSN that IS present but malformed (bad scheme, missing token, wrong
|
|
26
39
|
* number of path segments, etc.) always throws regardless of this option
|
|
27
40
|
* — a configuration typo should be loud.
|
|
@@ -61,13 +74,18 @@ export interface ParsedDsn {
|
|
|
61
74
|
|
|
62
75
|
/**
|
|
63
76
|
* Load secret values for the environment identified by `options.dsn` (or
|
|
64
|
-
* `process.env.BULGUR_DSN`) and assign them onto `process.env`.
|
|
77
|
+
* `process.env.BULGUR_DSN` or the selected .env file) and assign them onto `process.env`.
|
|
65
78
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
79
|
+
* Startup only: this writes process-global environment variables. Use
|
|
80
|
+
* loadValues for request-isolated access in concurrent serverless functions.
|
|
68
81
|
*/
|
|
69
82
|
export function load(options?: LoadOptions): Promise<LoadResult>;
|
|
70
83
|
|
|
84
|
+
/** Fetch values without mutating process.env. Use for request-isolated access. */
|
|
85
|
+
export function loadValues(
|
|
86
|
+
options?: LoadOptions,
|
|
87
|
+
): Promise<Record<string, string>>;
|
|
88
|
+
|
|
71
89
|
/**
|
|
72
90
|
* Parse and validate a `bulgur://TOKEN@HOST/ORG/PROJECT/ENV` DSN without
|
|
73
91
|
* making a network call. Thrown error messages never include the token.
|
package/index.js
CHANGED
|
@@ -31,14 +31,14 @@ function parseDsn(dsn) {
|
|
|
31
31
|
const schemeIdx = dsn.indexOf(SCHEME_SEP);
|
|
32
32
|
if (schemeIdx === -1) {
|
|
33
33
|
throw new Error(
|
|
34
|
-
'bulgur: invalid DSN: expected "bulgur://TOKEN@HOST/ORG/PROJECT/ENV"'
|
|
34
|
+
'bulgur: invalid DSN: expected "bulgur://TOKEN@HOST/ORG/PROJECT/ENV"',
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const scheme = dsn.slice(0, schemeIdx);
|
|
39
39
|
if (scheme !== "bulgur") {
|
|
40
40
|
throw new Error(
|
|
41
|
-
`bulgur: invalid DSN: scheme must be "bulgur", got "${scheme}"
|
|
41
|
+
`bulgur: invalid DSN: scheme must be "bulgur", got "${scheme}"`,
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -48,7 +48,7 @@ function parseDsn(dsn) {
|
|
|
48
48
|
const atIdx = rest.indexOf("@");
|
|
49
49
|
if (atIdx === -1) {
|
|
50
50
|
throw new Error(
|
|
51
|
-
"bulgur: invalid DSN: missing token (expected TOKEN@HOST/...)"
|
|
51
|
+
"bulgur: invalid DSN: missing token (expected TOKEN@HOST/...)",
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -61,7 +61,7 @@ function parseDsn(dsn) {
|
|
|
61
61
|
const slashIdx = afterAt.indexOf("/");
|
|
62
62
|
if (slashIdx === -1) {
|
|
63
63
|
throw new Error(
|
|
64
|
-
"bulgur: invalid DSN: missing path (expected /ORG/PROJECT/ENV)"
|
|
64
|
+
"bulgur: invalid DSN: missing path (expected /ORG/PROJECT/ENV)",
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -79,7 +79,7 @@ function parseDsn(dsn) {
|
|
|
79
79
|
if (segments.length !== 3 || segments.some((s) => s.length === 0)) {
|
|
80
80
|
throw new Error(
|
|
81
81
|
"bulgur: invalid DSN: path must have exactly 3 non-empty segments " +
|
|
82
|
-
`(/ORG/PROJECT/ENV), got ${segments.length} segment(s)
|
|
82
|
+
`(/ORG/PROJECT/ENV), got ${segments.length} segment(s)`,
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
const [org, project, envSlug] = segments;
|
|
@@ -114,21 +114,44 @@ function parseDsn(dsn) {
|
|
|
114
114
|
* @param {{dsn?: string, override?: boolean, optional?: boolean}} [options]
|
|
115
115
|
* @returns {Promise<{loaded: number}>}
|
|
116
116
|
*/
|
|
117
|
-
async function
|
|
117
|
+
async function loadTokenValues(options) {
|
|
118
118
|
const opts = options || {};
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
let dsn;
|
|
120
|
+
let fromFile = false;
|
|
121
|
+
if (opts.dsn !== undefined) {
|
|
122
|
+
dsn = opts.dsn;
|
|
123
|
+
} else if (process.env.BULGUR_DSN !== undefined) {
|
|
124
|
+
dsn = process.env.BULGUR_DSN;
|
|
125
|
+
} else if (opts.envFile !== false) {
|
|
126
|
+
if (
|
|
127
|
+
opts.envFile !== undefined &&
|
|
128
|
+
(typeof opts.envFile !== "string" || !opts.envFile)
|
|
129
|
+
) {
|
|
130
|
+
throw new Error("bulgur: invalid environment file");
|
|
131
|
+
}
|
|
132
|
+
dsn = await require("./dotenv-dsn.js").readDSN(
|
|
133
|
+
opts.envFile ?? ".env",
|
|
134
|
+
opts.envFile === undefined,
|
|
135
|
+
);
|
|
136
|
+
fromFile = dsn !== undefined;
|
|
137
|
+
}
|
|
121
138
|
const optional = opts.optional === true;
|
|
122
139
|
|
|
123
140
|
if (!dsn) {
|
|
124
|
-
if (optional) return {
|
|
141
|
+
if (optional) return { secrets: [], fromFile };
|
|
125
142
|
throw new Error(
|
|
126
|
-
"bulgur: missing DSN (set BULGUR_DSN or pass { dsn } to load())"
|
|
143
|
+
"bulgur: missing DSN (set BULGUR_DSN or pass { dsn } to load())",
|
|
127
144
|
);
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
// Malformed DSN is always loud, even with optional:true.
|
|
131
|
-
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = parseDsn(dsn);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (fromFile) throw new Error("bulgur: invalid environment file DSN");
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
132
155
|
const { token, host, org, project, env, baseUrl } = parsed;
|
|
133
156
|
|
|
134
157
|
const url =
|
|
@@ -145,18 +168,22 @@ async function load(options) {
|
|
|
145
168
|
},
|
|
146
169
|
});
|
|
147
170
|
} catch (err) {
|
|
148
|
-
if (optional) return {
|
|
171
|
+
if (optional) return { secrets: [], fromFile };
|
|
172
|
+
if (fromFile)
|
|
173
|
+
throw new Error("bulgur: environment file DSN request failed");
|
|
149
174
|
throw new Error(
|
|
150
175
|
`bulgur: failed to reach ${host} (org=${org}, project=${project}, ` +
|
|
151
|
-
`env=${env}): ${err && err.message ? err.message : err}
|
|
176
|
+
`env=${env}): ${err && err.message ? err.message : err}`,
|
|
152
177
|
);
|
|
153
178
|
}
|
|
154
179
|
|
|
155
180
|
if (!res.ok) {
|
|
156
|
-
if (optional) return {
|
|
181
|
+
if (optional) return { secrets: [], fromFile };
|
|
182
|
+
if (fromFile)
|
|
183
|
+
throw new Error("bulgur: environment file DSN request failed");
|
|
157
184
|
throw new Error(
|
|
158
185
|
`bulgur: request to ${host} failed with HTTP ${res.status} ` +
|
|
159
|
-
`(org=${org}, project=${project}, env=${env})
|
|
186
|
+
`(org=${org}, project=${project}, env=${env})`,
|
|
160
187
|
);
|
|
161
188
|
}
|
|
162
189
|
|
|
@@ -164,28 +191,71 @@ async function load(options) {
|
|
|
164
191
|
try {
|
|
165
192
|
body = await res.json();
|
|
166
193
|
} catch (err) {
|
|
167
|
-
if (optional) return {
|
|
194
|
+
if (optional) return { secrets: [], fromFile };
|
|
195
|
+
if (fromFile)
|
|
196
|
+
throw new Error("bulgur: environment file DSN request failed");
|
|
168
197
|
throw new Error(
|
|
169
198
|
`bulgur: failed to parse response from ${host} as JSON ` +
|
|
170
|
-
`(org=${org}, project=${project}, env=${env})
|
|
199
|
+
`(org=${org}, project=${project}, env=${env})`,
|
|
171
200
|
);
|
|
172
201
|
}
|
|
173
202
|
|
|
174
|
-
|
|
203
|
+
return {
|
|
204
|
+
secrets: body && Array.isArray(body.secrets) ? body.secrets : [],
|
|
205
|
+
fromFile,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const reserved = new Set([
|
|
210
|
+
"VERCEL_OIDC_TOKEN",
|
|
211
|
+
"VERCEL_OIDC_TOKEN_FILE",
|
|
212
|
+
"ACTIONS_ID_TOKEN_REQUEST_URL",
|
|
213
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
214
|
+
"BULGUR_AUTH",
|
|
215
|
+
"BULGUR_TOKEN",
|
|
216
|
+
"BULGUR_IDENTITY_ID",
|
|
217
|
+
"BULGUR_DSN",
|
|
218
|
+
"BULGUR_HOST",
|
|
219
|
+
"__proto__",
|
|
220
|
+
]);
|
|
221
|
+
function modeFor(opts) {
|
|
222
|
+
const mode = opts.auth ?? process.env.BULGUR_AUTH ?? "token";
|
|
223
|
+
if (mode !== "token" && mode !== "vercel-oidc")
|
|
224
|
+
throw new Error("bulgur: invalid authentication mode");
|
|
225
|
+
return mode;
|
|
226
|
+
}
|
|
227
|
+
async function fetchSecrets(opts, mode) {
|
|
228
|
+
return mode === "vercel-oidc"
|
|
229
|
+
? { secrets: await require("./oidc.js").loadOIDC(opts), fromFile: false }
|
|
230
|
+
: loadTokenValues(opts);
|
|
231
|
+
}
|
|
232
|
+
async function loadValues(options) {
|
|
233
|
+
const opts = options || {};
|
|
234
|
+
const { secrets } = await fetchSecrets(opts, modeFor(opts));
|
|
235
|
+
const result = Object.create(null);
|
|
236
|
+
for (const secret of secrets) {
|
|
237
|
+
if (secret && typeof secret.name === "string")
|
|
238
|
+
result[secret.name] = secret.value == null ? "" : String(secret.value);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
async function load(options) {
|
|
243
|
+
const opts = options || {};
|
|
244
|
+
const mode = modeFor(opts);
|
|
245
|
+
const { secrets, fromFile } = await fetchSecrets(opts, mode);
|
|
175
246
|
let loaded = 0;
|
|
176
247
|
for (const secret of secrets) {
|
|
177
248
|
if (!secret || typeof secret.name !== "string") continue;
|
|
178
249
|
const key = secret.name;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
key
|
|
182
|
-
|
|
183
|
-
|
|
250
|
+
if ((mode === "vercel-oidc" || fromFile) && reserved.has(key)) continue;
|
|
251
|
+
if (
|
|
252
|
+
Object.prototype.hasOwnProperty.call(process.env, key) &&
|
|
253
|
+
opts.override !== true
|
|
254
|
+
)
|
|
255
|
+
continue;
|
|
184
256
|
process.env[key] = secret.value == null ? "" : String(secret.value);
|
|
185
257
|
loaded++;
|
|
186
258
|
}
|
|
187
|
-
|
|
188
259
|
return { loaded };
|
|
189
260
|
}
|
|
190
|
-
|
|
191
|
-
module.exports = { load, parseDsn };
|
|
261
|
+
module.exports = { load, loadValues, parseDsn };
|
package/oidc.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { createHash } = require("node:crypto");
|
|
4
|
+
const sessions = new Map();
|
|
5
|
+
const inflight = new Map();
|
|
6
|
+
const targets = new Map();
|
|
7
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const fail = () =>
|
|
9
|
+
new Error("bulgur: Vercel OIDC authentication or request failed");
|
|
10
|
+
|
|
11
|
+
function configFor(opts) {
|
|
12
|
+
const identity = opts.identityId ?? process.env.BULGUR_IDENTITY_ID;
|
|
13
|
+
if (
|
|
14
|
+
typeof identity !== "string" ||
|
|
15
|
+
!uuid.test(identity) ||
|
|
16
|
+
/^0{8}-0{4}-0{4}-0{4}-0{12}$/.test(identity)
|
|
17
|
+
)
|
|
18
|
+
throw fail();
|
|
19
|
+
const dsn = opts.dsn !== undefined ? opts.dsn : process.env.BULGUR_DSN;
|
|
20
|
+
if (!dsn) return { identity, base: "https://api.bulgur.dev", path: "" };
|
|
21
|
+
if (typeof dsn !== "string" || !dsn.startsWith("bulgur://")) throw fail();
|
|
22
|
+
let u;
|
|
23
|
+
try {
|
|
24
|
+
u = new URL(dsn);
|
|
25
|
+
} catch {
|
|
26
|
+
throw fail();
|
|
27
|
+
}
|
|
28
|
+
if (u.username || u.password || u.search || u.hash || !u.hostname)
|
|
29
|
+
throw fail();
|
|
30
|
+
const parts = u.pathname.replace(/\/$/, "").split("/").slice(1);
|
|
31
|
+
if (
|
|
32
|
+
parts.length !== 3 ||
|
|
33
|
+
parts.some((v) => !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(v))
|
|
34
|
+
)
|
|
35
|
+
throw fail();
|
|
36
|
+
const host = u.hostname.toLowerCase();
|
|
37
|
+
const cloud = host === "api.bulgur.dev" && (!u.port || u.port === "443");
|
|
38
|
+
const local =
|
|
39
|
+
host === "localhost" || host === "127.0.0.1" || host === "[::1]";
|
|
40
|
+
if (!cloud && !local) throw fail();
|
|
41
|
+
return {
|
|
42
|
+
identity,
|
|
43
|
+
base: cloud ? "https://api.bulgur.dev" : `http://${u.host}`,
|
|
44
|
+
path: `/v1/orgs/${parts[0]}/projects/${parts[1]}/environments/${parts[2]}/values`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function providerToken(fresh = false) {
|
|
49
|
+
try {
|
|
50
|
+
const provider = await import("@vercel/oidc");
|
|
51
|
+
let timer;
|
|
52
|
+
const token = await Promise.race([
|
|
53
|
+
provider.getVercelOidcToken({
|
|
54
|
+
audience: "https://api.bulgur.dev",
|
|
55
|
+
...(fresh ? { skipCache: true } : {}),
|
|
56
|
+
}),
|
|
57
|
+
new Promise((_, reject) => {
|
|
58
|
+
timer = setTimeout(() => reject(fail()), 5000);
|
|
59
|
+
}),
|
|
60
|
+
]).finally(() => clearTimeout(timer));
|
|
61
|
+
if (typeof token !== "string" || !token || token.length > 16384)
|
|
62
|
+
throw fail();
|
|
63
|
+
return token;
|
|
64
|
+
} catch {
|
|
65
|
+
throw fail();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function sourceExpiry(jwt) {
|
|
69
|
+
try {
|
|
70
|
+
const payload = JSON.parse(
|
|
71
|
+
Buffer.from(jwt.split(".")[1], "base64url").toString("utf8"),
|
|
72
|
+
);
|
|
73
|
+
if (!Number.isFinite(payload.exp)) throw fail();
|
|
74
|
+
return payload.exp * 1000;
|
|
75
|
+
} catch {
|
|
76
|
+
throw fail();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function requestJSON(url, init, limit) {
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
...init,
|
|
85
|
+
redirect: "error",
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
if (res.body) await res.body.cancel();
|
|
90
|
+
return { status: res.status };
|
|
91
|
+
}
|
|
92
|
+
if (!res.body) throw fail();
|
|
93
|
+
const reader = res.body.getReader();
|
|
94
|
+
const chunks = [];
|
|
95
|
+
let size = 0;
|
|
96
|
+
for (;;) {
|
|
97
|
+
const item = await reader.read();
|
|
98
|
+
if (item.done) break;
|
|
99
|
+
size += item.value.length;
|
|
100
|
+
if (size > limit) {
|
|
101
|
+
await reader.cancel();
|
|
102
|
+
throw fail();
|
|
103
|
+
}
|
|
104
|
+
chunks.push(item.value);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
status: res.status,
|
|
108
|
+
body: JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
|
109
|
+
};
|
|
110
|
+
} catch {
|
|
111
|
+
throw fail();
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function keyFor(config, jwt) {
|
|
117
|
+
return createHash("sha256")
|
|
118
|
+
.update(JSON.stringify([config.identity, config.base, config.path, jwt]))
|
|
119
|
+
.digest("hex");
|
|
120
|
+
}
|
|
121
|
+
async function exchange(config, jwt, previous) {
|
|
122
|
+
const result = await requestJSON(
|
|
123
|
+
`${config.base}/v1/auth/oidc`,
|
|
124
|
+
{
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
Accept: "application/json",
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
provider: "vercel",
|
|
132
|
+
identityId: config.identity,
|
|
133
|
+
jwt,
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
65536,
|
|
137
|
+
);
|
|
138
|
+
const b = result.body;
|
|
139
|
+
const expiry = b && Date.parse(b.expiresAt);
|
|
140
|
+
if (
|
|
141
|
+
result.status !== 200 ||
|
|
142
|
+
!b ||
|
|
143
|
+
typeof b.token !== "string" ||
|
|
144
|
+
!b.token.startsWith("blg_wl_") ||
|
|
145
|
+
b.tokenType !== "Bearer" ||
|
|
146
|
+
!uuid.test(b.projectId || "") ||
|
|
147
|
+
!uuid.test(b.environmentId || "") ||
|
|
148
|
+
!Number.isFinite(expiry) ||
|
|
149
|
+
expiry <= Date.now() ||
|
|
150
|
+
expiry > Date.now() + 305000 ||
|
|
151
|
+
expiry > sourceExpiry(jwt) ||
|
|
152
|
+
(previous &&
|
|
153
|
+
(previous.projectId !== b.projectId ||
|
|
154
|
+
previous.environmentId !== b.environmentId))
|
|
155
|
+
)
|
|
156
|
+
throw fail();
|
|
157
|
+
return {
|
|
158
|
+
token: b.token,
|
|
159
|
+
expiry,
|
|
160
|
+
projectId: b.projectId,
|
|
161
|
+
environmentId: b.environmentId,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function sessionFor(config, jwt, previous) {
|
|
165
|
+
const key = keyFor(config, jwt);
|
|
166
|
+
const cached = sessions.get(key);
|
|
167
|
+
if (
|
|
168
|
+
cached &&
|
|
169
|
+
cached.expiry > Date.now() + 30000 &&
|
|
170
|
+
(!previous ||
|
|
171
|
+
(cached.projectId === previous.projectId &&
|
|
172
|
+
cached.environmentId === previous.environmentId))
|
|
173
|
+
) {
|
|
174
|
+
sessions.delete(key);
|
|
175
|
+
sessions.set(key, cached);
|
|
176
|
+
return { key, session: cached };
|
|
177
|
+
}
|
|
178
|
+
sessions.delete(key);
|
|
179
|
+
let pending = inflight.get(key);
|
|
180
|
+
if (!pending) {
|
|
181
|
+
if (inflight.size >= 64) throw fail();
|
|
182
|
+
pending = exchange(config, jwt, previous);
|
|
183
|
+
inflight.set(key, pending);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const session = await pending;
|
|
187
|
+
if (
|
|
188
|
+
previous &&
|
|
189
|
+
(session.projectId !== previous.projectId ||
|
|
190
|
+
session.environmentId !== previous.environmentId)
|
|
191
|
+
)
|
|
192
|
+
throw fail();
|
|
193
|
+
sessions.set(key, session);
|
|
194
|
+
while (sessions.size > 64) sessions.delete(sessions.keys().next().value);
|
|
195
|
+
return { key, session };
|
|
196
|
+
} finally {
|
|
197
|
+
if (inflight.get(key) === pending) inflight.delete(key);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function loadOIDC(opts) {
|
|
201
|
+
const config = configFor(opts);
|
|
202
|
+
const targetKey = JSON.stringify([config.identity, config.base, config.path]);
|
|
203
|
+
let current = await sessionFor(
|
|
204
|
+
config,
|
|
205
|
+
await providerToken(),
|
|
206
|
+
targets.get(targetKey),
|
|
207
|
+
);
|
|
208
|
+
const prior = targets.get(targetKey);
|
|
209
|
+
if (
|
|
210
|
+
prior &&
|
|
211
|
+
(prior.projectId !== current.session.projectId ||
|
|
212
|
+
prior.environmentId !== current.session.environmentId)
|
|
213
|
+
)
|
|
214
|
+
throw fail();
|
|
215
|
+
targets.delete(targetKey);
|
|
216
|
+
targets.set(targetKey, {
|
|
217
|
+
projectId: current.session.projectId,
|
|
218
|
+
environmentId: current.session.environmentId,
|
|
219
|
+
});
|
|
220
|
+
while (targets.size > 64) targets.delete(targets.keys().next().value);
|
|
221
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
222
|
+
const path =
|
|
223
|
+
config.path ||
|
|
224
|
+
`/v1/environments/${current.session.environmentId}/secrets/values`;
|
|
225
|
+
const result = await requestJSON(
|
|
226
|
+
config.base + path,
|
|
227
|
+
{
|
|
228
|
+
headers: {
|
|
229
|
+
Authorization: `Bearer ${current.session.token}`,
|
|
230
|
+
Accept: "application/json",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
8 * 1024 * 1024,
|
|
234
|
+
);
|
|
235
|
+
if (
|
|
236
|
+
result.status === 200 &&
|
|
237
|
+
result.body &&
|
|
238
|
+
Array.isArray(result.body.secrets)
|
|
239
|
+
) {
|
|
240
|
+
const secrets = result.body.secrets;
|
|
241
|
+
if (
|
|
242
|
+
!secrets.every(
|
|
243
|
+
(item) =>
|
|
244
|
+
item &&
|
|
245
|
+
typeof item.name === "string" &&
|
|
246
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(item.name) &&
|
|
247
|
+
typeof item.value === "string",
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
throw fail();
|
|
251
|
+
return secrets;
|
|
252
|
+
}
|
|
253
|
+
if (result.status !== 401 || attempt !== 0) throw fail();
|
|
254
|
+
sessions.delete(current.key);
|
|
255
|
+
current = await sessionFor(
|
|
256
|
+
config,
|
|
257
|
+
await providerToken(true),
|
|
258
|
+
current.session,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
throw fail();
|
|
262
|
+
}
|
|
263
|
+
module.exports = { loadOIDC };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bulgur",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Ultra-simple secrets manager CLI (bulgur), plus a runtime SDK: `import { load } from \"bulgur\"`",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bulgur": "bin/bulgur.js"
|
|
@@ -20,10 +20,14 @@
|
|
|
20
20
|
"binaries/",
|
|
21
21
|
"index.js",
|
|
22
22
|
"index.d.ts",
|
|
23
|
-
"README.md"
|
|
23
|
+
"README.md",
|
|
24
|
+
"oidc.js",
|
|
25
|
+
"dotenv-dsn.js",
|
|
26
|
+
"scripts/prepack.cjs"
|
|
24
27
|
],
|
|
25
28
|
"scripts": {
|
|
26
|
-
"test": "node --test"
|
|
29
|
+
"test": "node --test",
|
|
30
|
+
"prepack": "node scripts/prepack.cjs"
|
|
27
31
|
},
|
|
28
32
|
"engines": {
|
|
29
33
|
"node": ">=18"
|
|
@@ -46,5 +50,13 @@
|
|
|
46
50
|
"dotenv",
|
|
47
51
|
"environment-variables",
|
|
48
52
|
"secrets-manager"
|
|
49
|
-
]
|
|
53
|
+
],
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@vercel/oidc": "^4.0.7"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@vercel/oidc": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
50
62
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Fail closed for a direct pack/publish from an incomplete source checkout.
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const root = path.resolve(__dirname, "..");
|
|
6
|
+
const required = ["bin/bulgur.js", "index.js", "index.d.ts", "oidc.js", "dotenv-dsn.js"];
|
|
7
|
+
for (const name of ["darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"]) {
|
|
8
|
+
required.push(`binaries/${name}/bulgur`);
|
|
9
|
+
}
|
|
10
|
+
for (const name of required) {
|
|
11
|
+
let info;
|
|
12
|
+
try { info = fs.lstatSync(path.join(root, name)); } catch { throw new Error("Incomplete release bundle; build it with make npm-pack"); }
|
|
13
|
+
if (!info.isFile() || info.size === 0 || ((name.startsWith("binaries/") || name === "bin/bulgur.js") && !(info.mode & 0o111))) {
|
|
14
|
+
throw new Error("Incomplete release bundle; build it with make npm-pack");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const version = require(path.join(root, "package.json")).version;
|
|
18
|
+
for (const platform of ["darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"]) {
|
|
19
|
+
const bytes = fs.readFileSync(path.join(root, "binaries", platform, "bulgur"));
|
|
20
|
+
const validFormat = bytes.length >= 20 && (platform.startsWith("linux-")
|
|
21
|
+
? bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) && bytes.readUInt16LE(18) === (platform.endsWith("x64") ? 62 : 183)
|
|
22
|
+
: bytes.readUInt32LE(0) === 0xfeedfacf && bytes.readUInt32LE(4) === (platform.endsWith("x64") ? 0x01000007 : 0x0100000c));
|
|
23
|
+
if (!validFormat || !bytes.includes(Buffer.from(version))) throw new Error("Invalid release binary; build it with make npm-pack");
|
|
24
|
+
}
|