@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.
- package/dist/catalog.js +2 -1
- package/dist/cli.js +87 -3
- package/dist/deploy/cloudflare.d.ts +3 -1
- package/dist/deploy/index.d.ts +12 -0
- package/dist/deploy/keys.d.ts +34 -0
- package/dist/deploy/preflight.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +74 -0
- 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-sbchkpa6.js → index-yzezvy5h.js} +476 -77
- package/dist/index.js +4 -3
- package/dist/manifest.js +3 -2
- package/package.json +2 -1
- package/src/cli.ts +5 -1
- package/src/deploy/cloudflare.ts +25 -3
- package/src/deploy/index.ts +88 -0
- package/src/deploy/keys.ts +212 -0
- package/src/deploy/preflight.ts +38 -3
- package/src/deploy/secrets.ts +266 -0
- package/src/dist.ts +3 -1
- package/src/dotenvx-primitives.d.ts +10 -0
- package/src/generate/ci.ts +30 -5
- package/src/generate/index.ts +30 -1
- package/src/generate/strict.ts +5 -0
- package/src/help.ts +1 -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 } 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";
|
|
@@ -38,6 +38,10 @@ 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 },
|
|
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() },
|
|
41
45
|
dev: { run: appScript("dev"), help: appScriptHelp("dev") },
|
|
42
46
|
build: { run: appScript("build"), help: appScriptHelp("build") },
|
|
43
47
|
preview: { run: appScript("preview"), help: appScriptHelp("preview") },
|
package/src/deploy/cloudflare.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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;
|
package/src/deploy/index.ts
CHANGED
|
@@ -4,6 +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, plaintextSecretNames, secretsCommand } from "./secrets";
|
|
8
|
+
import { keysCommand } from "./keys";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* `vc deploy` extends `void deploy`: vc's preflight first, then void's
|
|
@@ -99,3 +101,89 @@ export async function preflightHelp(): Promise<number> {
|
|
|
99
101
|
);
|
|
100
102
|
return 0;
|
|
101
103
|
}
|
|
104
|
+
|
|
105
|
+
/** `vc secrets` — what the repository declares, and whether it is encrypted. */
|
|
106
|
+
export async function secretsCliCommand(args: string[]): Promise<number> {
|
|
107
|
+
const project = await findProject();
|
|
108
|
+
if (!project) {
|
|
109
|
+
console.error("vc: no voidcommerce.json here.");
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
return secretsCommand(project, args);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function secretsHelp(): Promise<number> {
|
|
116
|
+
const width = 80;
|
|
117
|
+
console.log(
|
|
118
|
+
box("vc secrets", [
|
|
119
|
+
line(`Secrets that live in the repository, encrypted, in ${SECRETS_FILE}.`, width),
|
|
120
|
+
line("", width),
|
|
121
|
+
...row("vc secrets", "what this shop needs, and whether it is declared and encrypted", width, 2),
|
|
122
|
+
...row("vc secrets --init", `write ${SECRETS_FILE} with every required key as \`unset\``, width, 2),
|
|
123
|
+
...row(`dotenvx set KEY '…' -f ${SECRETS_FILE}`, "set one, without decrypting the file", width, 2),
|
|
124
|
+
...row(`dotenvx encrypt -f ${SECRETS_FILE}`, "encrypt anything still in the clear", width, 2),
|
|
125
|
+
line("", width),
|
|
126
|
+
line("The file is COMMITTED: values are ciphertext, key names are not, so a", width),
|
|
127
|
+
line("diff shows which secret changed without showing what it changed to.", width),
|
|
128
|
+
line(`The private key stays out — .env.keys locally, and ${PRIVATE_KEY_VAR}`, width),
|
|
129
|
+
line("as one build variable where the deploy runs.", width),
|
|
130
|
+
line("", width),
|
|
131
|
+
line("Tradeoff worth knowing: ciphertext in git is permanent, so a leaked key", width),
|
|
132
|
+
line("exposes rotated secrets too. `wrangler secret put` does not have that", width),
|
|
133
|
+
line("property, and stays available for anything you would rather keep out.", width),
|
|
134
|
+
], width),
|
|
135
|
+
);
|
|
136
|
+
return 0;
|
|
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/preflight.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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(", ")}`);
|