@saastemly/voidcommerce 0.4.0 → 0.5.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.
@@ -4,7 +4,7 @@ import { delimiter, dirname, join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import color from "picocolors";
6
6
  import { allEnvKeys } from "../generate/env";
7
- import { keyFor } from "./keys";
7
+ import { committedPublicKey, provisionKey } from "./keys";
8
8
  import type { Project } from "../project";
9
9
 
10
10
  /**
@@ -20,10 +20,11 @@ import type { Project } from "../project";
20
20
  *
21
21
  * dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
22
22
  * it is COMMITTED: the key names are readable, the values are not, and a
23
- * diff shows when a secret changed without showing what it changed to. The
24
- * private key is not stored at all — it is derived from the Cloudflare API
25
- * token, so whoever can deploy the worker can read its secrets and nobody
26
- * else can. See `./keys.ts`, which also spells out what that costs.
23
+ * diff shows when a secret changed without showing what it changed to.
24
+ *
25
+ * Because dotenvx is asymmetric, SETTING a secret needs only the committed
26
+ * public key no credential, no login, no key file. The private key lives
27
+ * in one place, GitHub Actions, and only the deploy uses it. See `./keys.ts`.
27
28
  *
28
29
  * ── Why the values still become worker SECRETS ───────────────────────────
29
30
  *
@@ -42,10 +43,9 @@ import type { Project } from "../project";
42
43
  * in the history, including ones that were rotated — which is not true of
43
44
  * `wrangler secret put`, where a rotation genuinely retires the old value.
44
45
  *
45
- * And because the key is derived, rotating the API token orphans the lot.
46
- * `keys.ts` guards that with a public-key check, so the failure is a clear
47
- * refusal rather than a decryption error nobody can place but the guard
48
- * cannot recover values, only explain them.
46
+ * And GitHub will not hand a secret back once set, so re-keying means
47
+ * entering the values again. That is the right trade: a key worth rotating
48
+ * is a key that may have leaked, and the values then need replacing anyway.
49
49
  *
50
50
  * Both are the price of a shop that rebuilds from a checkout, and both
51
51
  * should be decisions rather than surprises.
@@ -72,7 +72,7 @@ export function findDotenvx(from: string): string | null {
72
72
  : null;
73
73
  }
74
74
 
75
- function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
75
+ export function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
76
76
  return new Promise((resolve) => {
77
77
  const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
78
78
  let out = "";
@@ -87,6 +87,28 @@ function run(cmd: string, args: string[], cwd: string, env: Record<string, strin
87
87
  });
88
88
  }
89
89
 
90
+ /**
91
+ * Put a public key at the top of `.env.secrets`, creating the file if it is
92
+ * not there yet.
93
+ *
94
+ * Only the public half is ever written to disk, and it is meant to be
95
+ * committed: it is what lets anyone with a clone SET a secret without
96
+ * holding a credential.
97
+ */
98
+ export function committedPublicKeyInto(root: string, publicKey: string): void {
99
+ const path = join(root, SECRETS_FILE);
100
+ const line = `DOTENV_PUBLIC_KEY_SECRETS="${publicKey}"`;
101
+ if (!existsSync(path)) {
102
+ writeFileSync(path, `${line}\n`, { mode: 0o600 });
103
+ return;
104
+ }
105
+ const body = readFileSync(path, "utf8");
106
+ writeFileSync(
107
+ path,
108
+ /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=.*$/m.test(body) ? body.replace(/^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=.*$/m, line) : `${line}\n${body}`,
109
+ );
110
+ }
111
+
90
112
  /**
91
113
  * The names a `.env.secrets` declares, read WITHOUT decrypting.
92
114
  *
@@ -136,16 +158,41 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
136
158
  const root = project.root;
137
159
  if (!existsSync(join(root, SECRETS_FILE))) return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
138
160
 
161
+ // The private key comes from the environment, which in the normal case is
162
+ // GitHub Actions injecting the repository secret. A laptop has no reason
163
+ // to hold it, so a missing one is explained rather than treated as a fault.
164
+ const privateKey = process.env[PRIVATE_KEY_VAR];
165
+ if (!privateKey) {
166
+ return {
167
+ error:
168
+ `${PRIVATE_KEY_VAR} is not set, so ${SECRETS_FILE} cannot be decrypted here.\n` +
169
+ ` That is the normal state on a laptop: the key lives in GitHub Actions and\n` +
170
+ ` only the deploy uses it. Push, and the workflow does this step.\n` +
171
+ ` To deploy by hand anyway, export the key for one command.`,
172
+ };
173
+ }
174
+ const expected = committedPublicKey(root);
175
+ if (expected) {
176
+ const { publicKeyFor } = await import("./keys");
177
+ const derived = await publicKeyFor(privateKey);
178
+ if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
179
+ return {
180
+ error:
181
+ `${PRIVATE_KEY_VAR} does not open this repository.\n` +
182
+ ` it derives: ${derived.slice(0, 16)}…\n` +
183
+ ` encrypted under: ${expected.slice(0, 16)}…\n` +
184
+ ` It belongs to a different shop, or the repository has been re-keyed since.`,
185
+ };
186
+ }
187
+ }
188
+
189
+ // Last, deliberately: "that key belongs to another shop" is more use than
190
+ // "install dotenvx" when both are true.
139
191
  const dotenvx = findDotenvx(root);
140
192
  if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
141
193
 
142
- // Derived from the API token, never read from disk. A mismatch is caught
143
- // here rather than surfacing as a decryption failure nobody can explain.
144
- const key = await keyFor(project.manifest, root);
145
- if (!key.ok) return { error: key.reason };
146
-
147
194
  const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
148
- [PRIVATE_KEY_VAR]: key.privateKey,
195
+ [PRIVATE_KEY_VAR]: privateKey,
149
196
  });
150
197
  if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
151
198
 
@@ -182,6 +229,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
182
229
  const bare = plaintextSecretNames(root);
183
230
 
184
231
  if (args.includes("--init")) return initSecrets(project);
232
+ if (args[0] === "set") return setSecretValue(project, args.slice(1));
185
233
 
186
234
  if (!existsSync(join(root, SECRETS_FILE))) {
187
235
  console.log(
@@ -220,17 +268,26 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
220
268
  }
221
269
 
222
270
  /** Write a `.env.secrets` with every required key as the `unset` sentinel. */
223
- async function initSecrets(project: Project): Promise<number> {
271
+ export async function initSecrets(project: Project): Promise<number> {
224
272
  const path = join(project.root, SECRETS_FILE);
225
- if (existsSync(path)) {
226
- console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
273
+
274
+ // `vc link` leaves a file holding nothing but the public key, so "exists"
275
+ // is not the same as "is already set up". Refusing on existence alone
276
+ // stranded anyone who linked first, which is the order the docs recommend.
277
+ if (existsSync(path) && declaredSecretNames(project.root).size > 0) {
278
+ console.error(`vc: ${SECRETS_FILE} already declares secrets; not overwriting it.\n \`vc secrets set KEY\` changes one.`);
227
279
  return 1;
228
280
  }
229
- // The PUBLIC key goes in the file, so `dotenvx encrypt` uses the derived
230
- // pair rather than inventing one and writing a .env.keys.
231
- const key = await keyFor(project.manifest, project.root);
232
- if (!key.ok) {
233
- console.error(`\nvc: ${key.reason}\n`);
281
+
282
+ // The PUBLIC key goes in the file so encryption uses this shop's pair
283
+ // rather than inventing one and writing a .env.keys nobody asked for. Its
284
+ // private half goes straight to GitHub and is never seen again — so an
285
+ // existing key is REUSED, never replaced: replacing it would orphan
286
+ // whatever it has already encrypted.
287
+ const existing = committedPublicKey(project.root);
288
+ const made = existing ? ({ ok: true, publicKey: existing } as const) : await provisionKey(project);
289
+ if (!made.ok) {
290
+ console.error(`\nvc: ${made.reason}\n`);
234
291
  return 1;
235
292
  }
236
293
  const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
@@ -241,26 +298,151 @@ async function initSecrets(project: Project): Promise<number> {
241
298
  "# secret changed without showing what it changed to. `unset` is the",
242
299
  "# documented placeholder and preflight refuses it.",
243
300
  "#",
244
- "# The private key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the",
245
- "# account id — it is stored nowhere, so there is no .env.keys to leak.",
301
+ "# Setting a value needs NO credential: dotenvx encrypts with the public key",
302
+ "# below, which is right here in the repository. Only the deploy decrypts,",
303
+ "# and the private key for that lives in GitHub Actions.",
246
304
  "#",
247
- "# The cost of that: rotating the token makes every value below",
248
- "# permanently unreadable. `vc keys --rotate` re-encrypts while you still",
249
- "# have the old token; after that there is no recovery.",
305
+ "# `vc secrets set KEY` is the way to change one — it reads the value from",
306
+ "# the terminal, so it never reaches a command line where `ps` would show it.",
250
307
  "",
251
- `DOTENV_PUBLIC_KEY_SECRETS="${key.publicKey}"`,
308
+ `DOTENV_PUBLIC_KEY_SECRETS="${made.publicKey}"`,
252
309
  "",
253
310
  ...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
254
311
  ].join("\n");
255
312
  writeFileSync(path, body, { mode: 0o600 });
256
313
 
257
314
  console.log(
258
- `\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`, under the key this token derives\n\n` +
315
+ `\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`\n` +
316
+ `${existing ? color.dim(`= ${PRIVATE_KEY_VAR} was already on GitHub; reused it`) : `${color.green("+")} ${PRIVATE_KEY_VAR} stored on GitHub; the public key is in the file`}\n\n` +
259
317
  `Next:\n` +
260
- ` 1. put the real values in, then\n` +
261
- ` 2. ${color.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)}\n` +
262
- ` 3. commit it — there is no key file to keep out\n\n` +
263
- color.yellow(`! Rotating CLOUDFLARE_API_TOKEN makes these unreadable. \`vc keys\` explains.\n`),
318
+ `${required.map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join("\n")}\n\n` +
319
+ `then commit ${SECRETS_FILE}. There is no key file to keep out of git.\n`,
264
320
  );
265
321
  return 0;
266
322
  }
323
+
324
+
325
+ /**
326
+ * `vc secrets set KEY` — change one value, with no credential of any kind.
327
+ *
328
+ * This is the command the whole design exists to make possible. dotenvx
329
+ * encrypts with the public key sitting in the committed file, so a
330
+ * contributor with a clone and nothing else can rotate the Stripe key. They
331
+ * cannot read the one that is there, which is exactly right.
332
+ *
333
+ * The value is read from the terminal or from stdin, never from argv: an
334
+ * argument is visible in `ps` to every process on the machine while the
335
+ * command runs, and shells keep it in history besides.
336
+ */
337
+ export async function setSecretValue(project: Project, args: string[]): Promise<number> {
338
+ const root = project.root;
339
+ const name = args.find((arg) => !arg.startsWith("-"));
340
+ if (!name) {
341
+ console.error(`\nvc: which secret? \`vc secrets set STRIPE_SECRET_KEY\`\n`);
342
+ return 1;
343
+ }
344
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
345
+ console.error(`\nvc: "${name}" is not a key name — they are SHOUTING_SNAKE_CASE.\n`);
346
+ return 1;
347
+ }
348
+ if (!existsSync(join(root, SECRETS_FILE))) {
349
+ console.error(`\nvc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.\n`);
350
+ return 1;
351
+ }
352
+ const publicKey = committedPublicKey(root);
353
+ if (!publicKey) {
354
+ console.error(`\nvc: ${SECRETS_FILE} has no DOTENV_PUBLIC_KEY_SECRETS, so there is nothing to encrypt to.\n \`vc keys --init\` makes a key and puts the public half here.\n`);
355
+ return 1;
356
+ }
357
+
358
+ // A known key gets its consequence shown, so a person setting one knows
359
+ // what it is for without leaving the terminal.
360
+ const known = allEnvKeys(project.manifest).find((key) => key.key === name);
361
+ const value = await readValue(name, known?.breaks, known?.where);
362
+ if (value === null) return 1;
363
+
364
+ const written = await encryptInto(root, publicKey, name, value);
365
+ if (!written.ok) {
366
+ console.error(`\nvc: ${written.error}\n`);
367
+ return 1;
368
+ }
369
+ const stillBare = plaintextSecretNames(root).includes(name);
370
+ if (stillBare) {
371
+ console.error(`\n${color.red("✗")} ${name} was written in the CLEAR — the public key did not encrypt it.\n Do not commit. Check DOTENV_PUBLIC_KEY_SECRETS in ${SECRETS_FILE}.\n`);
372
+ return 1;
373
+ }
374
+ console.log(`\n${color.green("✓")} ${name} encrypted into ${SECRETS_FILE}. Commit it.\n`);
375
+ return 0;
376
+ }
377
+
378
+ /** The value, from a masked prompt or from a pipe. Never from argv. */
379
+ async function readValue(name: string, breaks?: string, where?: string): Promise<string | null> {
380
+ if (!process.stdin.isTTY) {
381
+ const chunks: Buffer[] = [];
382
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
383
+ const piped = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
384
+ if (!piped) {
385
+ console.error(`\nvc: nothing on stdin to set ${name} to.\n`);
386
+ return null;
387
+ }
388
+ return piped;
389
+ }
390
+ const p = await import("@clack/prompts");
391
+ if (breaks) console.log(`\n${color.dim(breaks)}${where ? color.dim(` — from: ${where}`) : ""}`);
392
+ const answer = await p.password({
393
+ message: name,
394
+ validate: (input) => (input && input.length > 0 ? undefined : "a secret with no value is not a secret"),
395
+ });
396
+ if (p.isCancel(answer)) {
397
+ console.log(color.dim("\ncancelled; nothing changed.\n"));
398
+ return null;
399
+ }
400
+ return String(answer);
401
+ }
402
+
403
+
404
+ /**
405
+ * Encrypt one value into `.env.secrets`, in process.
406
+ *
407
+ * Deliberately NOT `dotenvx set NAME value -f …`: that takes the value as an
408
+ * argument, and an argument is visible in `ps` to every other process on the
409
+ * machine for as long as the call runs. This uses the same secp256k1 the CLI
410
+ * uses — it is the CLI's own library — so what it writes is byte-compatible
411
+ * with what `dotenvx decrypt` expects.
412
+ */
413
+ export async function encryptInto(root: string, publicKey: string, name: string, value: string): Promise<{ ok: true } | { ok: false; error: string }> {
414
+ const { encrypt } = await import("@dotenvx/primitives");
415
+ let ciphertext: string;
416
+ try {
417
+ ciphertext = encrypt(publicKey, value);
418
+ } catch (error) {
419
+ return { ok: false, error: `could not encrypt to the key in ${SECRETS_FILE}: ${String(error)}` };
420
+ }
421
+ // A belt-and-braces check on someone else's library, because the failure
422
+ // it guards against is writing a secret to a committed file in the clear.
423
+ //
424
+ // The containment half is gated on length on purpose: base64 output will
425
+ // contain almost any short string by chance, so checking it for a
426
+ // two-character value rejects perfectly good ciphertext. Twelve is above
427
+ // the length at which a coincidental match stops being plausible.
428
+ const looksWrong = !ciphertext.startsWith("encrypted:") || ciphertext === value || (value.length >= 12 && ciphertext.includes(value));
429
+ if (looksWrong) {
430
+ return { ok: false, error: `refusing to write ${name} — the result does not look encrypted.` };
431
+ }
432
+ upsertLine(join(root, SECRETS_FILE), name, ciphertext);
433
+ return { ok: true };
434
+ }
435
+
436
+ /**
437
+ * Replace `NAME=…` in a dotenv file, or add it if it is not there.
438
+ *
439
+ * Written by hand rather than with a parser because everything else in the
440
+ * file — the comments explaining what each secret breaks, the order — is
441
+ * meant for a person to read, and a round-trip through a parser would
442
+ * flatten all of it.
443
+ */
444
+ function upsertLine(path: string, name: string, value: string): void {
445
+ const body = readFileSync(path, "utf8");
446
+ const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, "m");
447
+ writeFileSync(path, pattern.test(body) ? body.replace(pattern, `${name}=${value}`) : `${body.replace(/\n*$/, "\n")}${name}=${value}\n`);
448
+ }
@@ -7,4 +7,8 @@ declare module "@dotenvx/primitives" {
7
7
  /** The secp256k1 public key for a private key, both as lowercase hex. */
8
8
  export function derive(privateKey: string): string;
9
9
  export function keypair(): { publicKey: string; privateKey: string };
10
+ /** Ciphertext for a value, given only the PUBLIC key. Note the argument order. */
11
+ export function encrypt(publicKey: string, value: string): string;
12
+ /** The value back, given the private key. Throws when the key is the wrong one. */
13
+ export function decrypt(privateKey: string, ciphertext: string): string;
10
14
  }