@saastemly/voidcommerce 0.3.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,6 +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 { committedPublicKey, provisionKey } from "./keys";
7
8
  import type { Project } from "../project";
8
9
 
9
10
  /**
@@ -19,9 +20,11 @@ import type { Project } from "../project";
19
20
  *
20
21
  * dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
21
22
  * it is COMMITTED: the key names are readable, the values are not, and a
22
- * diff shows when a secret changed without showing what it changed to. The
23
- * private key is the one thing that stays out — a single value, set once
24
- * where the deploy runs.
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`.
25
28
  *
26
29
  * ── Why the values still become worker SECRETS ───────────────────────────
27
30
  *
@@ -34,14 +37,18 @@ import type { Project } from "../project";
34
37
  * applies ADDITIVELY, so a secret this file does not name is left alone
35
38
  * rather than deleted.
36
39
  *
37
- * ── The tradeoff, stated ─────────────────────────────────────────────────
40
+ * ── The tradeoffs, stated ────────────────────────────────────────────────
41
+ *
42
+ * Ciphertext in git is permanent. A key that ever leaks reads every secret
43
+ * in the history, including ones that were rotated — which is not true of
44
+ * `wrangler secret put`, where a rotation genuinely retires the old value.
45
+ *
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.
38
49
  *
39
- * Ciphertext in git is permanent. If the private key ever leaks, every
40
- * secret in the history is readable, including ones that were rotated —
41
- * which is not true of `wrangler secret put`, where a rotation genuinely
42
- * retires the old value. Rotating the dotenvx key re-encrypts the present,
43
- * not the past. That is the price of a shop that rebuilds from a checkout,
44
- * and it should be a decision rather than a surprise.
50
+ * Both are the price of a shop that rebuilds from a checkout, and both
51
+ * should be decisions rather than surprises.
45
52
  */
46
53
 
47
54
  export const SECRETS_FILE = ".env.secrets";
@@ -65,7 +72,7 @@ export function findDotenvx(from: string): string | null {
65
72
  : null;
66
73
  }
67
74
 
68
- 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 }> {
69
76
  return new Promise((resolve) => {
70
77
  const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
71
78
  let out = "";
@@ -80,6 +87,28 @@ function run(cmd: string, args: string[], cwd: string, env: Record<string, strin
80
87
  });
81
88
  }
82
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
+
83
112
  /**
84
113
  * The names a `.env.secrets` declares, read WITHOUT decrypting.
85
114
  *
@@ -129,15 +158,42 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
129
158
  const root = project.root;
130
159
  if (!existsSync(join(root, SECRETS_FILE))) return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
131
160
 
132
- const dotenvx = findDotenvx(root);
133
- if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
134
- if (!process.env[PRIVATE_KEY_VAR] && !existsSync(join(root, ".env.keys"))) {
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) {
135
166
  return {
136
- error: `${PRIVATE_KEY_VAR} is not set and there is no .env.keys here.\n It is the one value that stays out of the repository; set it where the deploy runs.`,
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.`,
137
172
  };
138
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.
191
+ const dotenvx = findDotenvx(root);
192
+ if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
139
193
 
140
- const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root);
194
+ const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
195
+ [PRIVATE_KEY_VAR]: privateKey,
196
+ });
141
197
  if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
142
198
 
143
199
  // Keep only the shop's own keys: dotenvx echoes the public key back, and
@@ -173,6 +229,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
173
229
  const bare = plaintextSecretNames(root);
174
230
 
175
231
  if (args.includes("--init")) return initSecrets(project);
232
+ if (args[0] === "set") return setSecretValue(project, args.slice(1));
176
233
 
177
234
  if (!existsSync(join(root, SECRETS_FILE))) {
178
235
  console.log(
@@ -211,10 +268,26 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
211
268
  }
212
269
 
213
270
  /** Write a `.env.secrets` with every required key as the `unset` sentinel. */
214
- async function initSecrets(project: Project): Promise<number> {
271
+ export async function initSecrets(project: Project): Promise<number> {
215
272
  const path = join(project.root, SECRETS_FILE);
216
- if (existsSync(path)) {
217
- 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.`);
279
+ return 1;
280
+ }
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`);
218
291
  return 1;
219
292
  }
220
293
  const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
@@ -225,20 +298,151 @@ async function initSecrets(project: Project): Promise<number> {
225
298
  "# secret changed without showing what it changed to. `unset` is the",
226
299
  "# documented placeholder and preflight refuses it.",
227
300
  "#",
228
- "# The private key stays out of the repository. It lives in .env.keys",
229
- "# locally (gitignored) and as one build variable where the deploy runs.",
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.",
304
+ "#",
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.",
307
+ "",
308
+ `DOTENV_PUBLIC_KEY_SECRETS="${made.publicKey}"`,
230
309
  "",
231
310
  ...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
232
311
  ].join("\n");
233
312
  writeFileSync(path, body, { mode: 0o600 });
234
313
 
235
314
  console.log(
236
- `\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`\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` +
237
317
  `Next:\n` +
238
- ` 1. put the real values in, then\n` +
239
- ` 2. ${color.cyan(`dotenvx encrypt -f ${SECRETS_FILE}`)}\n` +
240
- ` 3. commit ${SECRETS_FILE}; never commit .env.keys\n` +
241
- ` 4. set ${PRIVATE_KEY_VAR} where the deploy runs\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`,
242
320
  );
243
321
  return 0;
244
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
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@dotenvx/primitives` ships no types. Only one function is used here, and
3
+ * declaring just that is better than an `any` import: the shape is asserted
4
+ * in one place a reader can check against the package.
5
+ */
6
+ declare module "@dotenvx/primitives" {
7
+ /** The secp256k1 public key for a private key, both as lowercase hex. */
8
+ export function derive(privateKey: string): string;
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;
14
+ }