vibecarbon 0.9.0 → 0.10.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.
Files changed (84) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +78 -0
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +63 -20
  60. package/src/lib/deploy/image.js +19 -23
  61. package/src/lib/deploy/k8s/ha/index.js +397 -150
  62. package/src/lib/deploy/k8s/k3s.js +65 -14
  63. package/src/lib/deploy/orchestrator.js +176 -6
  64. package/src/lib/deploy/remote-build.js +8 -1
  65. package/src/lib/deploy/replication.js +540 -0
  66. package/src/lib/deploy/state.js +62 -5
  67. package/src/lib/deploy/utils.js +30 -2
  68. package/src/lib/deploy/wireguard.js +146 -0
  69. package/src/lib/host-keys.js +120 -5
  70. package/src/lib/iac/index.js +57 -23
  71. package/src/lib/licensing/gate.js +59 -0
  72. package/src/lib/licensing/index.js +1 -1
  73. package/src/lib/licensing/tiers.js +6 -5
  74. package/src/lib/prod-confirm.js +65 -0
  75. package/src/lib/providers/hetzner-s3.js +85 -0
  76. package/src/lib/ssh.js +35 -35
  77. package/src/restore.js +102 -14
  78. package/src/scale.js +4 -2
  79. package/src/status.js +122 -0
  80. package/src/upgrade.js +0 -4
  81. package/carbon/ha/activate-standby.sh +0 -52
  82. package/carbon/ha/standby-init.sh +0 -74
  83. package/carbon/src/server/lib/request.ts +0 -34
  84. package/src/lib/pod-backups.js +0 -53
@@ -16,9 +16,35 @@ import {
16
16
  import { tmpdir } from 'node:os';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { runCommand, runCommandAsync } from '../command.js';
19
+ import { knownHostsPathForKey } from '../host-keys.js';
19
20
  import { getProviderClass } from '../providers/index.js';
20
21
  import { escapeDotenv } from '../shell.js';
21
22
 
23
+ /**
24
+ * Build the SSH `-o` options string for compose provisioning, pinned to the
25
+ * per-env known_hosts file derived from the deploy key. NEVER disables host-key
26
+ * verification (no `UserKnownHostsFile=/dev/null` + `StrictHostKeyChecking=no`).
27
+ *
28
+ * `accept-new` pins the host key on first connect (provisioning is trust-on-
29
+ * first-use for ephemeral cloud VMs) then strict-checks on reuse;
30
+ * `GlobalKnownHostsFile=/dev/null` ignores the system-wide file so a recycled
31
+ * Hetzner IP with a stale entry doesn't spuriously reject. Callers that need a
32
+ * stronger pin should `seedKnownHosts()` the IP first (compose/ha.js does).
33
+ *
34
+ * Paths are UNQUOTED on purpose: callers `.split(' ')` this string and spread it
35
+ * into a spawn() argv (no shell), so each token is passed literally — wrapping
36
+ * the path in quotes would make ssh look for a file literally named `"…"` and
37
+ * silently ignore the key/known_hosts. `.vibecarbon/` paths under the project
38
+ * root contain no spaces.
39
+ *
40
+ * @param {string} sshKeyPath
41
+ * @returns {string} space-joined ssh/scp option string
42
+ */
43
+ export function pinnedSshOptsString(sshKeyPath) {
44
+ const khPath = knownHostsPathForKey(sshKeyPath);
45
+ return `-i ${sshKeyPath} -o UserKnownHostsFile=${khPath} -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new -o BatchMode=yes`;
46
+ }
47
+
22
48
  // Deployment Defaults
23
49
  // Cluster-autoscaler is enabled by default with a static floor of 1 and a
24
50
  // bounded ceiling of 3 (CA pool spawns 0..(max-min) workers on top of the
@@ -173,7 +199,8 @@ export function readReplPassword(cwd = process.cwd()) {
173
199
  * @returns {Promise<boolean>}
174
200
  */
175
201
  export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
176
- const sshOpts = `-i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes`;
202
+ // shell-safety-ignore: pinnedSshOptsString() bakes in BatchMode=yes (validated in its definition)
203
+ const sshOpts = pinnedSshOptsString(sshKeyPath);
177
204
  for (let i = 0; i < maxAttempts; i++) {
178
205
  try {
179
206
  // silent:true → runCommandAsync rejects on failure so the retry loop
@@ -204,7 +231,8 @@ export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
204
231
  export async function setupServerFiles(host, sshKeyPath, projectName, options = {}) {
205
232
  const cwd = process.cwd();
206
233
  const remoteDir = `/opt/${projectName}`;
207
- const sshOpts = `-i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes`;
234
+ // shell-safety-ignore: pinnedSshOptsString() bakes in BatchMode=yes (validated in its definition)
235
+ const sshOpts = pinnedSshOptsString(sshKeyPath);
208
236
 
209
237
  // Create remote directory and required subdirectories
210
238
  await runCommandAsync(
@@ -0,0 +1,146 @@
1
+ /**
2
+ * WireGuard config builders for the primary↔standby replication tunnel.
3
+ *
4
+ * Replication used to run over the public internet with app-layer TLS
5
+ * (verify-ca). That transport is being replaced by a point-to-point WireGuard
6
+ * tunnel between the two clusters' gateways: the wire is encrypted by
7
+ * WireGuard itself, so Postgres traffic inside the tunnel runs plaintext
8
+ * (see replication.js — sslmode=disable, plain `host` pg_hba lines).
9
+ *
10
+ * WG_PORT is deliberately NOT 51820 — in k3s that port belongs to
11
+ * flannel's built-in wireguard backend (flannel-wg), so a distinct port is
12
+ * required to avoid colliding with cluster networking.
13
+ */
14
+
15
+ import { sshRun } from '../ssh.js';
16
+
17
+ export const WG_PORT = 51821; // NOT 51820 — flannel-wg owns that in k3s
18
+ export const WG_SUBNET_CIDR = '10.99.0.0/30';
19
+ export const WG_PRIMARY_IP = '10.99.0.1';
20
+ export const WG_STANDBY_IP = '10.99.0.2';
21
+
22
+ // The TCP port the repl-gateway socat relay listens on (both clusters). The
23
+ // standby db pod dials <local-node-private-ip>:REPL_GATEWAY_PORT; the standby
24
+ // gateway relays that into the tunnel to the primary gateway, which relays to
25
+ // the primary's postgres at 127.0.0.1:5433 (the db StatefulSet hostPort).
26
+ // Arbitrary — chosen distinct from postgres 5432/5433 so it never collides on
27
+ // the host.
28
+ export const REPL_GATEWAY_PORT = 15433;
29
+
30
+ // The on-node WireGuard private key. Generated on each supabase node with
31
+ // umask 077 and NEVER read back into this process — only the derived public
32
+ // key ever crosses the wire.
33
+ const WG_KEY_PATH = '/etc/vibecarbon-wg.key';
34
+
35
+ const WG_PUBKEY_RE = /^[A-Za-z0-9+/]{43}=$/; // base64 Curve25519 public key
36
+ const ENDPOINT_RE = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/;
37
+ const ALLOWED_IP_RE = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
38
+
39
+ /**
40
+ * Argv-safe `wg`/`ip` command sequence bringing up the point-to-point wg0
41
+ * tunnel. Interpolated values are validated (they reach a remote shell), so a
42
+ * malformed peer key/endpoint throws rather than injecting.
43
+ */
44
+ export function buildWireguardInterfaceConfig({
45
+ selfIp,
46
+ selfPrivKeyPath,
47
+ listenPort,
48
+ peerPubKey,
49
+ peerEndpoint,
50
+ peerAllowedIp,
51
+ keepalive = 25,
52
+ }) {
53
+ if (!WG_PUBKEY_RE.test(peerPubKey || '')) {
54
+ throw new Error(`wireguard: invalid peer pubkey '${peerPubKey}'`);
55
+ }
56
+ if (!ENDPOINT_RE.test(peerEndpoint || '')) {
57
+ throw new Error(`wireguard: invalid peer endpoint '${peerEndpoint}'`);
58
+ }
59
+ if (!ALLOWED_IP_RE.test(peerAllowedIp || '')) {
60
+ throw new Error(`wireguard: invalid peer allowed-ip '${peerAllowedIp}'`);
61
+ }
62
+ return [
63
+ 'ip link del wg0 2>/dev/null; true',
64
+ 'ip link add wg0 type wireguard',
65
+ `ip addr add ${selfIp}/30 dev wg0`,
66
+ `wg set wg0 private-key ${selfPrivKeyPath} listen-port ${listenPort} ` +
67
+ `peer ${peerPubKey} allowed-ips ${peerAllowedIp} endpoint ${peerEndpoint} ` +
68
+ `persistent-keepalive ${keepalive}`,
69
+ 'ip link set wg0 up',
70
+ ];
71
+ }
72
+
73
+ /**
74
+ * Generate a WireGuard keypair on each supabase node, cross-distribute the
75
+ * PUBLIC keys, and bring up the point-to-point wg0 tunnel between the two
76
+ * clusters' supabase nodes.
77
+ *
78
+ * Key-exchange security contract:
79
+ * - The private key is generated ON the node (`wg genkey`, umask 077) into
80
+ * WG_KEY_PATH and NEVER read back into this process. `wg set` reads it from
81
+ * the file (`private-key <path>`), so the secret never transits argv, never
82
+ * gets logged, and is never returned.
83
+ * - Only the derived public key (`wg pubkey`) crosses the wire, and each node
84
+ * is configured with the PEER's public key + the peer's `<public-ip>:WG_PORT`
85
+ * endpoint.
86
+ *
87
+ * `primaryIp` / `standbyIp` are the two supabase nodes' PUBLIC IPv4 addresses —
88
+ * they are BOTH the WireGuard endpoints (UDP WG_PORT) AND the SSH targets. All
89
+ * SSH runs go through `sshRun`, which pins host keys and passes
90
+ * `-o BatchMode=yes`.
91
+ *
92
+ * @param {object} o
93
+ * @param {string} o.primaryIp - primary supabase node public IP (SSH + WG endpoint)
94
+ * @param {string} o.standbyIp - standby supabase node public IP (SSH + WG endpoint)
95
+ * @param {string} o.sshKeyPath - path to the shared HA SSH private key
96
+ * @returns {Promise<{primaryPubKey: string, standbyPubKey: string}>}
97
+ */
98
+ export async function exchangeAndBringUpTunnel({ primaryIp, standbyIp, sshKeyPath }) {
99
+ if (!primaryIp || !standbyIp || !sshKeyPath) {
100
+ throw new Error('exchangeAndBringUpTunnel requires primaryIp, standbyIp, and sshKeyPath');
101
+ }
102
+
103
+ // 1. Ensure wireguard-tools is installed and a private key exists on each
104
+ // node. Idempotent: the install is skipped when `wg` is already present,
105
+ // and the key is generated only if absent (so a re-deploy keeps the same
106
+ // keypair and does not churn the tunnel).
107
+ for (const ip of [primaryIp, standbyIp]) {
108
+ await sshRun(ip, sshKeyPath, [
109
+ 'bash',
110
+ '-c',
111
+ 'command -v wg >/dev/null 2>&1 || { apt-get update -qq && ' +
112
+ 'DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard-tools; }; ' +
113
+ `umask 077; [ -f ${WG_KEY_PATH} ] || wg genkey > ${WG_KEY_PATH}`,
114
+ ]);
115
+ }
116
+
117
+ // 2. Read back ONLY the public key from each node (private key stays on-node).
118
+ const primaryPubKey = (
119
+ await sshRun(primaryIp, sshKeyPath, ['bash', '-c', `wg pubkey < ${WG_KEY_PATH}`])
120
+ ).trim();
121
+ const standbyPubKey = (
122
+ await sshRun(standbyIp, sshKeyPath, ['bash', '-c', `wg pubkey < ${WG_KEY_PATH}`])
123
+ ).trim();
124
+
125
+ // 3. Cross-configure + bring up each node with the PEER's pubkey + endpoint.
126
+ const primaryUp = buildWireguardInterfaceConfig({
127
+ selfIp: WG_PRIMARY_IP,
128
+ selfPrivKeyPath: WG_KEY_PATH,
129
+ listenPort: WG_PORT,
130
+ peerPubKey: standbyPubKey,
131
+ peerEndpoint: `${standbyIp}:${WG_PORT}`,
132
+ peerAllowedIp: `${WG_STANDBY_IP}/32`,
133
+ });
134
+ const standbyUp = buildWireguardInterfaceConfig({
135
+ selfIp: WG_STANDBY_IP,
136
+ selfPrivKeyPath: WG_KEY_PATH,
137
+ listenPort: WG_PORT,
138
+ peerPubKey: primaryPubKey,
139
+ peerEndpoint: `${primaryIp}:${WG_PORT}`,
140
+ peerAllowedIp: `${WG_PRIMARY_IP}/32`,
141
+ });
142
+ await sshRun(primaryIp, sshKeyPath, ['bash', '-c', primaryUp.join(' && ')]);
143
+ await sshRun(standbyIp, sshKeyPath, ['bash', '-c', standbyUp.join(' && ')]);
144
+
145
+ return { primaryPubKey, standbyPubKey };
146
+ }
@@ -1,5 +1,6 @@
1
- import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { runCommandAsync } from './command.js';
3
4
 
4
5
  /**
5
6
  * Absolute path to the per-environment known_hosts file for SSH strict-checking.
@@ -29,9 +30,123 @@ export function knownHostsPath(env, cwd = process.cwd()) {
29
30
  */
30
31
  export function pinHostKey(env, hostKeyLines, cwd = process.cwd()) {
31
32
  const path = knownHostsPath(env, cwd);
32
- const dir = dirname(path);
33
+ writeKnownHosts(path, hostKeyLines);
34
+ }
35
+
36
+ /**
37
+ * Low-level writer shared by pinHostKey / seedKnownHosts: overwrite `khPath`
38
+ * with the given lines, enforcing `.vibecarbon/` 0o700 and file 0o600 even
39
+ * when the targets already exist (mkdir/writeFile only apply `mode` on create).
40
+ *
41
+ * @param {string} khPath
42
+ * @param {string[]} hostKeyLines
43
+ */
44
+ function writeKnownHosts(khPath, hostKeyLines) {
45
+ const dir = dirname(khPath);
33
46
  mkdirSync(dir, { recursive: true, mode: 0o700 });
34
47
  chmodSync(dir, 0o700);
35
- writeFileSync(path, `${hostKeyLines.join('\n')}\n`, { mode: 0o600 });
36
- chmodSync(path, 0o600);
48
+ writeFileSync(khPath, `${hostKeyLines.join('\n')}\n`, { mode: 0o600 });
49
+ chmodSync(khPath, 0o600);
50
+ }
51
+
52
+ /**
53
+ * Derive the per-environment known_hosts path from an SSH key path.
54
+ *
55
+ * Every SSH/SCP caller in the codebase already threads the deploy key path
56
+ * (`.vibecarbon/deploy_key_<env>` or `.vibecarbon/ssh-<env>`). Placing
57
+ * `known_hosts_<env>` beside it lets every caller pin host keys per-env
58
+ * WITHOUT having to thread a separate `env` argument through helpers that
59
+ * only receive `(ip, sshKeyPath)`. Self-consistent: the same key path always
60
+ * derives the same known_hosts file, so seeding and verification agree.
61
+ *
62
+ * @param {string} sshKeyPath
63
+ * @returns {string}
64
+ */
65
+ export function knownHostsPathForKey(sshKeyPath) {
66
+ const dir = dirname(sshKeyPath);
67
+ const env = basename(sshKeyPath)
68
+ .replace(/^deploy_key_/, '')
69
+ .replace(/^ssh-/, '');
70
+ return join(dir, `known_hosts_${env}`);
71
+ }
72
+
73
+ /**
74
+ * Does a known_hosts line's host field reference `ip`? The first
75
+ * whitespace-delimited field is `host[,host2,...]` (we scan un-hashed, so it
76
+ * is the literal IP, not a `|1|…` hash).
77
+ */
78
+ function hostLineMatchesIp(line, ip) {
79
+ const host = line.split(/\s+/)[0] || '';
80
+ return host.split(',').includes(ip);
81
+ }
82
+
83
+ async function defaultKeyscan(ip) {
84
+ // argv form via runCommandAsync (spawn, no shell). `-T 10` bounds the
85
+ // connect; un-hashed output (no `-H`) so seedKnownHosts can strip a stale
86
+ // line for a recycled IP by exact-match. Best-effort: a freshly-booted VPS
87
+ // may not answer yet, so we ignore errors and let the retry loop poll.
88
+ const out = await runCommandAsync(['ssh-keyscan', '-T', '10', ip], {
89
+ silent: true,
90
+ returnOutput: true,
91
+ timeout: 30_000,
92
+ ignoreError: true,
93
+ });
94
+ return typeof out === 'string' ? out : '';
95
+ }
96
+
97
+ /**
98
+ * Seed (or re-seed) the per-env known_hosts file at `khPath` with the target
99
+ * server's real host keys, captured via `ssh-keyscan`. This is the "trusted
100
+ * source" for the pin, run once per env on fresh (re)provision.
101
+ *
102
+ * SECURITY — chosen semantics (documented so the trade-off is auditable):
103
+ * - Merge, don't clobber: existing lines for OTHER hosts are preserved (HA
104
+ * keeps primary + standby in one per-env file), but any stale line for
105
+ * THIS ip is dropped before the fresh scan is appended. That re-pin is why
106
+ * a Hetzner-recycled IP (destroy → redeploy, same IP, new host key) does
107
+ * NOT spuriously hard-fail: provisioning replaces the stale pin.
108
+ * - Only provisioning re-seeds. Every non-provision command (backup, restore,
109
+ * scale, status, warm deploy) strict-checks against the pinned file and
110
+ * NEVER re-seeds, so a MITM against an already-established env fails.
111
+ * - ssh-keyscan itself is trust-on-first-provision (it accepts whatever key
112
+ * the freshly-created VPS presents). This is the pragmatic trust anchor for
113
+ * ephemeral cloud VMs with no out-of-band key channel.
114
+ *
115
+ * Returns true if a scan landed and the file was written; false if the scan
116
+ * came back empty (caller's SSH opts still `accept-new`-TOFU on first connect).
117
+ *
118
+ * @param {string} khPath - per-env known_hosts path (see knownHostsPathForKey)
119
+ * @param {string} ip
120
+ * @param {object} [opts]
121
+ * @param {number} [opts.attempts=5]
122
+ * @param {number} [opts.delayMs=2000]
123
+ * @param {(ip: string) => Promise<string>} [opts.keyscan] - injectable for tests
124
+ * @param {(ms: number) => Promise<void>} [opts.sleep]
125
+ * @returns {Promise<boolean>}
126
+ */
127
+ export async function seedKnownHosts(khPath, ip, opts = {}) {
128
+ const {
129
+ attempts = 5,
130
+ delayMs = 2000,
131
+ keyscan = defaultKeyscan,
132
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
133
+ } = opts;
134
+
135
+ let scanned = '';
136
+ for (let i = 0; i < attempts; i++) {
137
+ scanned = (await keyscan(ip)).trim();
138
+ if (scanned) break;
139
+ if (i < attempts - 1) await sleep(delayMs);
140
+ }
141
+ if (!scanned) return false;
142
+
143
+ let kept = [];
144
+ if (existsSync(khPath)) {
145
+ kept = readFileSync(khPath, 'utf-8')
146
+ .split('\n')
147
+ .filter((line) => line.trim() && !hostLineMatchesIp(line, ip));
148
+ }
149
+ const merged = [...kept, ...scanned.split('\n').filter((l) => l.trim())];
150
+ writeKnownHosts(khPath, merged);
151
+ return true;
37
152
  }
@@ -39,15 +39,23 @@ mkdirSync(LOCAL_STATE_DIR, { recursive: true });
39
39
  *
40
40
  * Hetzner S3 uses path-style URLs via the Hetzner-provided endpoint, so we
41
41
  * pass both `endpoint` and `s3ForcePathStyle=true` in the query string.
42
+ *
43
+ * State lives in a DEDICATED bucket (`s3Config.stateBucket`), separate from
44
+ * the app storage bucket, so `destroy` deleting the app bucket can never yank
45
+ * the Pulumi backend out from under an in-flight destroy. The `?? bucket`
46
+ * fallback preserves behavior for any un-migrated path (configs persisted
47
+ * before the dedicated-state-bucket change, whose state still lives in the app
48
+ * bucket) — the deploy path always sets `stateBucket`.
42
49
  */
43
50
  export function resolveBackendUrl(s3Config) {
44
51
  if (process.env.PULUMI_BACKEND_URL) {
45
52
  return process.env.PULUMI_BACKEND_URL;
46
53
  }
47
54
  if (s3Config?.bucket && s3Config?.endpoint) {
55
+ const stateBucket = s3Config.stateBucket ?? s3Config.bucket;
48
56
  const endpointHost = s3Config.endpoint.replace(/^https?:\/\//, '');
49
57
  const region = s3Config.region || 'nbg1';
50
- return `s3://${s3Config.bucket}?endpoint=${endpointHost}&region=${region}&s3ForcePathStyle=true`;
58
+ return `s3://${stateBucket}?endpoint=${endpointHost}&region=${region}&s3ForcePathStyle=true`;
51
59
  }
52
60
  return `file://${LOCAL_STATE_DIR}`;
53
61
  }
@@ -80,20 +88,30 @@ export async function getOrCreateStack(stackName, program, options = {}) {
80
88
  const backendUrl = resolveBackendUrl(options.s3Config);
81
89
  const envVars = buildEnv(options);
82
90
 
83
- const stack = await LocalWorkspace.createOrSelectStack(
84
- {
85
- stackName,
86
- projectName: PULUMI_PROJECT,
87
- program,
88
- },
89
- {
90
- projectSettings: {
91
- name: PULUMI_PROJECT,
92
- runtime: 'nodejs',
93
- backend: { url: backendUrl },
94
- },
95
- envVars,
96
- },
91
+ // createOrSelectStack does a `pulumi stack select` that reads
92
+ // `.pulumi/meta.yaml` from the S3 backend. On a freshly-created dedicated
93
+ // state bucket, Hetzner Object Storage frequently throttles this first read
94
+ // with `SlowDown` / an Unknown blob error, which would kill the deploy before
95
+ // any resource is touched. Wrap it in the same throttle-retry the up/refresh
96
+ // ops use so the initial select rides out the fresh-bucket throttle.
97
+ const stack = await withStateBackendRetry(
98
+ () =>
99
+ LocalWorkspace.createOrSelectStack(
100
+ {
101
+ stackName,
102
+ projectName: PULUMI_PROJECT,
103
+ program,
104
+ },
105
+ {
106
+ projectSettings: {
107
+ name: PULUMI_PROJECT,
108
+ runtime: 'nodejs',
109
+ backend: { url: backendUrl },
110
+ },
111
+ envVars,
112
+ },
113
+ ),
114
+ `stack-select ${stackName}`,
97
115
  );
98
116
 
99
117
  return stack;
@@ -105,10 +123,12 @@ export async function getOrCreateStack(stackName, program, options = {}) {
105
123
  * fails with `could not list bucket: blob (code=NotFound): NoSuchKey`
106
124
  * because the backend hasn't laid down its `.pulumi/meta.yaml` yet. When
107
125
  * `deployK8sHA` launches primary + standby `deployK3s` in parallel against
108
- * the SAME bucket, both threads race the bucket-init and both see the
109
- * NoSuchKey error, killing the deploy in <20s. Calling this once
126
+ * the SAME (dedicated) state bucket, both threads race the bucket-init and
127
+ * both see the NoSuchKey error, killing the deploy in <20s. Calling this once
110
128
  * sequentially before the parallel block writes the backend metadata so
111
- * subsequent stack ops succeed.
129
+ * subsequent stack ops succeed. (Both HA stacks share ONE state bucket, so
130
+ * this parallel-init race persists even with the dedicated bucket — hence we
131
+ * keep the pre-init.)
112
132
  *
113
133
  * Idempotent: if the bucket is already initialized, the no-op stack is
114
134
  * created and removed cleanly. If it fails (e.g., bucket access issue),
@@ -142,8 +162,12 @@ export async function initBackend(options = {}) {
142
162
  // once). It is also a transient any single deploy can hit. These ops are all
143
163
  // idempotent (Pulumi reconciles to desired state), so retrying on a throttle is
144
164
  // safe. Non-throttle errors propagate immediately.
165
+ //
166
+ // "stack is currently locked" is included: a SlowDown-interrupted `up` can leave
167
+ // its own lock behind, so a lock error is retryable — the up wrapper cancels the
168
+ // lock before each attempt (safe: e2e/deploys run one operation per stack).
145
169
  export const STATE_BACKEND_THROTTLE_PATTERN =
146
- /SlowDown|ServiceUnavailable|RequestLimitExceeded|throttl|too many requests|\b503\b/i;
170
+ /SlowDown|ServiceUnavailable|RequestLimitExceeded|throttl|too many requests|\b503\b|currently locked|lock\(s\)/i;
147
171
 
148
172
  export async function withStateBackendRetry(fn, desc, options = {}) {
149
173
  const attempts = options.attempts ?? 5;
@@ -222,10 +246,20 @@ export async function upStack(stackName, program, options = {}) {
222
246
  }
223
247
  }
224
248
 
225
- const result = await withStateBackendRetry(
226
- () => stack.up({ onOutput: options.onOutput, color: 'never' }),
227
- 'up',
228
- );
249
+ const result = await withStateBackendRetry(async () => {
250
+ // A `up` that failed on an S3 `SlowDown` (common on a freshly-created
251
+ // dedicated state bucket, especially under the parallel HA primary+standby
252
+ // deploys) may have already acquired the stack lock before erroring. The
253
+ // retry would then fail with "stack is currently locked". Clear the lock
254
+ // before EACH attempt — cancel() is idempotent and per-stack, so it's safe
255
+ // under the parallel HA deploys (primary/standby are separate stacks).
256
+ try {
257
+ await stack.cancel();
258
+ } catch {
259
+ /* no lock — ignore */
260
+ }
261
+ return stack.up({ onOutput: options.onOutput, color: 'never' });
262
+ }, 'up');
229
263
  // Flatten outputs: { foo: { value: 'bar', secret: false } } → { foo: 'bar' }
230
264
  const outputs = {};
231
265
  for (const [key, { value }] of Object.entries(result.outputs || {})) {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Central license gate — the single pre-dispatch chokepoint for paid commands.
3
+ *
4
+ * cli.js consults COMMAND_GATES before routing to a command module, so a
5
+ * paid command can never ship unguarded: every command registered in
6
+ * KNOWN_COMMANDS must have an explicit classification here (enforced by
7
+ * tests/unit/licensing/command-gates.test.ts).
8
+ *
9
+ * Classifications:
10
+ * 'paid' — requires an active Diamond/Fullerene license. Gated in
11
+ * cli.js after the project guard (assertInProjectDir is the
12
+ * documented first action for project-scoped commands — the
13
+ * canonical "not in a project" refusal must win over the
14
+ * license upsell; see tests/integration/cli/_global/).
15
+ * 'free' — never gated. destroy is deliberately free: teardown is
16
+ * never held hostage to a license.
17
+ * 'internal' — the command gates a sub-flow itself. configure only gates
18
+ * `configure cicd`, which is also reachable via interactive
19
+ * prompt selection, so the gate must live in-flow.
20
+ */
21
+
22
+ export const COMMAND_GATES = {
23
+ create: 'free',
24
+ add: 'free',
25
+ remove: 'free',
26
+ up: 'free',
27
+ down: 'free',
28
+ reset: 'free',
29
+ deploy: 'paid',
30
+ destroy: 'free',
31
+ status: 'free',
32
+ backup: 'paid',
33
+ restore: 'paid',
34
+ failover: 'paid',
35
+ scale: 'paid',
36
+ upgrade: 'paid',
37
+ configure: 'internal',
38
+ activate: 'free',
39
+ deactivate: 'free',
40
+ shell: 'free',
41
+ diagnose: 'free',
42
+ console: 'free',
43
+ access: 'free',
44
+ };
45
+
46
+ /**
47
+ * Whether this invocation must hold a paid license.
48
+ * Help/version invocations are always free — every command handles -h/-v
49
+ * before doing real work, and the upsell must never block reading docs.
50
+ *
51
+ * @param {string} command - The subcommand name
52
+ * @param {string[]} args - The subcommand's argv (without the command itself)
53
+ * @returns {boolean}
54
+ */
55
+ export function shouldGate(command, args = []) {
56
+ if (COMMAND_GATES[command] !== 'paid') return false;
57
+ if (args.includes('-h') || args.includes('-v')) return false;
58
+ return true;
59
+ }
@@ -186,7 +186,7 @@ export function requireLicense(commandName) {
186
186
  ` ${c.bold('Vibecarbon Diamond')} — ${c.success('$149 lifetime')} ${c.dim('— deploy to production, indie & startup use')}`,
187
187
  );
188
188
  console.log(
189
- ` ${c.bold('Vibecarbon Fullerene')} — ${c.success('$499 lifetime')} ${c.dim('— deploy for clients & agencies, commercial use rights')}`,
189
+ ` ${c.bold('Vibecarbon Fullerene')} — ${c.success('$499 lifetime')} ${c.dim('— deploy for clients & agencies, FSL exception')}`,
190
190
  );
191
191
  console.log('');
192
192
  console.log(` ${c.dim('Purchase:')} ${c.info('https://vibecarbon.com/#pricing')}`);
@@ -13,7 +13,7 @@ export const TIERS = {
13
13
  features: ['local-dev', 'all-addons'],
14
14
  providers: [],
15
15
  maxServers: 0,
16
- license: 'MIT',
16
+ license: 'FSL-1.1-MIT',
17
17
  deployFlags: [],
18
18
  // Pricing
19
19
  price: 0,
@@ -25,6 +25,7 @@ export const TIERS = {
25
25
  'Local development',
26
26
  'Full Vibecarbon stack',
27
27
  'All add-ons (observability, n8n, metabase, redis, CI/CD)',
28
+ 'Fair Source — audit, fork, use commercially',
28
29
  'Community support',
29
30
  ],
30
31
  },
@@ -44,7 +45,7 @@ export const TIERS = {
44
45
  ],
45
46
  providers: ['hetzner'],
46
47
  maxServers: Infinity,
47
- license: 'AGPL-Exception',
48
+ license: 'FSL-1.1-MIT',
48
49
  deployFlags: ['--ha', '--k8s'],
49
50
  // Pricing
50
51
  price: 149,
@@ -77,11 +78,11 @@ export const TIERS = {
77
78
  'failover',
78
79
  'advanced-monitoring',
79
80
  'all-addons',
80
- 'commercial-use',
81
+ 'fsl-exception',
81
82
  ],
82
83
  providers: ['hetzner'],
83
84
  maxServers: Infinity,
84
- license: 'AGPL-Exception',
85
+ license: 'FSL-1.1-MIT + FSL Exception',
85
86
  deployFlags: ['--ha', '--k8s'],
86
87
  // Pricing
87
88
  price: 499,
@@ -90,7 +91,7 @@ export const TIERS = {
90
91
  badge: 'Lifetime Access',
91
92
  // Marketing
92
93
  marketingFeatures: [
93
- 'Commercial use rights (AGPL exception)',
94
+ 'FSL exception: embed, white-label, or resell Vibecarbon-powered services',
94
95
  'Deploy for clients & organizations',
95
96
  'All deployment modes (Compose, Compose HA, Kubernetes)',
96
97
  'Full Vibecarbon stack',
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared production type-to-confirm guard.
3
+ *
4
+ * Destructive commands (destroy, restore, failover) must NOT let a `-y` /
5
+ * `--yes` flag silently blow away a production environment. This module is the
6
+ * single source of truth for "which envs are production" and for the
7
+ * type-to-confirm prompt that runs UNCONDITIONALLY (independent of -y) on those
8
+ * envs — matching `destroy`'s long-standing behavior.
9
+ *
10
+ * `src/destroy.js` still carries its own copy of `requiresProdTypeToConfirm`
11
+ * for its bespoke `projectName-envName` slug; new callers (restore, and later
12
+ * failover) should import from here.
13
+ */
14
+
15
+ import * as p from '@clack/prompts';
16
+
17
+ /**
18
+ * Returns true for environment names that require a type-to-confirm prompt even
19
+ * when -y is passed. Currently: `prod` and `production` (case-insensitive).
20
+ * A trailing/leading qualifier (e.g. `prod-backup`, `production-us`) is NOT
21
+ * treated as production — it is a distinct environment.
22
+ *
23
+ * @param {string | null | undefined} envName
24
+ * @returns {boolean}
25
+ */
26
+ export function requiresProdTypeToConfirm(envName) {
27
+ if (!envName) return false;
28
+ return /^(prod|production)$/i.test(envName);
29
+ }
30
+
31
+ /**
32
+ * If `envName` is a production environment, require the operator to type a
33
+ * confirmation string before continuing — even under -y. On cancel or a
34
+ * non-production env with no confirmation needed, behaves correctly:
35
+ * - non-prod env: returns immediately (no prompt).
36
+ * - prod env: prompts; a cancel exits(0); a correct entry returns.
37
+ *
38
+ * Exits the process (code 0) on cancel — callers do not need to handle it.
39
+ *
40
+ * @param {string} envName
41
+ * @param {object} [opts]
42
+ * @param {string} [opts.confirmValue=envName] - the exact string to type
43
+ * @param {string} [opts.actionLabel='this operation'] - verb shown in the prompt (e.g. 'restore')
44
+ * @param {boolean} [opts.yes=false] - whether -y was passed (only affects the warning copy)
45
+ * @returns {Promise<void>}
46
+ */
47
+ export async function confirmProdOrExit(envName, opts = {}) {
48
+ const { confirmValue = envName, actionLabel = 'this operation', yes = false } = opts;
49
+ if (!requiresProdTypeToConfirm(envName)) return;
50
+
51
+ if (yes) {
52
+ p.log.warn(
53
+ `A ${actionLabel} against a production environment still requires type-to-confirm, even with -y.`,
54
+ );
55
+ }
56
+
57
+ const doubleConfirm = await p.text({
58
+ message: `Type "${confirmValue}" to confirm ${actionLabel} of the production environment:`,
59
+ validate: (v) => (v !== confirmValue ? `Please type "${confirmValue}" to confirm` : undefined),
60
+ });
61
+ if (p.isCancel(doubleConfirm)) {
62
+ p.cancel('Operation cancelled.');
63
+ process.exit(0);
64
+ }
65
+ }