@saastemly/voidcommerce 0.3.0 → 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.
- package/dist/catalog.js +2 -1
- package/dist/cli.js +54 -3
- package/dist/deploy/index.d.ts +9 -0
- package/dist/deploy/keys.d.ts +34 -0
- package/dist/deploy/secrets.d.ts +15 -9
- package/dist/index-0v6na3yp.js +35 -0
- package/dist/{index-z4qazajn.js → index-844b3qn9.js} +1 -4
- package/dist/index-mpr7gm6k.js +4597 -0
- package/dist/{index-zh3tmj08.js → index-pz6m2hkm.js} +1 -1
- package/dist/{index-84wq4qp8.js → index-yzezvy5h.js} +273 -110
- package/dist/index.js +4 -3
- package/dist/manifest.js +3 -2
- package/package.json +2 -1
- package/src/cli.ts +4 -1
- package/src/deploy/index.ts +54 -1
- package/src/deploy/keys.ts +212 -0
- package/src/deploy/secrets.ts +43 -21
- package/src/dotenvx-primitives.d.ts +10 -0
- package/src/generate/index.ts +21 -0
- package/src/generate/strict.ts +2 -0
package/dist/index.js
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
routeProblem,
|
|
58
58
|
strictDependencies,
|
|
59
59
|
upsertJsonc
|
|
60
|
-
} from "./index-
|
|
60
|
+
} from "./index-yzezvy5h.js";
|
|
61
61
|
import {
|
|
62
62
|
LAYOUTS,
|
|
63
63
|
MANIFEST_FILE,
|
|
@@ -77,7 +77,7 @@ import {
|
|
|
77
77
|
writeManifest,
|
|
78
78
|
zone,
|
|
79
79
|
zoneOf
|
|
80
|
-
} from "./index-
|
|
80
|
+
} from "./index-pz6m2hkm.js";
|
|
81
81
|
import {
|
|
82
82
|
AUTH_PLUGINS,
|
|
83
83
|
CARRIERS,
|
|
@@ -94,7 +94,8 @@ import {
|
|
|
94
94
|
SIGN_IN,
|
|
95
95
|
TAX,
|
|
96
96
|
UI
|
|
97
|
-
} from "./index-
|
|
97
|
+
} from "./index-844b3qn9.js";
|
|
98
|
+
import"./index-0v6na3yp.js";
|
|
98
99
|
export {
|
|
99
100
|
zoneOf,
|
|
100
101
|
zone,
|
package/dist/manifest.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@clack/prompts": "^0.11.0",
|
|
37
|
+
"@dotenvx/primitives": "^2.2.0",
|
|
37
38
|
"picocolors": "^1.1.1"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deployCommand, deployHelp, preflightCommand, preflightHelp, secretsCliCommand, secretsHelp } from "./deploy/index";
|
|
1
|
+
import { deployCommand, deployHelp, guardCommand, keysCliCommand, keysHelp, 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";
|
|
@@ -39,6 +39,9 @@ export const EXTENDED: Record<string, Extended> = {
|
|
|
39
39
|
deploy: { run: deployCommand, help: deployHelp },
|
|
40
40
|
preflight: { run: preflightCommand, help: preflightHelp },
|
|
41
41
|
secrets: { run: secretsCliCommand, help: secretsHelp },
|
|
42
|
+
keys: { run: keysCliCommand, help: keysHelp },
|
|
43
|
+
// Called by the generated pre-commit hook; exit code is the interface.
|
|
44
|
+
guard: { run: guardCommand, help: async () => guardCommand() },
|
|
42
45
|
dev: { run: appScript("dev"), help: appScriptHelp("dev") },
|
|
43
46
|
build: { run: appScript("build"), help: appScriptHelp("build") },
|
|
44
47
|
preview: { run: appScript("preview"), help: appScriptHelp("preview") },
|
package/src/deploy/index.ts
CHANGED
|
@@ -4,7 +4,8 @@ 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
|
+
import { PRIVATE_KEY_VAR, SECRETS_FILE, plaintextSecretNames, secretsCommand } from "./secrets";
|
|
8
|
+
import { keysCommand } from "./keys";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* `vc deploy` extends `void deploy`: vc's preflight first, then void's
|
|
@@ -134,3 +135,55 @@ export async function secretsHelp(): Promise<number> {
|
|
|
134
135
|
);
|
|
135
136
|
return 0;
|
|
136
137
|
}
|
|
138
|
+
|
|
139
|
+
/** `vc keys` — what the token derives, and the warning that goes with it. */
|
|
140
|
+
export async function keysCliCommand(args: string[]): Promise<number> {
|
|
141
|
+
const project = await findProject();
|
|
142
|
+
if (!project) {
|
|
143
|
+
console.error("vc: no voidcommerce.json here.");
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
return keysCommand(project.manifest, project.root, args);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function keysHelp(): Promise<number> {
|
|
150
|
+
const width = 80;
|
|
151
|
+
console.log(
|
|
152
|
+
box("vc keys", [
|
|
153
|
+
line("The key that encrypts this repository's secrets is DERIVED from", width),
|
|
154
|
+
line("CLOUDFLARE_API_TOKEN, salted with the account id. Nothing is stored.", width),
|
|
155
|
+
line("", width),
|
|
156
|
+
...row("vc keys", "what this token derives, and whether it matches the repository", width, 2),
|
|
157
|
+
...row("vc keys --rotate", "re-encrypt under a new token, while the old one still works", width, 2),
|
|
158
|
+
line("", width),
|
|
159
|
+
line("Whoever can deploy the worker can read its secrets, and nobody else can.", width),
|
|
160
|
+
line("The price: rotating the token orphans every encrypted value, a second", width),
|
|
161
|
+
line("admin's token derives a different key, and the build must use the same", width),
|
|
162
|
+
line("token the values were encrypted under. `vc keys` checks the derived", width),
|
|
163
|
+
line("public key against the committed one, so a mismatch is a clear refusal", width),
|
|
164
|
+
line("rather than a decryption error nobody can place.", width),
|
|
165
|
+
], width),
|
|
166
|
+
);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The pre-commit gate: refuse a commit that would put a secret in the clear.
|
|
172
|
+
*
|
|
173
|
+
* Exit code is the whole interface — a hook cares about nothing else.
|
|
174
|
+
*/
|
|
175
|
+
export async function guardCommand(): Promise<number> {
|
|
176
|
+
const project = await findProject();
|
|
177
|
+
if (!project) return 0; // Not a shop; nothing to guard.
|
|
178
|
+
const bare = plaintextSecretNames(project.root);
|
|
179
|
+
if (bare.length === 0) return 0;
|
|
180
|
+
console.error(
|
|
181
|
+
`\n${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${
|
|
182
|
+
bare.length === 1 ? "is" : "are"
|
|
183
|
+
} not encrypted.\n\n` +
|
|
184
|
+
` ${bare.join("\n ")}\n\n` +
|
|
185
|
+
` bunx dotenvx encrypt -f ${SECRETS_FILE}\n\n` +
|
|
186
|
+
color.dim(" Committing a secret in the clear cannot be undone by a later commit;\n the value stays in the history and must be treated as burned.\n"),
|
|
187
|
+
);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { hkdfSync } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import color from "picocolors";
|
|
5
|
+
import type { Manifest } from "../manifest";
|
|
6
|
+
import { SECRETS_FILE } from "./secrets";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The key that encrypts the repository's secrets, DERIVED rather than stored.
|
|
10
|
+
*
|
|
11
|
+
* ── What this buys, and what it costs ────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* Nothing has to be kept. There is no `.env.keys` to lose, leak, or forget to
|
|
14
|
+
* gitignore — a real failure that has already happened once here. Whoever can
|
|
15
|
+
* deploy the worker can read its secrets, and nobody else can, which is the
|
|
16
|
+
* property that was wanted.
|
|
17
|
+
*
|
|
18
|
+
* The cost is severe and worth stating plainly, because it is invisible until
|
|
19
|
+
* the day it is not:
|
|
20
|
+
*
|
|
21
|
+
* **Rotating the API token orphans every secret in the repository.** The
|
|
22
|
+
* key changes with the token, and the ciphertext does not. Every
|
|
23
|
+
* `encrypted:` value becomes permanently unreadable, and the only recovery
|
|
24
|
+
* is to type them all in again.
|
|
25
|
+
*
|
|
26
|
+
* **A second admin derives a different key.** Two people with two tokens
|
|
27
|
+
* cannot read each other's values.
|
|
28
|
+
*
|
|
29
|
+
* **The build derives from whichever token it was given**, so the token in
|
|
30
|
+
* the build settings must be the one the values were encrypted under.
|
|
31
|
+
*
|
|
32
|
+
* So this module's real job is not deriving a key — that is eight lines. It
|
|
33
|
+
* is making those three failures LOUD. The committed `.env.secrets` carries
|
|
34
|
+
* the public key, so the derived private key can always be checked against
|
|
35
|
+
* what the repository was actually encrypted under, and a mismatch is caught
|
|
36
|
+
* before anything is attempted rather than surfacing as a decryption failure
|
|
37
|
+
* nobody can explain.
|
|
38
|
+
*
|
|
39
|
+
* @see https://developers.cloudflare.com/workers/wrangler/system-environment-variables/
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Domain separation. A key derived for this purpose must not equal one
|
|
44
|
+
* derived for any other, so the label is versioned: changing it is how a
|
|
45
|
+
* future scheme migrates without colliding with this one.
|
|
46
|
+
*/
|
|
47
|
+
const INFO = "voidcommerce/dotenvx/secrets/v1";
|
|
48
|
+
|
|
49
|
+
/** Where the token comes from, in the order wrangler itself reads them. */
|
|
50
|
+
export function apiToken(): string | null {
|
|
51
|
+
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The private key this token and account derive.
|
|
56
|
+
*
|
|
57
|
+
* The account id is the salt rather than a constant, so the same token used
|
|
58
|
+
* against two accounts does not produce one key — tokens are often scoped to
|
|
59
|
+
* several accounts, and a shared key across them would be a wider blast
|
|
60
|
+
* radius than anyone asked for.
|
|
61
|
+
*/
|
|
62
|
+
export function derivePrivateKey(token: string, accountId: string): string {
|
|
63
|
+
const bytes = hkdfSync("sha256", Buffer.from(token, "utf8"), Buffer.from(accountId, "utf8"), Buffer.from(INFO), 32);
|
|
64
|
+
return Buffer.from(bytes).toString("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The public key for a private one, via dotenvx's own primitive. */
|
|
68
|
+
export async function publicKeyFor(privateKey: string): Promise<string | null> {
|
|
69
|
+
try {
|
|
70
|
+
const { derive } = await import("@dotenvx/primitives");
|
|
71
|
+
return derive(privateKey);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The public key the repository was actually encrypted under. */
|
|
78
|
+
export function committedPublicKey(root: string): string | null {
|
|
79
|
+
const path = join(root, SECRETS_FILE);
|
|
80
|
+
if (!existsSync(path)) return null;
|
|
81
|
+
const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync(path, "utf8"));
|
|
82
|
+
return match?.[1] ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type KeyState =
|
|
86
|
+
| { ok: true; privateKey: string; publicKey: string; fresh: boolean }
|
|
87
|
+
| { ok: false; reason: string };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The key for this repository, or a refusal that says exactly what is wrong.
|
|
91
|
+
*
|
|
92
|
+
* `fresh` means the repository has no secrets yet, so there is nothing to
|
|
93
|
+
* check the derivation against and any token is as good as another.
|
|
94
|
+
*/
|
|
95
|
+
export async function keyFor(manifest: Manifest, root: string): Promise<KeyState> {
|
|
96
|
+
const token = apiToken();
|
|
97
|
+
if (!token) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason:
|
|
101
|
+
"CLOUDFLARE_API_TOKEN is not set.\n" +
|
|
102
|
+
" The key is derived from it, so there is nothing to derive from. Note that\n" +
|
|
103
|
+
" `wrangler login` does NOT set one — this scheme needs an API token, from\n" +
|
|
104
|
+
" My Profile → API Tokens.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
108
|
+
if (!accountId) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason:
|
|
112
|
+
"the account is not pinned, and it salts the derivation.\n" +
|
|
113
|
+
" Run `vc deploy --cloudflare --provision` once, or set CLOUDFLARE_ACCOUNT_ID.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const privateKey = derivePrivateKey(token, accountId);
|
|
118
|
+
const publicKey = await publicKeyFor(privateKey);
|
|
119
|
+
if (!publicKey) {
|
|
120
|
+
return { ok: false, reason: "@dotenvx/dotenvx is not installed here, so a key cannot be derived. `bun add -d @dotenvx/dotenvx`" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const committed = committedPublicKey(root);
|
|
124
|
+
if (!committed) return { ok: true, privateKey, publicKey, fresh: true };
|
|
125
|
+
|
|
126
|
+
if (committed.toLowerCase() !== publicKey.toLowerCase()) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
reason:
|
|
130
|
+
`this token does not derive the key ${SECRETS_FILE} was encrypted under.\n\n` +
|
|
131
|
+
` encrypted under: ${committed.slice(0, 16)}…\n` +
|
|
132
|
+
` this token gives: ${publicKey.slice(0, 16)}…\n\n` +
|
|
133
|
+
" That is one of three things, and all of them are the same fix:\n" +
|
|
134
|
+
" · the token was rotated since the secrets were encrypted\n" +
|
|
135
|
+
" · this is a different admin's token\n" +
|
|
136
|
+
" · CLOUDFLARE_ACCOUNT_ID is not the account they were encrypted for\n\n" +
|
|
137
|
+
` If you still have the ORIGINAL token, \`vc keys rotate\` re-encrypts\n` +
|
|
138
|
+
" everything under the new one. If you do not, the values are unrecoverable\n" +
|
|
139
|
+
" and must be entered again.",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, privateKey, publicKey, fresh: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `vc keys` — what the derivation says, without printing anything secret. */
|
|
146
|
+
export async function keysCommand(manifest: Manifest, root: string, args: string[]): Promise<number> {
|
|
147
|
+
if (args.includes("--rotate")) return rotate(manifest, root);
|
|
148
|
+
|
|
149
|
+
const state = await keyFor(manifest, root);
|
|
150
|
+
console.log(`\nThe key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the account id.`);
|
|
151
|
+
console.log(color.dim("Nothing is stored, so nothing can leak — and nothing can be recovered.\n"));
|
|
152
|
+
|
|
153
|
+
if (!state.ok) {
|
|
154
|
+
console.error(`${color.red("✗")} ${state.reason}\n`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
console.log(` derives public key ${state.publicKey.slice(0, 20)}…`);
|
|
158
|
+
console.log(
|
|
159
|
+
state.fresh
|
|
160
|
+
? ` ${color.dim(`${SECRETS_FILE} does not exist yet, so there is nothing to check against`)}`
|
|
161
|
+
: ` ${color.green("✓")} matches what ${SECRETS_FILE} was encrypted under`,
|
|
162
|
+
);
|
|
163
|
+
console.log(
|
|
164
|
+
color.yellow(
|
|
165
|
+
`\n! Rotating this API token makes every secret in ${SECRETS_FILE} unreadable.\n` +
|
|
166
|
+
" Run `vc keys --rotate` with the NEW token exported and the old one in\n" +
|
|
167
|
+
" CLOUDFLARE_API_TOKEN_OLD, BEFORE the old one stops working.\n",
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-encrypt under a new token's key, while the old one still works.
|
|
175
|
+
*
|
|
176
|
+
* This is the escape hatch that makes a derived key survivable, and it only
|
|
177
|
+
* exists in the window where both tokens are known. Outside that window there
|
|
178
|
+
* is no recovery, which is why `vc keys` says so every time it runs.
|
|
179
|
+
*/
|
|
180
|
+
async function rotate(manifest: Manifest, root: string): Promise<number> {
|
|
181
|
+
const oldToken = process.env["CLOUDFLARE_API_TOKEN_OLD"];
|
|
182
|
+
const newToken = apiToken();
|
|
183
|
+
if (!oldToken || !newToken) {
|
|
184
|
+
console.error(
|
|
185
|
+
"\nvc: rotation needs BOTH tokens:\n" +
|
|
186
|
+
" CLOUDFLARE_API_TOKEN_OLD=<the one the secrets were encrypted under>\n" +
|
|
187
|
+
" CLOUDFLARE_API_TOKEN=<the new one>\n\n" +
|
|
188
|
+
" The old one is the only thing that can read the current values.\n",
|
|
189
|
+
);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
193
|
+
if (!accountId) {
|
|
194
|
+
console.error("vc: the account is not pinned, and it salts the derivation.");
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
const oldKey = derivePrivateKey(oldToken, accountId);
|
|
198
|
+
const oldPublic = await publicKeyFor(oldKey);
|
|
199
|
+
const committed = committedPublicKey(root);
|
|
200
|
+
if (committed && oldPublic && committed.toLowerCase() !== oldPublic.toLowerCase()) {
|
|
201
|
+
console.error(`\nvc: CLOUDFLARE_API_TOKEN_OLD does not derive the key ${SECRETS_FILE} was encrypted under either.\n`);
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
console.log(
|
|
205
|
+
`\nRotation is a decrypt with the old key and an encrypt with the new one.\n` +
|
|
206
|
+
`Run these two, in this order, from ${root}:\n\n` +
|
|
207
|
+
` ${color.cyan(`DOTENV_PRIVATE_KEY_SECRETS=<old> bunx dotenvx decrypt -f ${SECRETS_FILE}`)}\n` +
|
|
208
|
+
` ${color.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)} ${color.dim("# under the new derived key")}\n\n` +
|
|
209
|
+
color.dim("vc does not run them for you: the middle state is your secrets in\nplaintext on disk, and that is a moment to be deliberate about.\n"),
|
|
210
|
+
);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
package/src/deploy/secrets.ts
CHANGED
|
@@ -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 { keyFor } from "./keys";
|
|
7
8
|
import type { Project } from "../project";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -20,8 +21,9 @@ import type { Project } from "../project";
|
|
|
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
23
|
* diff shows when a secret changed without showing what it changed to. The
|
|
23
|
-
* private key is
|
|
24
|
-
*
|
|
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.
|
|
25
27
|
*
|
|
26
28
|
* ── Why the values still become worker SECRETS ───────────────────────────
|
|
27
29
|
*
|
|
@@ -34,14 +36,19 @@ import type { Project } from "../project";
|
|
|
34
36
|
* applies ADDITIVELY, so a secret this file does not name is left alone
|
|
35
37
|
* rather than deleted.
|
|
36
38
|
*
|
|
37
|
-
* ── The
|
|
39
|
+
* ── The tradeoffs, stated ────────────────────────────────────────────────
|
|
38
40
|
*
|
|
39
|
-
* Ciphertext in git is permanent.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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.
|
|
45
52
|
*/
|
|
46
53
|
|
|
47
54
|
export const SECRETS_FILE = ".env.secrets";
|
|
@@ -131,13 +138,15 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
|
|
|
131
138
|
|
|
132
139
|
const dotenvx = findDotenvx(root);
|
|
133
140
|
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
141
|
|
|
140
|
-
|
|
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
|
+
});
|
|
141
150
|
if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
|
|
142
151
|
|
|
143
152
|
// Keep only the shop's own keys: dotenvx echoes the public key back, and
|
|
@@ -217,6 +226,13 @@ async function initSecrets(project: Project): Promise<number> {
|
|
|
217
226
|
console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
|
|
218
227
|
return 1;
|
|
219
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
|
+
}
|
|
220
236
|
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
221
237
|
const body = [
|
|
222
238
|
"# Secrets, encrypted, and COMMITTED.",
|
|
@@ -225,20 +241,26 @@ async function initSecrets(project: Project): Promise<number> {
|
|
|
225
241
|
"# secret changed without showing what it changed to. `unset` is the",
|
|
226
242
|
"# documented placeholder and preflight refuses it.",
|
|
227
243
|
"#",
|
|
228
|
-
"# The private key
|
|
229
|
-
"#
|
|
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}"`,
|
|
230
252
|
"",
|
|
231
253
|
...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
|
|
232
254
|
].join("\n");
|
|
233
255
|
writeFileSync(path, body, { mode: 0o600 });
|
|
234
256
|
|
|
235
257
|
console.log(
|
|
236
|
-
`\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset
|
|
258
|
+
`\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`, under the key this token derives\n\n` +
|
|
237
259
|
`Next:\n` +
|
|
238
260
|
` 1. put the real values in, then\n` +
|
|
239
|
-
` 2. ${color.cyan(`dotenvx encrypt -f ${SECRETS_FILE}`)}\n` +
|
|
240
|
-
` 3. commit
|
|
241
|
-
|
|
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`),
|
|
242
264
|
);
|
|
243
265
|
return 0;
|
|
244
266
|
}
|
|
@@ -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
|
+
}
|
package/src/generate/index.ts
CHANGED
|
@@ -320,6 +320,10 @@ 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,
|
|
@@ -336,6 +340,23 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
|
|
|
336
340
|
"own",
|
|
337
341
|
);
|
|
338
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
|
+
);
|
|
339
360
|
await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
340
361
|
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
|
341
362
|
await put(root, ".env", renderEnvLocal(manifest), result, "own");
|
package/src/generate/strict.ts
CHANGED
|
@@ -66,6 +66,8 @@ export function strictDependencies(manifest: Manifest): {
|
|
|
66
66
|
// The repository's secrets are encrypted with this; a build needs it to
|
|
67
67
|
// hand them to wrangler.
|
|
68
68
|
"@dotenvx/dotenvx",
|
|
69
|
+
// Installs the committed pre-commit hook on every fresh clone.
|
|
70
|
+
"husky",
|
|
69
71
|
"@rolldown/plugin-babel",
|
|
70
72
|
"@tailwindcss/vite",
|
|
71
73
|
"@types/node",
|