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 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, dependency-free runtime SDK for loading
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, assigns onto process.env
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 exec it, forwarding stdio, the exit code, and signals.
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
- // spawnSync with stdio: inherit forwards stdin/stdout/stderr transparently and
44
- // relays terminating signals to the child on POSIX, so the run command's signal
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
- * Defaults to `process.env.BULGUR_DSN`.
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
- * When true, values fetched from bulgur overwrite any existing
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
- * When true:
22
- * - a missing/empty DSN resolves `{ loaded: 0 }` instead of throwing.
23
- * - a network failure or non-2xx HTTP response resolves `{ loaded: 0 }`
24
- * instead of throwing.
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
- * This is a one-shot call: after it resolves, this module has no further
67
- * runtime effect and holds no open connections.
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 load(options) {
117
+ async function loadTokenValues(options) {
118
118
  const opts = options || {};
119
- const dsn = opts.dsn !== undefined ? opts.dsn : process.env.BULGUR_DSN;
120
- const override = opts.override === true;
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 { loaded: 0 };
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
- const parsed = parseDsn(dsn);
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 { loaded: 0 };
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 { loaded: 0 };
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 { loaded: 0 };
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
- const secrets = body && Array.isArray(body.secrets) ? body.secrets : [];
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
- const hasExisting = Object.prototype.hasOwnProperty.call(
180
- process.env,
181
- key
182
- );
183
- if (hasExisting && !override) continue; // existing env vars win by default
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.1.0",
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
+ }