@saastemly/voidcommerce 0.2.2 → 0.4.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.
@@ -0,0 +1,266 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { delimiter, dirname, join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import color from "picocolors";
6
+ import { allEnvKeys } from "../generate/env";
7
+ import { keyFor } from "./keys";
8
+ import type { Project } from "../project";
9
+
10
+ /**
11
+ * Secrets that live in the repository, encrypted.
12
+ *
13
+ * ── The problem this solves ──────────────────────────────────────────────
14
+ *
15
+ * A shop's secrets were set by hand, one `wrangler secret put` at a time,
16
+ * on a machine that happened to have them. Nothing in the repository said
17
+ * what the values WERE, only what they were called, so the shop could not
18
+ * be rebuilt from a checkout and a new deploy target needed somebody to
19
+ * remember. That is the last manual step between "push" and "live".
20
+ *
21
+ * dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
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.
27
+ *
28
+ * ── Why the values still become worker SECRETS ───────────────────────────
29
+ *
30
+ * The lazy version of this would decrypt at build time and let the values
31
+ * land in the worker's `vars`. That undoes the thing we built the scrub
32
+ * step for: `vars` are plaintext in the deployed configuration and readable
33
+ * by anyone with dashboard access. So the plaintext exists only inside the
34
+ * build, in a temp file, and goes up through `wrangler deploy
35
+ * --secrets-file`, which stores them as real secrets and — importantly —
36
+ * applies ADDITIVELY, so a secret this file does not name is left alone
37
+ * rather than deleted.
38
+ *
39
+ * ── The tradeoffs, stated ────────────────────────────────────────────────
40
+ *
41
+ * Ciphertext in git is permanent. A key that ever leaks reads every secret
42
+ * in the history, including ones that were rotated — which is not true of
43
+ * `wrangler secret put`, where a rotation genuinely retires the old value.
44
+ *
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.
49
+ *
50
+ * Both are the price of a shop that rebuilds from a checkout, and both
51
+ * should be decisions rather than surprises.
52
+ */
53
+
54
+ export const SECRETS_FILE = ".env.secrets";
55
+ export const PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
56
+
57
+ /** dotenvx, from the project's own install rather than a global one. */
58
+ export function findDotenvx(from: string): string | null {
59
+ let dir = from;
60
+ for (;;) {
61
+ const local = join(dir, "node_modules", ".bin", "dotenvx");
62
+ if (existsSync(local)) return local;
63
+ const parent = dirname(dir);
64
+ if (parent === dir) break;
65
+ dir = parent;
66
+ }
67
+ return (process.env["PATH"] ?? "")
68
+ .split(delimiter)
69
+ .filter(Boolean)
70
+ .some((entry) => existsSync(join(entry, "dotenvx")))
71
+ ? "dotenvx"
72
+ : null;
73
+ }
74
+
75
+ function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
76
+ return new Promise((resolve) => {
77
+ const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
78
+ let out = "";
79
+ child.stdout.on("data", (chunk) => {
80
+ out += chunk;
81
+ });
82
+ child.stderr.on("data", (chunk) => {
83
+ out += chunk;
84
+ });
85
+ child.on("error", (error) => resolve({ code: 1, out: String(error) }));
86
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
87
+ });
88
+ }
89
+
90
+ /**
91
+ * The names a `.env.secrets` declares, read WITHOUT decrypting.
92
+ *
93
+ * dotenvx leaves keys in plaintext and encrypts only values, so preflight
94
+ * can say "all five are declared" on a machine that cannot read any of them.
95
+ */
96
+ export function declaredSecretNames(root: string): Set<string> {
97
+ const path = join(root, SECRETS_FILE);
98
+ if (!existsSync(path)) return new Set();
99
+ const names = new Set<string>();
100
+ for (const line of readFileSync(path, "utf8").split("\n")) {
101
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
102
+ if (match && !match[1]!.startsWith("DOTENV_")) names.add(match[1]!);
103
+ }
104
+ return names;
105
+ }
106
+
107
+ /** Is a declared value actually encrypted, or was it committed in the clear? */
108
+ export function plaintextSecretNames(root: string): string[] {
109
+ const path = join(root, SECRETS_FILE);
110
+ if (!existsSync(path)) return [];
111
+ const bare: string[] = [];
112
+ for (const line of readFileSync(path, "utf8").split("\n")) {
113
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
114
+ if (!match || match[1]!.startsWith("DOTENV_")) continue;
115
+ const value = match[2]!.trim().replace(/^['"]|['"]$/g, "");
116
+ // `unset` is the documented placeholder, not a leak.
117
+ if (value && value !== "unset" && !value.startsWith("encrypted:")) bare.push(match[1]!);
118
+ }
119
+ return bare;
120
+ }
121
+
122
+ export interface DecryptedSecrets {
123
+ /** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
124
+ path: string;
125
+ names: string[];
126
+ cleanup: () => void;
127
+ }
128
+
129
+ /**
130
+ * Decrypt into a temp file the deploy can hand to wrangler.
131
+ *
132
+ * Written to disk rather than piped because that is what `--secrets-file`
133
+ * takes, and removed by `cleanup()` whatever happens next.
134
+ */
135
+ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets | { error: string }> {
136
+ const root = project.root;
137
+ if (!existsSync(join(root, SECRETS_FILE))) return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
138
+
139
+ const dotenvx = findDotenvx(root);
140
+ if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
141
+
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
+ const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
148
+ [PRIVATE_KEY_VAR]: key.privateKey,
149
+ });
150
+ if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
151
+
152
+ // Keep only the shop's own keys: dotenvx echoes the public key back, and
153
+ // it has no business being a worker secret.
154
+ const lines = result.out
155
+ .split("\n")
156
+ .filter((line) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) && !line.trimStart().startsWith("DOTENV_"));
157
+ const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
158
+
159
+ const path = join(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
160
+ writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
161
+ return {
162
+ path,
163
+ names,
164
+ cleanup: () => {
165
+ try {
166
+ writeFileSync(path, "", { mode: 0o600 });
167
+ // Overwrite before unlinking: the plaintext existed on disk and
168
+ // should stop existing there promptly.
169
+ require("node:fs").unlinkSync(path);
170
+ } catch {
171
+ // Already gone, or never written. Nothing to do.
172
+ }
173
+ },
174
+ };
175
+ }
176
+
177
+ /** `vc secrets` — what the repository declares, and whether it is readable here. */
178
+ export async function secretsCommand(project: Project, args: string[]): Promise<number> {
179
+ const root = project.root;
180
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
181
+ const declared = declaredSecretNames(root);
182
+ const bare = plaintextSecretNames(root);
183
+
184
+ if (args.includes("--init")) return initSecrets(project);
185
+
186
+ if (!existsSync(join(root, SECRETS_FILE))) {
187
+ console.log(
188
+ `\n${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,\n` +
189
+ `which means the shop cannot be rebuilt from a checkout.\n\n` +
190
+ ` vc secrets --init write one, with every required key as \`unset\`\n`,
191
+ );
192
+ return 1;
193
+ }
194
+
195
+ console.log(`\n${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`\n`);
196
+ for (const key of required) {
197
+ const state = declared.has(key.key) ? color.green("declared") : color.red("MISSING ");
198
+ console.log(` ${state} ${key.key}`);
199
+ }
200
+ const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
201
+ for (const name of extra) console.log(` ${color.dim("extra ")} ${name} ${color.dim("— not required by this shop")}`);
202
+
203
+ if (bare.length > 0) {
204
+ console.log(
205
+ `\n${color.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}\n` +
206
+ ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.\n`,
207
+ );
208
+ return 1;
209
+ }
210
+ const missing = required.filter((key) => !declared.has(key.key));
211
+ if (missing.length > 0) {
212
+ console.log(
213
+ `\n${color.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.\n` +
214
+ ` dotenvx set ${missing[0]!.key} '…' -f ${SECRETS_FILE}\n`,
215
+ );
216
+ return 1;
217
+ }
218
+ console.log(`\n${color.green("✓")} every secret this shop needs is declared and encrypted.\n`);
219
+ return 0;
220
+ }
221
+
222
+ /** Write a `.env.secrets` with every required key as the `unset` sentinel. */
223
+ async function initSecrets(project: Project): Promise<number> {
224
+ const path = join(project.root, SECRETS_FILE);
225
+ if (existsSync(path)) {
226
+ console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
227
+ return 1;
228
+ }
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`);
234
+ return 1;
235
+ }
236
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
237
+ const body = [
238
+ "# Secrets, encrypted, and COMMITTED.",
239
+ "#",
240
+ "# Values are ciphertext; the key names are readable so a diff shows WHICH",
241
+ "# secret changed without showing what it changed to. `unset` is the",
242
+ "# documented placeholder and preflight refuses it.",
243
+ "#",
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.",
246
+ "#",
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.",
250
+ "",
251
+ `DOTENV_PUBLIC_KEY_SECRETS="${key.publicKey}"`,
252
+ "",
253
+ ...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
254
+ ].join("\n");
255
+ writeFileSync(path, body, { mode: 0o600 });
256
+
257
+ console.log(
258
+ `\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`, under the key this token derives\n\n` +
259
+ `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`),
264
+ );
265
+ return 0;
266
+ }
package/src/dist.ts CHANGED
@@ -120,7 +120,9 @@ export async function distCommand(args: string[]): Promise<number> {
120
120
 
121
121
  // The patches the pins refer to, and the lockfile, so the install is the
122
122
  // one that was tested rather than whatever resolves today.
123
- for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml"]) {
123
+ // The encrypted secrets travel with the app: they are ciphertext, and the
124
+ // branch Cloudflare builds is where the deploy command reads them.
125
+ for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", ".env.secrets"]) {
124
126
  const source = join(project.root, file);
125
127
  if (existsSync(source)) await cp(source, join(target, file));
126
128
  }
@@ -0,0 +1,10 @@
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
+ }
@@ -137,13 +137,27 @@ and \`voidcommerce.json\` so every later generate carries the real ids.
137
137
 
138
138
  ### 3. The secrets
139
139
 
140
+ They live in the repository, encrypted:
141
+
140
142
  \`\`\`sh
141
- vc preflight --cloudflare
143
+ vc secrets --init # every required key, as \`unset\`
144
+ # put the real values in, then
145
+ bunx dotenvx encrypt -f .env.secrets # ciphertext; commit this
142
146
  \`\`\`
143
147
 
144
- lists every required key, says what breaks without it, and prints the
145
- \`wrangler secret put\` line. On a Worker that has never been deployed, setting
146
- the first secret is what creates it.
148
+ \`.env.secrets\` is committed and \`.env.keys\` is not. The deploy decrypts it and
149
+ hands the values to \`wrangler deploy --secrets-file\`, which stores them as
150
+ real Worker secrets not as plaintext \`vars\`, which anyone with dashboard
151
+ access can read.
152
+
153
+ That turns one \`wrangler secret put\` per value into one build variable, set
154
+ once in step 5. It also means the shop rebuilds from a checkout.
155
+
156
+ **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
157
+ leaks, every secret in the history is readable, including ones you rotated.
158
+ \`wrangler secret put\` does not have that property, and stays available for
159
+ anything you would rather never commit. \`vc preflight\` counts a secret the
160
+ repository declares as present, and refuses any value committed in the clear.
147
161
 
148
162
  ### 4. An API token the build can use
149
163
 
@@ -178,9 +192,20 @@ the \`wrangler.jsonc\` at the root directory, or the build fails.
178
192
  | branch | \`${DIST_BRANCH}\` |
179
193
  | root directory | \`/\` |
180
194
  | build command | \`bun install && bunx void prepare && bunx vp build\` |
181
- | deploy command | \`bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json\` |
195
+ | deploy command | see below |
196
+ | build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
182
197
  | API token | the one from step 4 |
183
198
 
199
+ The deploy command, on one line:
200
+
201
+ \`\`\`sh
202
+ bunx dotenvx decrypt -f .env.secrets --stdout > .vc-secrets.env && bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json --secrets-file .vc-secrets.env
203
+ \`\`\`
204
+
205
+ Plain \`sh\`, so it does not rely on process substitution. The decrypted file
206
+ exists only inside the build sandbox, and \`--secrets-file\` applies additively:
207
+ a secret it does not name is left alone rather than deleted.
208
+
184
209
  The migration command names the **binding** (\`DB\`), not the database, so it
185
210
  still points at the right database if the name ever differs.
186
211
 
@@ -320,14 +320,43 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
320
320
  scripts["deploy"] ??= "vc deploy";
321
321
  scripts["import:catalog"] ??= "vc import";
322
322
  scripts["maildev"] ??= "maildev --smtp 1025 --web 1080";
323
+ scripts["secrets"] ??= "vc secrets";
324
+ // husky installs the hook on `bun install`, so a fresh clone is
325
+ // guarded without anybody remembering to run anything.
326
+ scripts["prepare"] ??= "husky";
323
327
  pkg["scripts"] = scripts;
324
328
  },
325
329
  result,
326
330
  );
327
331
  // `.void` and `dist` are the STOREFRONT's build artifacts at the root now;
328
332
  // `.vc` holds the generated worker and its own.
329
- await put(root, ".gitignore", "node_modules\n.vc\n.void\n.wrangler\n.env\n.env.local\ndist\n*.tsbuildinfo\n.DS_Store\n", result, "own");
333
+ // `.env.keys` is the ONE thing that must never be committed: it is the
334
+ // authority to decrypt everything in `.env.secrets`, which is.
335
+ await put(
336
+ root,
337
+ ".gitignore",
338
+ "node_modules\n.vc\n.void\n.wrangler\n.env\n.env.local\n.env.keys\ndist\n*.tsbuildinfo\n.DS_Store\n",
339
+ result,
340
+ "own",
341
+ );
330
342
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
343
+ /**
344
+ * The hook lives in `.husky/`, which is COMMITTED, so the guard travels
345
+ * with the repository instead of being something each clone remembers to
346
+ * install. A secret committed in the clear cannot be un-committed.
347
+ */
348
+ await put(
349
+ root,
350
+ ".husky/pre-commit",
351
+ `#!/usr/bin/env sh
352
+ # Generated by \`vc init\`. Refuses a commit that would put a secret in the
353
+ # clear in .env.secrets — which cannot be undone by a later commit,
354
+ # because the value stays in the history.
355
+ bunx vc guard
356
+ `,
357
+ result,
358
+ "regenerate",
359
+ );
331
360
  await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
332
361
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
333
362
  await put(root, ".env", renderEnvLocal(manifest), result, "own");
@@ -63,6 +63,11 @@ export function strictDependencies(manifest: Manifest): {
63
63
  // The storefront at the root prerenders on the node target, whose SSR
64
64
  // bundle imports this. Absent, a cold install builds nothing.
65
65
  "@hono/node-server",
66
+ // The repository's secrets are encrypted with this; a build needs it to
67
+ // hand them to wrangler.
68
+ "@dotenvx/dotenvx",
69
+ // Installs the committed pre-commit hook on every fresh clone.
70
+ "husky",
66
71
  "@rolldown/plugin-babel",
67
72
  "@tailwindcss/vite",
68
73
  "@types/node",
package/src/help.ts CHANGED
@@ -17,6 +17,7 @@ export const EXTENDED_ROWS: Array<[command: string, summary: string]> = [
17
17
  ["vc dev | build | preview", "the app's own script, run where the app is — api/ for a monorepo, .vc/app for strict"],
18
18
  ["vc dist", "the deployable app as a self-contained tree — what the void-dist branch carries"],
19
19
  ["vc import", "push data/ and content/ into the running shop, as the shop itself — an upsert, safe on every deploy"],
20
+ ["vc secrets", "the shop's secrets, encrypted and committed — one key where the deploy runs, instead of one wrangler secret put per value"],
20
21
  ["vc preflight", "is the shop ready to advertise on? every required key, and what breaks without it"],
21
22
  ["vc deploy", "preflight, then void deploy; or --cloudflare: wrangler, your own account, no Void login"],
22
23
  ];