@saastemly/voidcommerce 0.2.1 → 0.3.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/dist/index.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  routeProblem,
58
58
  strictDependencies,
59
59
  upsertJsonc
60
- } from "./index-xdsp97e4.js";
60
+ } from "./index-84wq4qp8.js";
61
61
  import {
62
62
  LAYOUTS,
63
63
  MANIFEST_FILE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { deployCommand, deployHelp, preflightCommand, preflightHelp } from "./deploy/index";
1
+ import { deployCommand, deployHelp, preflightCommand, preflightHelp, secretsCliCommand, secretsHelp } from "./deploy/index";
2
2
  import { distCommand, distHelp } from "./dist";
3
3
  import { importCommand, importHelp } from "./import";
4
4
  import { fullHelp, initHelp, version } from "./help";
@@ -38,6 +38,7 @@ export const EXTENDED: Record<string, Extended> = {
38
38
  import: { run: importCommand, help: importHelp },
39
39
  deploy: { run: deployCommand, help: deployHelp },
40
40
  preflight: { run: preflightCommand, help: preflightHelp },
41
+ secrets: { run: secretsCliCommand, help: secretsHelp },
41
42
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
42
43
  build: { run: appScript("build"), help: appScriptHelp("build") },
43
44
  preview: { run: appScript("preview"), help: appScriptHelp("preview") },
@@ -6,6 +6,7 @@ import { hasFrontend, workerHosts, writeManifest } from "../manifest";
6
6
  import type { Project } from "../project";
7
7
  import { parseJsonc, upsertJsonc } from "./jsonc";
8
8
  import { preflight, printPreflight } from "./preflight";
9
+ import { SECRETS_FILE, decryptSecrets } from "./secrets";
9
10
  import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangler";
10
11
 
11
12
  /**
@@ -25,7 +26,9 @@ import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangle
25
26
  * secret-class key and every `unset` is removed from dist/ssr/wrangler.json
26
27
  * before it is uploaded; the worker reads those from `wrangler secret`.
27
28
  * 6. apply the committed migrations to the remote D1
28
- * 7. wrangler deploy, on exactly the config that was scrubbed
29
+ * 7. decrypt the repository's own secrets, if it keeps any
30
+ * 8. wrangler deploy, on exactly the config that was scrubbed, carrying
31
+ * those secrets up with the version
29
32
  */
30
33
 
31
34
  export interface CloudflareOptions {
@@ -153,9 +156,28 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
153
156
  const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
154
157
  if (migrated.code !== 0) return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
155
158
 
156
- // 7. deploy exactly what was scrubbed.
159
+ /**
160
+ * 7. Secrets, from the repository, in the same operation as the deploy.
161
+ *
162
+ * `--secrets-file` applies ADDITIVELY: a secret this file does not name is
163
+ * left alone rather than deleted, so a shop that sets one by hand does not
164
+ * lose it the next time this runs. The plaintext exists only as a
165
+ * 0600 temp file for the length of the upload.
166
+ */
167
+ const secretArgs: string[] = [];
168
+ let cleanupSecrets: (() => void) | undefined;
169
+ if (existsSync(join(project.root, SECRETS_FILE))) {
170
+ const decrypted = await decryptSecrets(project);
171
+ if ("error" in decrypted) return fail(`the repository's secrets could not be read: ${decrypted.error}`);
172
+ secretArgs.push("--secrets-file", decrypted.path);
173
+ cleanupSecrets = decrypted.cleanup;
174
+ console.log(`${color.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
175
+ }
176
+
177
+ // 8. deploy exactly what was scrubbed.
157
178
  console.log("\n▸ wrangler deploy -c dist/ssr/wrangler.json");
158
- const deployed = await wrangler(bin, ["deploy", "-c", join("dist", "ssr", "wrangler.json")], app, true);
179
+ const deployed = await wrangler(bin, ["deploy", "-c", join("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
180
+ cleanupSecrets?.();
159
181
  if (deployed.code !== 0) return fail(`wrangler deploy failed (exit ${deployed.code}).`);
160
182
 
161
183
  const domain = project.manifest.shop.domain;
@@ -4,6 +4,7 @@ import { findProject } from "../project";
4
4
  import { captureVoid, runVoid } from "../void";
5
5
  import { deployCloudflare } from "./cloudflare";
6
6
  import { preflight, printPreflight } from "./preflight";
7
+ import { PRIVATE_KEY_VAR, SECRETS_FILE, secretsCommand } from "./secrets";
7
8
 
8
9
  /**
9
10
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -99,3 +100,37 @@ export async function preflightHelp(): Promise<number> {
99
100
  );
100
101
  return 0;
101
102
  }
103
+
104
+ /** `vc secrets` — what the repository declares, and whether it is encrypted. */
105
+ export async function secretsCliCommand(args: string[]): Promise<number> {
106
+ const project = await findProject();
107
+ if (!project) {
108
+ console.error("vc: no voidcommerce.json here.");
109
+ return 1;
110
+ }
111
+ return secretsCommand(project, args);
112
+ }
113
+
114
+ export async function secretsHelp(): Promise<number> {
115
+ const width = 80;
116
+ console.log(
117
+ box("vc secrets", [
118
+ line(`Secrets that live in the repository, encrypted, in ${SECRETS_FILE}.`, width),
119
+ line("", width),
120
+ ...row("vc secrets", "what this shop needs, and whether it is declared and encrypted", width, 2),
121
+ ...row("vc secrets --init", `write ${SECRETS_FILE} with every required key as \`unset\``, width, 2),
122
+ ...row(`dotenvx set KEY '…' -f ${SECRETS_FILE}`, "set one, without decrypting the file", width, 2),
123
+ ...row(`dotenvx encrypt -f ${SECRETS_FILE}`, "encrypt anything still in the clear", width, 2),
124
+ line("", width),
125
+ line("The file is COMMITTED: values are ciphertext, key names are not, so a", width),
126
+ line("diff shows which secret changed without showing what it changed to.", width),
127
+ line(`The private key stays out — .env.keys locally, and ${PRIVATE_KEY_VAR}`, width),
128
+ line("as one build variable where the deploy runs.", width),
129
+ line("", width),
130
+ line("Tradeoff worth knowing: ciphertext in git is permanent, so a leaked key", width),
131
+ line("exposes rotated secrets too. `wrangler secret put` does not have that", width),
132
+ line("property, and stays available for anything you would rather keep out.", width),
133
+ ], width),
134
+ );
135
+ return 0;
136
+ }
@@ -7,6 +7,7 @@ import { workerHosts } from "../manifest";
7
7
  import type { Project } from "../project";
8
8
  import { captureVoid } from "../void";
9
9
  import { parseJsonc } from "./jsonc";
10
+ import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames } from "./secrets";
10
11
  import { findWrangler, secretNames } from "./wrangler";
11
12
 
12
13
  /**
@@ -28,6 +29,8 @@ export interface Preflight {
28
29
  remote: Set<string> | null;
29
30
  missing: EnvKey[];
30
31
  routeProblem: string | null;
32
+ /** Names in `.env.secrets` whose value was committed unencrypted. */
33
+ bareSecrets: string[];
31
34
  ready: boolean;
32
35
  }
33
36
 
@@ -86,12 +89,29 @@ export async function preflight(project: Project, source: SecretSource): Promise
86
89
  }
87
90
  for (const name of remote ?? []) present.set(name, "<secret>");
88
91
 
92
+ /**
93
+ * A secret the REPOSITORY declares counts as present, because the deploy
94
+ * uploads it. The names are readable without the key — dotenvx encrypts
95
+ * values, not keys — so this works on a machine that cannot decrypt.
96
+ */
97
+ for (const name of declaredSecretNames(project.root)) present.set(name, "<in the repository>");
98
+
89
99
  const missing = allEnvKeys(project.manifest).filter((key) => {
90
100
  const value = present.get(key.key);
91
101
  return value === undefined || value === "" || value === UNSET;
92
102
  });
93
103
  const problem = routeProblem(project);
94
- return { present, remote, missing, routeProblem: problem, ready: missing.length === 0 && problem === null };
104
+ // A value committed in the clear is worse than a missing one: it is a
105
+ // leak that looks like it is working.
106
+ const bare = plaintextSecretNames(project.root);
107
+ return {
108
+ present,
109
+ remote,
110
+ missing,
111
+ routeProblem: problem,
112
+ bareSecrets: bare,
113
+ ready: missing.length === 0 && problem === null && bare.length === 0,
114
+ };
95
115
  }
96
116
 
97
117
  /** Print the report a person can act on. */
@@ -120,6 +140,12 @@ export function printPreflight(project: Project, result: Preflight, source: Secr
120
140
  if (result.routeProblem) {
121
141
  console.log(`${color.red("✗")} the worker's hostname\n ${result.routeProblem}\n`);
122
142
  }
143
+ if (result.bareSecrets.length > 0) {
144
+ console.log(
145
+ `${color.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}\n` +
146
+ ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.\n`,
147
+ );
148
+ }
123
149
  for (const key of result.missing) {
124
150
  console.log(`${color.red("✗")} ${key.key}`);
125
151
  console.log(` ${key.breaks}`);
@@ -128,8 +154,17 @@ export function printPreflight(project: Project, result: Preflight, source: Secr
128
154
  }
129
155
  const secrets = result.missing.filter((key) => !key.plaintext);
130
156
  if (secrets.length > 0) {
131
- console.log(source === "wrangler" ? "Set each secret on the worker (this also creates the draft worker on a first deploy):" : "Set each secret on the project:");
132
- for (const key of secrets) console.log(` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
157
+ const inRepo = existsSync(join(project.root, SECRETS_FILE));
158
+ console.log(
159
+ inRepo
160
+ ? `Put each in ${SECRETS_FILE}, which the deploy uploads:`
161
+ : source === "wrangler"
162
+ ? "Set each secret on the worker (this also creates the draft worker on a first deploy):"
163
+ : "Set each secret on the project:",
164
+ );
165
+ for (const key of secrets) {
166
+ console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
167
+ }
133
168
  }
134
169
  const plain = result.missing.filter((key) => key.plaintext);
135
170
  if (plain.length > 0) console.log(`Values safe to commit go in .env.production: ${plain.map((k) => k.key).join(", ")}`);
@@ -0,0 +1,244 @@
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 type { Project } from "../project";
8
+
9
+ /**
10
+ * Secrets that live in the repository, encrypted.
11
+ *
12
+ * ── The problem this solves ──────────────────────────────────────────────
13
+ *
14
+ * A shop's secrets were set by hand, one `wrangler secret put` at a time,
15
+ * on a machine that happened to have them. Nothing in the repository said
16
+ * what the values WERE, only what they were called, so the shop could not
17
+ * be rebuilt from a checkout and a new deploy target needed somebody to
18
+ * remember. That is the last manual step between "push" and "live".
19
+ *
20
+ * dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
21
+ * 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.
25
+ *
26
+ * ── Why the values still become worker SECRETS ───────────────────────────
27
+ *
28
+ * The lazy version of this would decrypt at build time and let the values
29
+ * land in the worker's `vars`. That undoes the thing we built the scrub
30
+ * step for: `vars` are plaintext in the deployed configuration and readable
31
+ * by anyone with dashboard access. So the plaintext exists only inside the
32
+ * build, in a temp file, and goes up through `wrangler deploy
33
+ * --secrets-file`, which stores them as real secrets and — importantly —
34
+ * applies ADDITIVELY, so a secret this file does not name is left alone
35
+ * rather than deleted.
36
+ *
37
+ * ── The tradeoff, stated ─────────────────────────────────────────────────
38
+ *
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.
45
+ */
46
+
47
+ export const SECRETS_FILE = ".env.secrets";
48
+ export const PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
49
+
50
+ /** dotenvx, from the project's own install rather than a global one. */
51
+ export function findDotenvx(from: string): string | null {
52
+ let dir = from;
53
+ for (;;) {
54
+ const local = join(dir, "node_modules", ".bin", "dotenvx");
55
+ if (existsSync(local)) return local;
56
+ const parent = dirname(dir);
57
+ if (parent === dir) break;
58
+ dir = parent;
59
+ }
60
+ return (process.env["PATH"] ?? "")
61
+ .split(delimiter)
62
+ .filter(Boolean)
63
+ .some((entry) => existsSync(join(entry, "dotenvx")))
64
+ ? "dotenvx"
65
+ : null;
66
+ }
67
+
68
+ function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
69
+ return new Promise((resolve) => {
70
+ const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
71
+ let out = "";
72
+ child.stdout.on("data", (chunk) => {
73
+ out += chunk;
74
+ });
75
+ child.stderr.on("data", (chunk) => {
76
+ out += chunk;
77
+ });
78
+ child.on("error", (error) => resolve({ code: 1, out: String(error) }));
79
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
80
+ });
81
+ }
82
+
83
+ /**
84
+ * The names a `.env.secrets` declares, read WITHOUT decrypting.
85
+ *
86
+ * dotenvx leaves keys in plaintext and encrypts only values, so preflight
87
+ * can say "all five are declared" on a machine that cannot read any of them.
88
+ */
89
+ export function declaredSecretNames(root: string): Set<string> {
90
+ const path = join(root, SECRETS_FILE);
91
+ if (!existsSync(path)) return new Set();
92
+ const names = new Set<string>();
93
+ for (const line of readFileSync(path, "utf8").split("\n")) {
94
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
95
+ if (match && !match[1]!.startsWith("DOTENV_")) names.add(match[1]!);
96
+ }
97
+ return names;
98
+ }
99
+
100
+ /** Is a declared value actually encrypted, or was it committed in the clear? */
101
+ export function plaintextSecretNames(root: string): string[] {
102
+ const path = join(root, SECRETS_FILE);
103
+ if (!existsSync(path)) return [];
104
+ const bare: string[] = [];
105
+ for (const line of readFileSync(path, "utf8").split("\n")) {
106
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
107
+ if (!match || match[1]!.startsWith("DOTENV_")) continue;
108
+ const value = match[2]!.trim().replace(/^['"]|['"]$/g, "");
109
+ // `unset` is the documented placeholder, not a leak.
110
+ if (value && value !== "unset" && !value.startsWith("encrypted:")) bare.push(match[1]!);
111
+ }
112
+ return bare;
113
+ }
114
+
115
+ export interface DecryptedSecrets {
116
+ /** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
117
+ path: string;
118
+ names: string[];
119
+ cleanup: () => void;
120
+ }
121
+
122
+ /**
123
+ * Decrypt into a temp file the deploy can hand to wrangler.
124
+ *
125
+ * Written to disk rather than piped because that is what `--secrets-file`
126
+ * takes, and removed by `cleanup()` whatever happens next.
127
+ */
128
+ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets | { error: string }> {
129
+ const root = project.root;
130
+ if (!existsSync(join(root, SECRETS_FILE))) return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
131
+
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"))) {
135
+ 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.`,
137
+ };
138
+ }
139
+
140
+ const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root);
141
+ if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
142
+
143
+ // Keep only the shop's own keys: dotenvx echoes the public key back, and
144
+ // it has no business being a worker secret.
145
+ const lines = result.out
146
+ .split("\n")
147
+ .filter((line) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) && !line.trimStart().startsWith("DOTENV_"));
148
+ const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
149
+
150
+ const path = join(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
151
+ writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
152
+ return {
153
+ path,
154
+ names,
155
+ cleanup: () => {
156
+ try {
157
+ writeFileSync(path, "", { mode: 0o600 });
158
+ // Overwrite before unlinking: the plaintext existed on disk and
159
+ // should stop existing there promptly.
160
+ require("node:fs").unlinkSync(path);
161
+ } catch {
162
+ // Already gone, or never written. Nothing to do.
163
+ }
164
+ },
165
+ };
166
+ }
167
+
168
+ /** `vc secrets` — what the repository declares, and whether it is readable here. */
169
+ export async function secretsCommand(project: Project, args: string[]): Promise<number> {
170
+ const root = project.root;
171
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
172
+ const declared = declaredSecretNames(root);
173
+ const bare = plaintextSecretNames(root);
174
+
175
+ if (args.includes("--init")) return initSecrets(project);
176
+
177
+ if (!existsSync(join(root, SECRETS_FILE))) {
178
+ console.log(
179
+ `\n${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,\n` +
180
+ `which means the shop cannot be rebuilt from a checkout.\n\n` +
181
+ ` vc secrets --init write one, with every required key as \`unset\`\n`,
182
+ );
183
+ return 1;
184
+ }
185
+
186
+ console.log(`\n${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`\n`);
187
+ for (const key of required) {
188
+ const state = declared.has(key.key) ? color.green("declared") : color.red("MISSING ");
189
+ console.log(` ${state} ${key.key}`);
190
+ }
191
+ const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
192
+ for (const name of extra) console.log(` ${color.dim("extra ")} ${name} ${color.dim("— not required by this shop")}`);
193
+
194
+ if (bare.length > 0) {
195
+ console.log(
196
+ `\n${color.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}\n` +
197
+ ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.\n`,
198
+ );
199
+ return 1;
200
+ }
201
+ const missing = required.filter((key) => !declared.has(key.key));
202
+ if (missing.length > 0) {
203
+ console.log(
204
+ `\n${color.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.\n` +
205
+ ` dotenvx set ${missing[0]!.key} '…' -f ${SECRETS_FILE}\n`,
206
+ );
207
+ return 1;
208
+ }
209
+ console.log(`\n${color.green("✓")} every secret this shop needs is declared and encrypted.\n`);
210
+ return 0;
211
+ }
212
+
213
+ /** Write a `.env.secrets` with every required key as the `unset` sentinel. */
214
+ async function initSecrets(project: Project): Promise<number> {
215
+ const path = join(project.root, SECRETS_FILE);
216
+ if (existsSync(path)) {
217
+ console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
218
+ return 1;
219
+ }
220
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
221
+ const body = [
222
+ "# Secrets, encrypted, and COMMITTED.",
223
+ "#",
224
+ "# Values are ciphertext; the key names are readable so a diff shows WHICH",
225
+ "# secret changed without showing what it changed to. `unset` is the",
226
+ "# documented placeholder and preflight refuses it.",
227
+ "#",
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.",
230
+ "",
231
+ ...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
232
+ ].join("\n");
233
+ writeFileSync(path, body, { mode: 0o600 });
234
+
235
+ console.log(
236
+ `\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`\n\n` +
237
+ `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`,
242
+ );
243
+ return 0;
244
+ }
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
  }
@@ -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
 
@@ -326,7 +326,15 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
326
326
  );
327
327
  // `.void` and `dist` are the STOREFRONT's build artifacts at the root now;
328
328
  // `.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");
329
+ // `.env.keys` is the ONE thing that must never be committed: it is the
330
+ // authority to decrypt everything in `.env.secrets`, which is.
331
+ await put(
332
+ root,
333
+ ".gitignore",
334
+ "node_modules\n.vc\n.void\n.wrangler\n.env\n.env.local\n.env.keys\ndist\n*.tsbuildinfo\n.DS_Store\n",
335
+ result,
336
+ "own",
337
+ );
330
338
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
331
339
  await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
332
340
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
@@ -63,6 +63,9 @@ 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",
66
69
  "@rolldown/plugin-babel",
67
70
  "@tailwindcss/vite",
68
71
  "@types/node",
@@ -182,7 +182,8 @@ export const emailNotifications = (emailer: Emailer): NotificationProvider => ({
182
182
  * because the choice lives on the line, not in the catalogue.
183
183
  */
184
184
  export function renderMintTs(): string {
185
- return `import { type MintProvider, recordedMint } from "@saastemly/better-commerce/plugins/nft";
185
+ return `import { getCurrentAuthContext } from "@better-auth/core/context";
186
+ import { type MintProvider, recordedMint } from "@saastemly/better-commerce/plugins/nft";
186
187
 
187
188
  /**
188
189
  * Where a token comes from.
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
  ];