@saastemly/voidcommerce 0.16.0 → 0.17.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/cli.js +23 -10
- package/dist/deploy/github.d.ts +8 -0
- package/dist/deploy/keys.d.ts +22 -11
- package/dist/{index-x70qxh3m.js → index-4jz1448c.js} +33 -6
- package/dist/{index-s8se0x2d.js → index-negctpws.js} +69 -5
- package/dist/index.js +2 -2
- package/dist/{keys-6neyajsv.js → keys-w65kbdp6.js} +1 -1
- package/package.json +1 -1
- package/src/deploy/github.ts +16 -0
- package/src/deploy/index.ts +17 -9
- package/src/deploy/keys.ts +85 -15
- package/src/deploy/link.ts +21 -7
- package/src/generate/ci.ts +31 -4
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
runVoid,
|
|
22
22
|
version,
|
|
23
23
|
voidAppsIn
|
|
24
|
-
} from "./index-
|
|
24
|
+
} from "./index-4jz1448c.js";
|
|
25
25
|
import {
|
|
26
26
|
LOCAL_KEY_FILE,
|
|
27
27
|
PRIVATE_KEY_VAR,
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
declaredSecretNames,
|
|
33
33
|
encryptInto,
|
|
34
34
|
envSummary,
|
|
35
|
+
getVariable,
|
|
35
36
|
ghAuth,
|
|
36
37
|
ignoresKeyFile,
|
|
37
38
|
initSecrets,
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
setVariable,
|
|
51
52
|
variableNames,
|
|
52
53
|
verifyCloudflareToken
|
|
53
|
-
} from "./index-
|
|
54
|
+
} from "./index-negctpws.js";
|
|
54
55
|
import {
|
|
55
56
|
LAYOUTS,
|
|
56
57
|
oneOrigin,
|
|
@@ -118,16 +119,23 @@ async function linkCommand(project, args) {
|
|
|
118
119
|
} else {
|
|
119
120
|
const parked = localPrivateKey(root);
|
|
120
121
|
if (parked) {
|
|
121
|
-
const sent = await
|
|
122
|
+
const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
|
|
122
123
|
if (!sent.ok) {
|
|
123
|
-
p.cancel(`GitHub refused the
|
|
124
|
+
p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
|
|
124
125
|
return 1;
|
|
125
126
|
}
|
|
126
127
|
const publicKey = await publicKeyFor(parked);
|
|
127
128
|
if (publicKey)
|
|
128
129
|
committedPublicKeyInto(root, publicKey);
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
const readBack = await getVariable(root, PRIVATE_KEY_VAR);
|
|
131
|
+
if (readBack !== parked) {
|
|
132
|
+
p.log.warn(`${color.yellow("!")} ${PRIVATE_KEY_VAR} was written to ${slug} but could not be read back,
|
|
133
|
+
so ${LOCAL_KEY_FILE} has been LEFT IN PLACE. It is currently the only copy
|
|
134
|
+
` + " of the key that opens this shop — do not delete it until `vc keys --restore` works.");
|
|
135
|
+
} else {
|
|
136
|
+
rmSync(join(root, LOCAL_KEY_FILE), { force: true });
|
|
137
|
+
p.log.success(`${color.green("✓")} the key moved to ${slug} and ${LOCAL_KEY_FILE} deleted — verified readable, so \`vc keys --restore\` can bring it back`);
|
|
138
|
+
}
|
|
131
139
|
} else {
|
|
132
140
|
const made = await provisionKey(project);
|
|
133
141
|
if (!made.ok) {
|
|
@@ -372,10 +380,15 @@ async function keysHelp() {
|
|
|
372
380
|
line("A contributor with only a clone can rotate the Stripe key and cannot read", width),
|
|
373
381
|
line("the one already there. Only the deploy decrypts.", width),
|
|
374
382
|
line("", width),
|
|
375
|
-
line("
|
|
376
|
-
line("
|
|
377
|
-
line("
|
|
378
|
-
line("
|
|
383
|
+
line("The key is a repository VARIABLE, not a secret. GitHub secrets are", width),
|
|
384
|
+
line("write-only — `gh secret` has no read command — so a key kept there is", width),
|
|
385
|
+
line("gone the moment your local copy is, and every encrypted value with it.", width),
|
|
386
|
+
line("As a variable it can be read back, which is what makes a new machine or", width),
|
|
387
|
+
line("a second person possible: `vc keys --restore`.", width),
|
|
388
|
+
line("", width),
|
|
389
|
+
line("So anyone who can READ the repository can decrypt its secrets. Keep it", width),
|
|
390
|
+
line("private. That access list is also how you share: a collaborator can", width),
|
|
391
|
+
line("restore the key, and removing them removes their access.", width)
|
|
379
392
|
], width));
|
|
380
393
|
return 0;
|
|
381
394
|
}
|
package/dist/deploy/github.d.ts
CHANGED
|
@@ -66,6 +66,14 @@ export declare function setVariable(cwd: string, name: string, value: string): P
|
|
|
66
66
|
ok: boolean;
|
|
67
67
|
error?: string;
|
|
68
68
|
}>;
|
|
69
|
+
/**
|
|
70
|
+
* A variable's VALUE — the thing a secret can never give back.
|
|
71
|
+
*
|
|
72
|
+
* `gh secret` has list, set and delete and no read, by design: a GitHub
|
|
73
|
+
* secret is write-only forever, even to its owner. A variable is not, and
|
|
74
|
+
* that single difference is why the encryption key lives in one.
|
|
75
|
+
*/
|
|
76
|
+
export declare function getVariable(cwd: string, name: string): Promise<string | null>;
|
|
69
77
|
export declare function variableNames(cwd: string): Promise<Set<string>>;
|
|
70
78
|
/**
|
|
71
79
|
* Ask Cloudflare whether a token is real, before it is stored anywhere.
|
package/dist/deploy/keys.d.ts
CHANGED
|
@@ -24,19 +24,30 @@ import type { Project } from "../project";
|
|
|
24
24
|
*
|
|
25
25
|
* So the key is generated at random and handed to GitHub, which is where
|
|
26
26
|
* the deploy runs and the one place a person who can push is already
|
|
27
|
-
* authenticated.
|
|
28
|
-
* stdin, GitHub encrypts it, and no API can read it back — which is the
|
|
29
|
-
* property that makes it a good home and, unavoidably, the property that
|
|
30
|
-
* makes rotation a re-key rather than a re-encrypt.
|
|
27
|
+
* authenticated.
|
|
31
28
|
*
|
|
32
|
-
* ──
|
|
29
|
+
* ── Why it is a VARIABLE and not a secret ────────────────────────────────
|
|
33
30
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
31
|
+
* `gh secret` has list, set and delete and NO read: a GitHub secret is
|
|
32
|
+
* write-only forever, even to the person who set it. Keeping the key there
|
|
33
|
+
* built a one-way door — once the local `.env.keys` was deleted the key
|
|
34
|
+
* existed nowhere a human could reach, so nobody could ever decrypt again,
|
|
35
|
+
* including the owner of the shop. CI could deploy, and that was the whole
|
|
36
|
+
* of it. No reading a value back to check it, no re-keying, no second
|
|
37
|
+
* machine, no colleague.
|
|
38
|
+
*
|
|
39
|
+
* A repository VARIABLE can be read back (`gh variable get`), so the key is
|
|
40
|
+
* recoverable by anyone with access to the repository — the owner, plus
|
|
41
|
+
* whoever they invite. That access list IS the answer to "a key several
|
|
42
|
+
* people can share": adding a colleague is adding a collaborator, and
|
|
43
|
+
* removing their access removes their ability to decrypt.
|
|
44
|
+
*
|
|
45
|
+
* The cost, stated plainly. A variable is shown in the repository's settings
|
|
46
|
+
* in the clear, and unlike a secret it is NOT masked in workflow logs
|
|
47
|
+
* automatically. So the generated workflow masks it with `::add-mask::` the
|
|
48
|
+
* moment it reads it, and the repository must be private. This is the right
|
|
49
|
+
* trade only because the alternative — a key nobody can ever read — is not a
|
|
50
|
+
* working system, it is a shop with one deploy left in it.
|
|
40
51
|
*/
|
|
41
52
|
/**
|
|
42
53
|
* Where a private key waits before the repository exists.
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
renderEnvProduction,
|
|
13
13
|
renderEnvTs,
|
|
14
14
|
unsetSecretNames
|
|
15
|
-
} from "./index-
|
|
15
|
+
} from "./index-negctpws.js";
|
|
16
16
|
import {
|
|
17
17
|
MANIFEST_FILE,
|
|
18
18
|
has,
|
|
@@ -1379,7 +1379,7 @@ import color from "picocolors";
|
|
|
1379
1379
|
// package.json
|
|
1380
1380
|
var package_default = {
|
|
1381
1381
|
name: "@saastemly/voidcommerce",
|
|
1382
|
-
version: "0.
|
|
1382
|
+
version: "0.17.0",
|
|
1383
1383
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1384
1384
|
type: "module",
|
|
1385
1385
|
license: "MIT",
|
|
@@ -1720,7 +1720,7 @@ function renderDistWorkflow(manifest) {
|
|
|
1720
1720
|
# The credentials come from the repository, set once by \`vc link\`:
|
|
1721
1721
|
#
|
|
1722
1722
|
# secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
|
|
1723
|
-
#
|
|
1723
|
+
# vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
|
|
1724
1724
|
# vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
|
|
1725
1725
|
#
|
|
1726
1726
|
# Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
|
|
@@ -1817,16 +1817,25 @@ jobs:
|
|
|
1817
1817
|
env:
|
|
1818
1818
|
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
1819
1819
|
CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
1820
|
-
${PRIVATE_KEY_VAR}: \${{
|
|
1820
|
+
${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
|
|
1821
1821
|
steps:
|
|
1822
1822
|
- uses: actions/checkout@v6
|
|
1823
1823
|
- uses: oven-sh/setup-bun@v2
|
|
1824
1824
|
|
|
1825
1825
|
# Checked before anything is built, so a repository that was never
|
|
1826
1826
|
# linked says so in five seconds rather than four minutes.
|
|
1827
|
+
#
|
|
1828
|
+
# And ${PRIVATE_KEY_VAR} is masked here, first thing. It is a
|
|
1829
|
+
# VARIABLE rather than a secret so that a human can read it back —
|
|
1830
|
+
# that is what makes a new laptop or a second person possible — but
|
|
1831
|
+
# GitHub only masks SECRETS in logs automatically. Registering it with
|
|
1832
|
+
# ::add-mask:: buys back the redaction without giving up recovery.
|
|
1827
1833
|
- name: Are the credentials here?
|
|
1828
1834
|
run: |
|
|
1829
1835
|
set -euo pipefail
|
|
1836
|
+
if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
|
|
1837
|
+
echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
|
|
1838
|
+
fi
|
|
1830
1839
|
missing=""
|
|
1831
1840
|
[ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
|
|
1832
1841
|
[ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
|
|
@@ -1898,7 +1907,7 @@ vc link
|
|
|
1898
1907
|
|
|
1899
1908
|
| what | where | why |
|
|
1900
1909
|
|---|---|---|
|
|
1901
|
-
| \`${PRIVATE_KEY_VAR}\` | repository **
|
|
1910
|
+
| \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
|
|
1902
1911
|
| \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
|
|
1903
1912
|
| \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
|
|
1904
1913
|
|
|
@@ -1946,6 +1955,24 @@ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
|
|
|
1946
1955
|
vc secrets # what is set, what is missing
|
|
1947
1956
|
\`\`\`
|
|
1948
1957
|
|
|
1958
|
+
### Getting the key back
|
|
1959
|
+
|
|
1960
|
+
\`\`\`sh
|
|
1961
|
+
vc keys --restore
|
|
1962
|
+
\`\`\`
|
|
1963
|
+
|
|
1964
|
+
\`${PRIVATE_KEY_VAR}\` is a repository **variable**, not a secret. GitHub
|
|
1965
|
+
secrets are write-only — \`gh secret\` has no read command — so a key kept
|
|
1966
|
+
there is gone the moment your local copy is, and with it every value in
|
|
1967
|
+
\`${SECRETS_FILE}\`. As a variable it can be read back, which is what makes a
|
|
1968
|
+
new laptop, a rebuild, or a second person possible at all.
|
|
1969
|
+
|
|
1970
|
+
**That means anyone who can read this repository can decrypt its secrets.**
|
|
1971
|
+
Keep it private. It is also the answer to sharing: a colleague who is a
|
|
1972
|
+
collaborator can run \`vc keys --restore\` and work; removing them removes
|
|
1973
|
+
their access. The deploy workflow masks the value with \`::add-mask::\` the
|
|
1974
|
+
moment it reads it, because GitHub does not mask variables on its own.
|
|
1975
|
+
|
|
1949
1976
|
**This needs no credential.** dotenvx is asymmetric: encryption uses the public
|
|
1950
1977
|
key committed at the top of the file, so anyone with a clone can set or rotate
|
|
1951
1978
|
a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
|
|
@@ -1970,7 +1997,7 @@ The same thing, from a checkout, if CI is ever not the answer:
|
|
|
1970
1997
|
|
|
1971
1998
|
\`\`\`sh
|
|
1972
1999
|
export CLOUDFLARE_API_TOKEN=…
|
|
1973
|
-
export ${PRIVATE_KEY_VAR}=… #
|
|
2000
|
+
export ${PRIVATE_KEY_VAR}=… # or run \`vc keys --restore\` first
|
|
1974
2001
|
vc deploy --cloudflare --provision
|
|
1975
2002
|
\`\`\`
|
|
1976
2003
|
|
|
@@ -103,6 +103,16 @@ async function setVariable(cwd, name, value) {
|
|
|
103
103
|
return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
|
|
104
104
|
`).slice(-2).join(" ") };
|
|
105
105
|
}
|
|
106
|
+
async function getVariable(cwd, name) {
|
|
107
|
+
const gh = findGh();
|
|
108
|
+
if (!gh)
|
|
109
|
+
return null;
|
|
110
|
+
const got = await run(gh, ["variable", "get", name, "--json", "value", "-q", ".value"], cwd);
|
|
111
|
+
if (got.code !== 0)
|
|
112
|
+
return null;
|
|
113
|
+
const value = got.out.trim();
|
|
114
|
+
return value.length > 0 ? value : null;
|
|
115
|
+
}
|
|
106
116
|
async function variableNames(cwd) {
|
|
107
117
|
const gh = findGh();
|
|
108
118
|
if (!gh)
|
|
@@ -404,7 +414,7 @@ async function decryptSecrets(project) {
|
|
|
404
414
|
}
|
|
405
415
|
const expected = committedPublicKey(root);
|
|
406
416
|
if (expected) {
|
|
407
|
-
const { publicKeyFor } = await import("./keys-
|
|
417
|
+
const { publicKeyFor } = await import("./keys-w65kbdp6.js");
|
|
408
418
|
const derived = await publicKeyFor(privateKey);
|
|
409
419
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
410
420
|
return {
|
|
@@ -749,7 +759,7 @@ async function publicKeyFor(privateKey) {
|
|
|
749
759
|
async function keyState(project) {
|
|
750
760
|
const publicKey = committedPublicKey(project.root);
|
|
751
761
|
const slug = await repoSlug(project.root);
|
|
752
|
-
const names = slug ? await
|
|
762
|
+
const names = slug ? await variableNames(project.root) : new Set;
|
|
753
763
|
const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
|
|
754
764
|
let mismatch;
|
|
755
765
|
if (localKey && publicKey) {
|
|
@@ -773,9 +783,9 @@ async function provisionKey(project) {
|
|
|
773
783
|
const auth = await ghAuth(project.root);
|
|
774
784
|
const slug = auth.ok ? await repoSlug(project.root) : null;
|
|
775
785
|
if (slug) {
|
|
776
|
-
const sent = await
|
|
786
|
+
const sent = await setVariable(project.root, PRIVATE_KEY_VAR, pair.privateKey);
|
|
777
787
|
if (!sent.ok)
|
|
778
|
-
return { ok: false, reason: `GitHub refused the
|
|
788
|
+
return { ok: false, reason: `GitHub refused the variable: ${sent.error ?? "unknown error"}` };
|
|
779
789
|
return { ok: true, publicKey: pair.publicKey, parked: false };
|
|
780
790
|
}
|
|
781
791
|
if (!ignoresKeyFile(project.root)) {
|
|
@@ -797,11 +807,65 @@ function ignoresKeyFile(root) {
|
|
|
797
807
|
return readFileSync2(path, "utf8").split(`
|
|
798
808
|
`).map((line) => line.trim()).some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
|
|
799
809
|
}
|
|
810
|
+
async function restoreKey(project) {
|
|
811
|
+
const auth = await ghAuth(project.root);
|
|
812
|
+
if (!auth.ok) {
|
|
813
|
+
console.error(`
|
|
814
|
+
vc: ${auth.reason}
|
|
815
|
+
`);
|
|
816
|
+
return 1;
|
|
817
|
+
}
|
|
818
|
+
const slug = await repoSlug(project.root);
|
|
819
|
+
if (!slug) {
|
|
820
|
+
console.error(`
|
|
821
|
+
vc: this checkout has no GitHub repository, so there is nowhere to restore from.
|
|
822
|
+
`);
|
|
823
|
+
return 1;
|
|
824
|
+
}
|
|
825
|
+
const key = await getVariable(project.root, PRIVATE_KEY_VAR);
|
|
826
|
+
if (!key) {
|
|
827
|
+
console.error(`
|
|
828
|
+
vc: ${slug} has no ${PRIVATE_KEY_VAR} variable.
|
|
829
|
+
Either this shop was never linked, or the key is held somewhere else.
|
|
830
|
+
\`vc keys\` shows what this repository has.
|
|
831
|
+
`);
|
|
832
|
+
return 1;
|
|
833
|
+
}
|
|
834
|
+
const committed = committedPublicKey(project.root);
|
|
835
|
+
const derived = await publicKeyFor(key);
|
|
836
|
+
if (committed && derived && derived.toLowerCase() !== committed.toLowerCase()) {
|
|
837
|
+
console.error(`
|
|
838
|
+
vc: the key on ${slug} does not open this repository.
|
|
839
|
+
it derives: ${derived.slice(0, 16)}…
|
|
840
|
+
` + ` ${SECRETS_FILE} needs: ${committed.slice(0, 16)}…
|
|
841
|
+
|
|
842
|
+
` + ` Nothing was written. The repository has been re-keyed since that
|
|
843
|
+
variable was set, or the two belong to different shops.
|
|
844
|
+
`);
|
|
845
|
+
return 1;
|
|
846
|
+
}
|
|
847
|
+
if (!ignoresKeyFile(project.root)) {
|
|
848
|
+
console.error(`
|
|
849
|
+
vc: ${LOCAL_KEY_FILE} is not gitignored here, and this would write the key into it.
|
|
850
|
+
Add it to .gitignore first.
|
|
851
|
+
`);
|
|
852
|
+
return 1;
|
|
853
|
+
}
|
|
854
|
+
writeFileSync2(join3(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${key}"
|
|
855
|
+
`, { mode: 384 });
|
|
856
|
+
console.log(`
|
|
857
|
+
${color2.green("✓")} key restored from ${slug} into ${LOCAL_KEY_FILE}
|
|
858
|
+
` + color2.dim(` It opens ${SECRETS_FILE}. The file is gitignored; delete it when you are done.
|
|
859
|
+
`));
|
|
860
|
+
return 0;
|
|
861
|
+
}
|
|
800
862
|
async function keysCommand(project, args) {
|
|
801
863
|
if (args.includes("--rotate"))
|
|
802
864
|
return rotate(project);
|
|
803
865
|
if (args.includes("--init"))
|
|
804
866
|
return initKey(project);
|
|
867
|
+
if (args.includes("--restore"))
|
|
868
|
+
return restoreKey(project);
|
|
805
869
|
const state = await keyState(project);
|
|
806
870
|
console.log(`
|
|
807
871
|
Secrets are encrypted with a public key that is COMMITTED, and read with a`);
|
|
@@ -931,4 +995,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
931
995
|
return 0;
|
|
932
996
|
}
|
|
933
997
|
|
|
934
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
|
998
|
+
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, getVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
package/dist/index.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
routeProblem,
|
|
53
53
|
strictDependencies,
|
|
54
54
|
upsertJsonc
|
|
55
|
-
} from "./index-
|
|
55
|
+
} from "./index-4jz1448c.js";
|
|
56
56
|
import {
|
|
57
57
|
allEnvKeys,
|
|
58
58
|
envSummary,
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
renderEnvLocal,
|
|
61
61
|
renderEnvProduction,
|
|
62
62
|
renderEnvTs
|
|
63
|
-
} from "./index-
|
|
63
|
+
} from "./index-negctpws.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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/deploy/github.ts
CHANGED
|
@@ -153,6 +153,22 @@ export async function setVariable(cwd: string, name: string, value: string): Pro
|
|
|
153
153
|
return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split("\n").slice(-2).join(" ") };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* A variable's VALUE — the thing a secret can never give back.
|
|
158
|
+
*
|
|
159
|
+
* `gh secret` has list, set and delete and no read, by design: a GitHub
|
|
160
|
+
* secret is write-only forever, even to its owner. A variable is not, and
|
|
161
|
+
* that single difference is why the encryption key lives in one.
|
|
162
|
+
*/
|
|
163
|
+
export async function getVariable(cwd: string, name: string): Promise<string | null> {
|
|
164
|
+
const gh = findGh();
|
|
165
|
+
if (!gh) return null;
|
|
166
|
+
const got = await run(gh, ["variable", "get", name, "--json", "value", "-q", ".value"], cwd);
|
|
167
|
+
if (got.code !== 0) return null;
|
|
168
|
+
const value = got.out.trim();
|
|
169
|
+
return value.length > 0 ? value : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
156
172
|
export async function variableNames(cwd: string): Promise<Set<string>> {
|
|
157
173
|
const gh = findGh();
|
|
158
174
|
if (!gh) return new Set();
|
package/src/deploy/index.ts
CHANGED
|
@@ -66,11 +66,14 @@ export async function preflightCommand(args: string[]): Promise<number> {
|
|
|
66
66
|
printPreflight(project, result, source);
|
|
67
67
|
if (source !== "repository") return result.ready ? 0 : 1;
|
|
68
68
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
69
|
+
// Is there a migration that was generated and never committed?
|
|
70
|
+
//
|
|
71
|
+
// This one has teeth only LOCALLY. A CI checkout is by definition clean,
|
|
72
|
+
// so `git status` there reports nothing and this passes vacuously — the
|
|
73
|
+
// job that actually catches it in CI is the one that runs AFTER `vc dist`
|
|
74
|
+
// regenerates, because that is the moment a new migration can appear.
|
|
75
|
+
// Both are kept: they catch the same mistake at the two different moments
|
|
76
|
+
// it can be made.
|
|
74
77
|
const pending = await uncommittedMigrations(project.root);
|
|
75
78
|
if (pending.length > 0) {
|
|
76
79
|
console.error(
|
|
@@ -198,10 +201,15 @@ export async function keysHelp(): Promise<number> {
|
|
|
198
201
|
line("A contributor with only a clone can rotate the Stripe key and cannot read", width),
|
|
199
202
|
line("the one already there. Only the deploy decrypts.", width),
|
|
200
203
|
line("", width),
|
|
201
|
-
line("
|
|
202
|
-
line("
|
|
203
|
-
line("
|
|
204
|
-
line("
|
|
204
|
+
line("The key is a repository VARIABLE, not a secret. GitHub secrets are", width),
|
|
205
|
+
line("write-only — `gh secret` has no read command — so a key kept there is", width),
|
|
206
|
+
line("gone the moment your local copy is, and every encrypted value with it.", width),
|
|
207
|
+
line("As a variable it can be read back, which is what makes a new machine or", width),
|
|
208
|
+
line("a second person possible: `vc keys --restore`.", width),
|
|
209
|
+
line("", width),
|
|
210
|
+
line("So anyone who can READ the repository can decrypt its secrets. Keep it", width),
|
|
211
|
+
line("private. That access list is also how you share: a collaborator can", width),
|
|
212
|
+
line("restore the key, and removing them removes their access.", width),
|
|
205
213
|
], width),
|
|
206
214
|
);
|
|
207
215
|
return 0;
|
package/src/deploy/keys.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import color from "picocolors";
|
|
4
4
|
import type { Project } from "../project";
|
|
5
|
-
import { ghAuth, repoSlug,
|
|
5
|
+
import { getVariable, ghAuth, repoSlug, setVariable, variableNames } from "./github";
|
|
6
6
|
import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, findDotenvx, run } from "./secrets";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -30,19 +30,30 @@ import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, findDotenvx, run
|
|
|
30
30
|
*
|
|
31
31
|
* So the key is generated at random and handed to GitHub, which is where
|
|
32
32
|
* the deploy runs and the one place a person who can push is already
|
|
33
|
-
* authenticated.
|
|
34
|
-
* stdin, GitHub encrypts it, and no API can read it back — which is the
|
|
35
|
-
* property that makes it a good home and, unavoidably, the property that
|
|
36
|
-
* makes rotation a re-key rather than a re-encrypt.
|
|
33
|
+
* authenticated.
|
|
37
34
|
*
|
|
38
|
-
* ──
|
|
35
|
+
* ── Why it is a VARIABLE and not a secret ────────────────────────────────
|
|
39
36
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
37
|
+
* `gh secret` has list, set and delete and NO read: a GitHub secret is
|
|
38
|
+
* write-only forever, even to the person who set it. Keeping the key there
|
|
39
|
+
* built a one-way door — once the local `.env.keys` was deleted the key
|
|
40
|
+
* existed nowhere a human could reach, so nobody could ever decrypt again,
|
|
41
|
+
* including the owner of the shop. CI could deploy, and that was the whole
|
|
42
|
+
* of it. No reading a value back to check it, no re-keying, no second
|
|
43
|
+
* machine, no colleague.
|
|
44
|
+
*
|
|
45
|
+
* A repository VARIABLE can be read back (`gh variable get`), so the key is
|
|
46
|
+
* recoverable by anyone with access to the repository — the owner, plus
|
|
47
|
+
* whoever they invite. That access list IS the answer to "a key several
|
|
48
|
+
* people can share": adding a colleague is adding a collaborator, and
|
|
49
|
+
* removing their access removes their ability to decrypt.
|
|
50
|
+
*
|
|
51
|
+
* The cost, stated plainly. A variable is shown in the repository's settings
|
|
52
|
+
* in the clear, and unlike a secret it is NOT masked in workflow logs
|
|
53
|
+
* automatically. So the generated workflow masks it with `::add-mask::` the
|
|
54
|
+
* moment it reads it, and the repository must be private. This is the right
|
|
55
|
+
* trade only because the alternative — a key nobody can ever read — is not a
|
|
56
|
+
* working system, it is a shop with one deploy left in it.
|
|
46
57
|
*/
|
|
47
58
|
|
|
48
59
|
/**
|
|
@@ -108,7 +119,7 @@ export interface KeyState {
|
|
|
108
119
|
export async function keyState(project: Project): Promise<KeyState> {
|
|
109
120
|
const publicKey = committedPublicKey(project.root);
|
|
110
121
|
const slug = await repoSlug(project.root);
|
|
111
|
-
const names = slug ? await
|
|
122
|
+
const names = slug ? await variableNames(project.root) : new Set<string>();
|
|
112
123
|
const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
|
|
113
124
|
|
|
114
125
|
let mismatch: string | undefined;
|
|
@@ -144,8 +155,8 @@ export async function provisionKey(project: Project): Promise<{ ok: true; public
|
|
|
144
155
|
const slug = auth.ok ? await repoSlug(project.root) : null;
|
|
145
156
|
|
|
146
157
|
if (slug) {
|
|
147
|
-
const sent = await
|
|
148
|
-
if (!sent.ok) return { ok: false, reason: `GitHub refused the
|
|
158
|
+
const sent = await setVariable(project.root, PRIVATE_KEY_VAR, pair.privateKey);
|
|
159
|
+
if (!sent.ok) return { ok: false, reason: `GitHub refused the variable: ${sent.error ?? "unknown error"}` };
|
|
149
160
|
return { ok: true, publicKey: pair.publicKey, parked: false };
|
|
150
161
|
}
|
|
151
162
|
|
|
@@ -175,10 +186,69 @@ export function ignoresKeyFile(root: string): boolean {
|
|
|
175
186
|
.some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Bring the key back onto a machine that does not have it.
|
|
191
|
+
*
|
|
192
|
+
* This is the command that makes the whole arrangement survivable. A new
|
|
193
|
+
* laptop, a colleague joining, or simply wanting to read a value back: all
|
|
194
|
+
* of them were impossible while the key lived somewhere write-only. Here it
|
|
195
|
+
* is one command, and the only credential involved is being logged into
|
|
196
|
+
* `gh` as somebody the repository lets in.
|
|
197
|
+
*/
|
|
198
|
+
async function restoreKey(project: Project): Promise<number> {
|
|
199
|
+
const auth = await ghAuth(project.root);
|
|
200
|
+
if (!auth.ok) {
|
|
201
|
+
console.error(`\nvc: ${auth.reason}\n`);
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
const slug = await repoSlug(project.root);
|
|
205
|
+
if (!slug) {
|
|
206
|
+
console.error("\nvc: this checkout has no GitHub repository, so there is nowhere to restore from.\n");
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const key = await getVariable(project.root, PRIVATE_KEY_VAR);
|
|
211
|
+
if (!key) {
|
|
212
|
+
console.error(
|
|
213
|
+
`\nvc: ${slug} has no ${PRIVATE_KEY_VAR} variable.\n` +
|
|
214
|
+
` Either this shop was never linked, or the key is held somewhere else.\n` +
|
|
215
|
+
` \`vc keys\` shows what this repository has.\n`,
|
|
216
|
+
);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check it opens THIS repository before writing it down. A key that
|
|
221
|
+
// belongs to another shop would decrypt nothing and confuse everything.
|
|
222
|
+
const committed = committedPublicKey(project.root);
|
|
223
|
+
const derived = await publicKeyFor(key);
|
|
224
|
+
if (committed && derived && derived.toLowerCase() !== committed.toLowerCase()) {
|
|
225
|
+
console.error(
|
|
226
|
+
`\nvc: the key on ${slug} does not open this repository.\n` +
|
|
227
|
+
` it derives: ${derived.slice(0, 16)}…\n` +
|
|
228
|
+
` ${SECRETS_FILE} needs: ${committed.slice(0, 16)}…\n\n` +
|
|
229
|
+
" Nothing was written. The repository has been re-keyed since that\n" +
|
|
230
|
+
" variable was set, or the two belong to different shops.\n",
|
|
231
|
+
);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!ignoresKeyFile(project.root)) {
|
|
236
|
+
console.error(`\nvc: ${LOCAL_KEY_FILE} is not gitignored here, and this would write the key into it.\n Add it to .gitignore first.\n`);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
writeFileSync(join(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${key}"\n`, { mode: 0o600 });
|
|
240
|
+
console.log(
|
|
241
|
+
`\n${color.green("✓")} key restored from ${slug} into ${LOCAL_KEY_FILE}\n` +
|
|
242
|
+
color.dim(` It opens ${SECRETS_FILE}. The file is gitignored; delete it when you are done.\n`),
|
|
243
|
+
);
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
178
247
|
/** `vc keys` — where the key is, and what is missing. */
|
|
179
248
|
export async function keysCommand(project: Project, args: string[]): Promise<number> {
|
|
180
249
|
if (args.includes("--rotate")) return rotate(project);
|
|
181
250
|
if (args.includes("--init")) return initKey(project);
|
|
251
|
+
if (args.includes("--restore")) return restoreKey(project);
|
|
182
252
|
|
|
183
253
|
const state = await keyState(project);
|
|
184
254
|
console.log(`\nSecrets are encrypted with a public key that is COMMITTED, and read with a`);
|
package/src/deploy/link.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import color from "picocolors";
|
|
4
4
|
import { writeManifest } from "../manifest";
|
|
5
5
|
import type { Project } from "../project";
|
|
6
|
-
import { cloudflareAccounts, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
|
|
6
|
+
import { cloudflareAccounts, getVariable, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
|
|
7
7
|
import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
|
|
8
8
|
import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
|
|
9
9
|
|
|
@@ -91,17 +91,31 @@ export async function linkCommand(project: Project, args: string[]): Promise<num
|
|
|
91
91
|
// ADOPTED, not replaced: it may already have encrypted the whole shop.
|
|
92
92
|
const parked = localPrivateKey(root);
|
|
93
93
|
if (parked) {
|
|
94
|
-
const sent = await
|
|
94
|
+
const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
|
|
95
95
|
if (!sent.ok) {
|
|
96
|
-
p.cancel(`GitHub refused the
|
|
96
|
+
p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
|
|
97
97
|
return 1;
|
|
98
98
|
}
|
|
99
99
|
const publicKey = await publicKeyFor(parked);
|
|
100
100
|
if (publicKey) committedPublicKeyInto(root, publicKey);
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
|
|
102
|
+
// READ IT BACK before deleting the only other copy. "The write
|
|
103
|
+
// returned success" is not the same claim as "the key can be
|
|
104
|
+
// recovered", and it is the second one this file is about to bet
|
|
105
|
+
// the shop on. If it cannot be read, the local copy stays.
|
|
106
|
+
const readBack = await getVariable(root, PRIVATE_KEY_VAR);
|
|
107
|
+
if (readBack !== parked) {
|
|
108
|
+
p.log.warn(
|
|
109
|
+
`${color.yellow("!")} ${PRIVATE_KEY_VAR} was written to ${slug} but could not be read back,\n` +
|
|
110
|
+
` so ${LOCAL_KEY_FILE} has been LEFT IN PLACE. It is currently the only copy\n` +
|
|
111
|
+
" of the key that opens this shop — do not delete it until `vc keys --restore` works.",
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
rmSync(join(root, LOCAL_KEY_FILE), { force: true });
|
|
115
|
+
p.log.success(
|
|
116
|
+
`${color.green("✓")} the key moved to ${slug} and ${LOCAL_KEY_FILE} deleted — verified readable, so \`vc keys --restore\` can bring it back`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
105
119
|
} else {
|
|
106
120
|
const made = await provisionKey(project);
|
|
107
121
|
if (!made.ok) {
|
package/src/generate/ci.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function renderDistWorkflow(manifest: Manifest): string {
|
|
|
50
50
|
# The credentials come from the repository, set once by \`vc link\`:
|
|
51
51
|
#
|
|
52
52
|
# secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
|
|
53
|
-
#
|
|
53
|
+
# vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
|
|
54
54
|
# vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
|
|
55
55
|
#
|
|
56
56
|
# Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
|
|
@@ -147,16 +147,25 @@ jobs:
|
|
|
147
147
|
env:
|
|
148
148
|
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
149
149
|
CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
150
|
-
${PRIVATE_KEY_VAR}: \${{
|
|
150
|
+
${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
|
|
151
151
|
steps:
|
|
152
152
|
- uses: actions/checkout@v6
|
|
153
153
|
- uses: oven-sh/setup-bun@v2
|
|
154
154
|
|
|
155
155
|
# Checked before anything is built, so a repository that was never
|
|
156
156
|
# linked says so in five seconds rather than four minutes.
|
|
157
|
+
#
|
|
158
|
+
# And ${PRIVATE_KEY_VAR} is masked here, first thing. It is a
|
|
159
|
+
# VARIABLE rather than a secret so that a human can read it back —
|
|
160
|
+
# that is what makes a new laptop or a second person possible — but
|
|
161
|
+
# GitHub only masks SECRETS in logs automatically. Registering it with
|
|
162
|
+
# ::add-mask:: buys back the redaction without giving up recovery.
|
|
157
163
|
- name: Are the credentials here?
|
|
158
164
|
run: |
|
|
159
165
|
set -euo pipefail
|
|
166
|
+
if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
|
|
167
|
+
echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
|
|
168
|
+
fi
|
|
160
169
|
missing=""
|
|
161
170
|
[ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
|
|
162
171
|
[ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
|
|
@@ -230,7 +239,7 @@ vc link
|
|
|
230
239
|
|
|
231
240
|
| what | where | why |
|
|
232
241
|
|---|---|---|
|
|
233
|
-
| \`${PRIVATE_KEY_VAR}\` | repository **
|
|
242
|
+
| \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
|
|
234
243
|
| \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
|
|
235
244
|
| \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
|
|
236
245
|
|
|
@@ -278,6 +287,24 @@ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
|
|
|
278
287
|
vc secrets # what is set, what is missing
|
|
279
288
|
\`\`\`
|
|
280
289
|
|
|
290
|
+
### Getting the key back
|
|
291
|
+
|
|
292
|
+
\`\`\`sh
|
|
293
|
+
vc keys --restore
|
|
294
|
+
\`\`\`
|
|
295
|
+
|
|
296
|
+
\`${PRIVATE_KEY_VAR}\` is a repository **variable**, not a secret. GitHub
|
|
297
|
+
secrets are write-only — \`gh secret\` has no read command — so a key kept
|
|
298
|
+
there is gone the moment your local copy is, and with it every value in
|
|
299
|
+
\`${SECRETS_FILE}\`. As a variable it can be read back, which is what makes a
|
|
300
|
+
new laptop, a rebuild, or a second person possible at all.
|
|
301
|
+
|
|
302
|
+
**That means anyone who can read this repository can decrypt its secrets.**
|
|
303
|
+
Keep it private. It is also the answer to sharing: a colleague who is a
|
|
304
|
+
collaborator can run \`vc keys --restore\` and work; removing them removes
|
|
305
|
+
their access. The deploy workflow masks the value with \`::add-mask::\` the
|
|
306
|
+
moment it reads it, because GitHub does not mask variables on its own.
|
|
307
|
+
|
|
281
308
|
**This needs no credential.** dotenvx is asymmetric: encryption uses the public
|
|
282
309
|
key committed at the top of the file, so anyone with a clone can set or rotate
|
|
283
310
|
a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
|
|
@@ -302,7 +329,7 @@ The same thing, from a checkout, if CI is ever not the answer:
|
|
|
302
329
|
|
|
303
330
|
\`\`\`sh
|
|
304
331
|
export CLOUDFLARE_API_TOKEN=…
|
|
305
|
-
export ${PRIVATE_KEY_VAR}=… #
|
|
332
|
+
export ${PRIVATE_KEY_VAR}=… # or run \`vc keys --restore\` first
|
|
306
333
|
vc deploy --cloudflare --provision
|
|
307
334
|
\`\`\`
|
|
308
335
|
|