vibecarbon 0.8.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 (121) 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/scripts/dev-init.js +6 -2
  12. package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
  13. package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
  14. package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
  15. package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
  16. package/carbon/src/client/components/AppSidebar.tsx +35 -28
  17. package/carbon/src/client/components/CTAFooter.tsx +1 -1
  18. package/carbon/src/client/components/ComparisonSection.tsx +1 -3
  19. package/carbon/src/client/components/ContentPanel.tsx +1 -1
  20. package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
  21. package/carbon/src/client/components/FAQSection.tsx +1 -1
  22. package/carbon/src/client/components/Hero.tsx +13 -1
  23. package/carbon/src/client/components/Logo.tsx +66 -49
  24. package/carbon/src/client/components/Nav.tsx +1 -1
  25. package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
  26. package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
  27. package/carbon/src/client/components/PageHeader.tsx +1 -1
  28. package/carbon/src/client/components/PricingSection.tsx +2 -6
  29. package/carbon/src/client/components/TechStackSection.tsx +1 -3
  30. package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
  31. package/carbon/src/client/components/ui/button.tsx +1 -1
  32. package/carbon/src/client/components/ui/card.tsx +1 -1
  33. package/carbon/src/client/components/ui/dialog.tsx +1 -1
  34. package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
  35. package/carbon/src/client/components/ui/popover.tsx +1 -1
  36. package/carbon/src/client/components/ui/select.tsx +1 -1
  37. package/carbon/src/client/components/ui/sidebar.tsx +1 -1
  38. package/carbon/src/client/index.css +186 -104
  39. package/carbon/src/client/lib/supabase.ts +41 -0
  40. package/carbon/src/client/lib/theme.ts +14 -14
  41. package/carbon/src/client/locales/de.json +27 -12
  42. package/carbon/src/client/locales/en.json +33 -18
  43. package/carbon/src/client/locales/es.json +27 -12
  44. package/carbon/src/client/locales/fr.json +27 -12
  45. package/carbon/src/client/locales/pt.json +27 -12
  46. package/carbon/src/client/pages/Blog.tsx +1 -1
  47. package/carbon/src/client/pages/Changelog.tsx +1 -1
  48. package/carbon/src/client/pages/Checkout.tsx +1 -1
  49. package/carbon/src/client/pages/Dashboard.tsx +33 -40
  50. package/carbon/src/client/pages/Docs.tsx +1 -1
  51. package/carbon/src/client/pages/Home.tsx +1 -1
  52. package/carbon/src/client/pages/Legal.tsx +1 -1
  53. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  54. package/carbon/src/server/billing/providers/polar.ts +14 -0
  55. package/carbon/src/server/index.ts +12 -9
  56. package/carbon/src/server/lib/client-ip.ts +68 -0
  57. package/carbon/src/server/lib/env.ts +20 -6
  58. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  59. package/carbon/src/server/lib/supabase.ts +10 -4
  60. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  61. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  62. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  63. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  64. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  65. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  66. package/carbon/src/server/routes/v1/auth.ts +5 -2
  67. package/carbon/src/server/routes/v1/billing.ts +24 -64
  68. package/carbon/src/server/routes/v1/index.ts +8 -1
  69. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  70. package/carbon/src/server/routes/v1/theme.ts +36 -15
  71. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  72. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  73. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  74. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  75. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  76. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  77. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  78. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  79. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  80. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  81. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  82. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  83. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  84. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  85. package/carbon/volumes/db/wal-archive.sh +36 -0
  86. package/package.json +2 -2
  87. package/src/backup.js +0 -2
  88. package/src/cli.js +16 -0
  89. package/src/deploy.js +16 -5
  90. package/src/destroy.js +78 -0
  91. package/src/failover.js +118 -237
  92. package/src/lib/command.js +19 -1
  93. package/src/lib/deploy/compose/ha.js +180 -102
  94. package/src/lib/deploy/compose/index.js +63 -20
  95. package/src/lib/deploy/image.js +19 -23
  96. package/src/lib/deploy/k8s/ha/index.js +397 -150
  97. package/src/lib/deploy/k8s/k3s.js +65 -14
  98. package/src/lib/deploy/orchestrator.js +176 -6
  99. package/src/lib/deploy/remote-build.js +8 -1
  100. package/src/lib/deploy/replication.js +540 -0
  101. package/src/lib/deploy/state.js +62 -5
  102. package/src/lib/deploy/utils.js +30 -2
  103. package/src/lib/deploy/wireguard.js +146 -0
  104. package/src/lib/host-keys.js +120 -5
  105. package/src/lib/iac/index.js +57 -23
  106. package/src/lib/licensing/gate.js +59 -0
  107. package/src/lib/licensing/index.js +1 -1
  108. package/src/lib/licensing/tiers.js +6 -5
  109. package/src/lib/prod-confirm.js +65 -0
  110. package/src/lib/providers/hetzner-s3.js +85 -0
  111. package/src/lib/ssh.js +35 -35
  112. package/src/restore.js +102 -14
  113. package/src/scale.js +4 -2
  114. package/src/status.js +122 -0
  115. package/src/upgrade.js +0 -4
  116. package/carbon/ha/activate-standby.sh +0 -52
  117. package/carbon/ha/standby-init.sh +0 -74
  118. package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
  119. package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
  120. package/carbon/src/server/lib/request.ts +0 -34
  121. package/src/lib/pod-backups.js +0 -53
@@ -17,6 +17,7 @@
17
17
 
18
18
  import {
19
19
  AbortMultipartUploadCommand,
20
+ CopyObjectCommand,
20
21
  CreateBucketCommand,
21
22
  DeleteBucketCommand,
22
23
  DeleteObjectsCommand,
@@ -251,6 +252,74 @@ export class HetznerS3Provider {
251
252
  }
252
253
  }
253
254
 
255
+ /**
256
+ * Return true if the bucket contains at least one object under `prefix`.
257
+ * Tolerant of NoSuchBucket (returns false) so callers can probe a bucket
258
+ * that may not exist yet. Used by the deploy-time Pulumi-state migration to
259
+ * decide whether the dedicated state bucket has already been seeded and
260
+ * whether the legacy app bucket still holds state to copy over.
261
+ * @param {string} bucketName
262
+ * @param {string} prefix
263
+ * @returns {Promise<boolean>}
264
+ */
265
+ async hasObjectsWithPrefix(bucketName, prefix) {
266
+ try {
267
+ const res = await this._send(
268
+ new ListObjectsV2Command({ Bucket: bucketName, Prefix: prefix, MaxKeys: 1 }),
269
+ {
270
+ terminal: (err) => {
271
+ const status = err.$metadata?.httpStatusCode;
272
+ return err.name === 'NoSuchBucket' || status === 404;
273
+ },
274
+ },
275
+ );
276
+ return (res.KeyCount ?? res.Contents?.length ?? 0) > 0;
277
+ } catch (err) {
278
+ const status = err.$metadata?.httpStatusCode;
279
+ if (err.name === 'NoSuchBucket' || status === 404) return false;
280
+ throw err;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Copy every object under `prefix` from `srcBucket` to `destBucket`,
286
+ * preserving keys. Server-side copy (CopyObjectCommand) — no bytes transit
287
+ * the operator. Used to migrate legacy Pulumi state (the `.pulumi/` prefix)
288
+ * out of the app storage bucket into the dedicated state bucket. Both
289
+ * buckets must be in this provider's region.
290
+ * @param {string} srcBucket
291
+ * @param {string} destBucket
292
+ * @param {string} prefix
293
+ * @returns {Promise<{copied: number}>}
294
+ */
295
+ async copyPrefix(srcBucket, destBucket, prefix) {
296
+ let continuationToken;
297
+ let copied = 0;
298
+ do {
299
+ const listResult = await this._send(
300
+ new ListObjectsV2Command({
301
+ Bucket: srcBucket,
302
+ Prefix: prefix,
303
+ ContinuationToken: continuationToken,
304
+ }),
305
+ );
306
+ for (const obj of listResult.Contents || []) {
307
+ await this._send(
308
+ new CopyObjectCommand({
309
+ Bucket: destBucket,
310
+ Key: obj.Key,
311
+ // CopySource must be URL-encoded; keys contain `/` path separators
312
+ // (`.pulumi/stacks/...`) which encodeURI preserves.
313
+ CopySource: encodeURI(`${srcBucket}/${obj.Key}`),
314
+ }),
315
+ );
316
+ copied += 1;
317
+ }
318
+ continuationToken = listResult.IsTruncated ? listResult.NextContinuationToken : undefined;
319
+ } while (continuationToken);
320
+ return { copied };
321
+ }
322
+
254
323
  /**
255
324
  * Search all S3 regions for a bucket we own.
256
325
  * Each region's HeadBucket failure is intentionally swallowed — finding
@@ -674,3 +743,19 @@ export function sanitizeBucketName(projectName, suffix = 'storage') {
674
743
  .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
675
744
  .slice(0, 63); // Truncate to max length
676
745
  }
746
+
747
+ /**
748
+ * Derive the dedicated Pulumi-state bucket name from the app storage bucket
749
+ * name. Pulumi state used to live in the app storage bucket, which created a
750
+ * destroy-time circular hazard (deleting the storage bucket nuked the state
751
+ * backend mid-destroy). State now lives in a separate bucket derived once,
752
+ * deterministically, from the app bucket — so the derivation is stable across
753
+ * deploy / scale / destroy and identical for both HA stacks (they share one
754
+ * state bucket).
755
+ *
756
+ * @param {string} appBucketName - The app storage bucket (e.g. `myapp-storage`)
757
+ * @returns {string} - Valid state bucket name (e.g. `myapp-storage-pulumi-state`)
758
+ */
759
+ export function deriveStateBucketName(appBucketName) {
760
+ return sanitizeBucketName(appBucketName, 'pulumi-state');
761
+ }
package/src/lib/ssh.js CHANGED
@@ -8,16 +8,22 @@
8
8
  * of dynamic tokens), or
9
9
  * 2. Use sshRunScript to execute a bash script SCP'd as a file.
10
10
  *
11
- * Host-key checking: first-provision callers pass { firstConnect: true }
12
- * which switches StrictHostKeyChecking to accept-new (TOFU). All other
13
- * calls use strict checking against a per-environment known_hosts file.
11
+ * Host-key checking: EVERY call pins against a per-environment known_hosts
12
+ * file there is no /dev/null + StrictHostKeyChecking=no bypass. When a
13
+ * caller passes an explicit `env`, first-provision callers pass
14
+ * { firstConnect: true } to accept-new (TOFU) once and strict-check ('yes')
15
+ * thereafter. Callers that only have `(ip, sshKeyPath)` get the per-env
16
+ * known_hosts derived from the key path (knownHostsPathForKey) with
17
+ * StrictHostKeyChecking=accept-new — which still REJECTS a changed key for an
18
+ * already-pinned host (MITM on an established env fails) while TOFU-ing a fresh
19
+ * or recycled IP. The operator's ~/.ssh/known_hosts is never touched.
14
20
  */
15
21
 
16
22
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
17
23
  import { tmpdir } from 'node:os';
18
24
  import { join } from 'node:path';
19
25
  import { runCommandAsync } from './command.js';
20
- import { knownHostsPath } from './host-keys.js';
26
+ import { knownHostsPath, knownHostsPathForKey } from './host-keys.js';
21
27
  import { shEscape } from './shell.js';
22
28
 
23
29
  // k3s writes its kubeconfig here; kubectl doesn't find it by default in SSH sessions.
@@ -53,30 +59,36 @@ export function getSSHKeyPath(environment) {
53
59
  }
54
60
 
55
61
  /**
56
- * Build the SSH `-o` option args for host-key checking.
62
+ * Build the SSH `-o` option args for host-key checking. Always pins against a
63
+ * per-environment known_hosts file under .vibecarbon/ — never /dev/null.
64
+ * GlobalKnownHostsFile=/dev/null ignores the system-wide file; the operator's
65
+ * ~/.ssh/known_hosts is never read or polluted (Hetzner recycles public IPs,
66
+ * and a stale entry there would reject an unrelated reconnect).
57
67
  *
58
- * - With `env`: StrictHostKeyChecking=yes against .vibecarbon/known_hosts_<env>
59
- * (or accept-new if firstConnect=true used during first provision).
68
+ * - With explicit `env`: pin to .vibecarbon/known_hosts_<env>,
69
+ * StrictHostKeyChecking=yes (or accept-new when firstConnect=true, used
70
+ * during first provision).
60
71
  *
61
- * - Without `env`: bypass the user's known_hosts with UserKnownHostsFile=
62
- * /dev/null and StrictHostKeyChecking=no, matching the deploy path's long-
63
- * standing SSH_OPTS in compose/index.js. This keeps backup/restore/scale/
64
- * failover consistent with the deploy flow and avoids the Hetzner recycled-
65
- * IP footgun: destroy-then-redeploy gives you the same public IP with a new
66
- * host key, and a stale TOFU entry in ~/.ssh/known_hosts would refuse the
67
- * reconnect. H-1 (tracked in docs/superpowers/specs/2026-04-17-security-
68
- * remediation-design.md) replaces this with env-scoped pinned known_hosts
69
- * across every CLI command — callers pass `env` once the deploy path
70
- * populates .vibecarbon/known_hosts_<env>.
72
+ * - With only `sshKeyPath` (backup/restore/scale/failover only receive the key
73
+ * path, not an env): derive .vibecarbon/known_hosts_<env> from the key path
74
+ * (knownHostsPathForKey) and use StrictHostKeyChecking=accept-new. accept-new
75
+ * bootstraps an empty pin and TOFU's a fresh/recycled IP, but REJECTS a
76
+ * changed key for an already-pinned host so a MITM against an established
77
+ * env fails, while destroy→redeploy (same IP, new key, re-seeded on
78
+ * provision) does not spuriously hard-fail.
71
79
  */
72
- function sshHostKeyOpts(env, { firstConnect = false } = {}) {
80
+ function sshHostKeyOpts(env, { firstConnect = false, sshKeyPath } = {}) {
73
81
  const opts = [];
74
82
  if (env) {
75
83
  opts.push('-o', `UserKnownHostsFile=${knownHostsPath(env)}`);
84
+ opts.push('-o', 'GlobalKnownHostsFile=/dev/null');
76
85
  opts.push('-o', `StrictHostKeyChecking=${firstConnect ? 'accept-new' : 'yes'}`);
86
+ } else if (sshKeyPath) {
87
+ opts.push('-o', `UserKnownHostsFile=${knownHostsPathForKey(sshKeyPath)}`);
88
+ opts.push('-o', 'GlobalKnownHostsFile=/dev/null');
89
+ opts.push('-o', 'StrictHostKeyChecking=accept-new');
77
90
  } else {
78
- opts.push('-o', 'UserKnownHostsFile=/dev/null');
79
- opts.push('-o', 'StrictHostKeyChecking=no');
91
+ throw new Error('sshHostKeyOpts requires either `env` or `sshKeyPath` for host-key pinning');
80
92
  }
81
93
  opts.push('-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10');
82
94
  // ConnectTimeout only covers TCP connect — once the socket is open, ssh
@@ -94,18 +106,6 @@ function sshHostKeyOpts(env, { firstConnect = false } = {}) {
94
106
  return opts;
95
107
  }
96
108
 
97
- /**
98
- * The no-host-key-pinning SSH option set as a single space-joined string,
99
- * for the compose deploy path's string-command runners (sshRun/sshRunAsync
100
- * in compose/index.js) and raw `ssh ${SSH_OPTS}` interpolations.
101
- *
102
- * Derived from sshHostKeyOpts() so the string form can never drift from the
103
- * argv form — the historical footgun was compose/ha.js hand-copying this
104
- * string and silently dropping the ServerAlive keepalives, letting a
105
- * banner-exchange hang stall an HA deploy for ~600s.
106
- */
107
- export const NO_PIN_SSH_OPTS = sshHostKeyOpts().join(' ');
108
-
109
109
  /**
110
110
  * Execute a command on a remote server via SSH using argv form.
111
111
  *
@@ -143,7 +143,7 @@ export async function sshRun(ip, sshKeyPath, argv, options = {}) {
143
143
  'ssh',
144
144
  '-i',
145
145
  sshKeyPath,
146
- ...sshHostKeyOpts(env, { firstConnect }),
146
+ ...sshHostKeyOpts(env, { firstConnect, sshKeyPath }),
147
147
  '--',
148
148
  `root@${ip}`,
149
149
  remoteCmd,
@@ -189,7 +189,7 @@ export async function scpDownload(ip, sshKeyPath, remotePath, localPath, options
189
189
  'scp',
190
190
  '-i',
191
191
  sshKeyPath,
192
- ...sshHostKeyOpts(env, { firstConnect }),
192
+ ...sshHostKeyOpts(env, { firstConnect, sshKeyPath }),
193
193
  '--',
194
194
  `root@${ip}:${remotePath}`,
195
195
  localPath,
@@ -206,7 +206,7 @@ export async function scpUpload(ip, sshKeyPath, localPath, remotePath, options =
206
206
  'scp',
207
207
  '-i',
208
208
  sshKeyPath,
209
- ...sshHostKeyOpts(env, { firstConnect }),
209
+ ...sshHostKeyOpts(env, { firstConnect, sshKeyPath }),
210
210
  '--',
211
211
  localPath,
212
212
  `root@${ip}:${remotePath}`,
package/src/restore.js CHANGED
@@ -19,6 +19,7 @@
19
19
  import { existsSync } from 'node:fs';
20
20
  import { basename } from 'node:path';
21
21
  import * as p from '@clack/prompts';
22
+ import { identifyServers } from './failover.js';
22
23
  import { formatInstant } from './lib/backup-format.js';
23
24
  import { renderHelp } from './lib/cli/help.js';
24
25
  import { parseFlags } from './lib/cli/parse-flags.js';
@@ -31,9 +32,14 @@ import { loadCredentials, loadProjectConfig } from './lib/config.js';
31
32
  // while still accepting injected mocks for unit tests.
32
33
  import { configureStandbyReplication as realConfigureStandbyReplication } from './lib/deploy/compose/ha.js';
33
34
  import { restoreCompose as realRestoreCompose } from './lib/deploy/compose/index.js';
34
- import { requireLicense } from './lib/licensing/index.js';
35
+ import {
36
+ readK8sReplicationState as realReadK8sReplicationState,
37
+ reseedStandbyFromPrimary as realReseedStandbyFromPrimary,
38
+ verifyStreaming,
39
+ } from './lib/deploy/replication.js';
35
40
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
36
41
  import { perfAsync } from './lib/perf.js';
42
+ import { confirmProdOrExit } from './lib/prod-confirm.js';
37
43
  import { assertInProjectDir } from './lib/project-guard.js';
38
44
  import { getPostgresPod, getSSHKeyPath, sshKubectl } from './lib/ssh.js';
39
45
  import { createTracker } from './lib/tracker.js';
@@ -245,7 +251,6 @@ export async function run(args) {
245
251
  // restore` from a parent directory emits the canonical message.
246
252
  assertInProjectDir();
247
253
 
248
- requireLicense('restore');
249
254
  printBanner();
250
255
  p.intro(`${c.bold('vibecarbon restore')} ${c.dim(`v${VERSION}`)}`);
251
256
 
@@ -392,19 +397,15 @@ export async function run(args) {
392
397
  p.cancel('Operation cancelled.');
393
398
  process.exit(0);
394
399
  }
395
-
396
- if (envName === 'prod' || envName === 'production') {
397
- const doubleConfirm = await p.text({
398
- message: `Type "${envName}" to confirm restoration of production database:`,
399
- validate: (v) => (v !== envName ? `Please type "${envName}" to confirm` : undefined),
400
- });
401
- if (p.isCancel(doubleConfirm)) {
402
- p.cancel('Operation cancelled.');
403
- process.exit(0);
404
- }
405
- }
406
400
  }
407
401
 
402
+ // SECURITY: the production type-to-confirm runs UNCONDITIONALLY — even with
403
+ // -y — so `restore -y prod` can never silently overwrite the production
404
+ // database. This is deliberately OUTSIDE the `!values.y` block above (the
405
+ // soft y/N confirm is skippable with -y; this hard gate is not). Mirrors
406
+ // destroy's prod guard; shared helper so failover can reuse it.
407
+ await confirmProdOrExit(envName, { actionLabel: 'restore', yes: !!values.y });
408
+
408
409
  // Dispatch by mode
409
410
  const tracker = createTracker('restore', { environment: envName });
410
411
  const s = tracker.spinner();
@@ -427,6 +428,7 @@ export async function run(args) {
427
428
  envName,
428
429
  serverIp,
429
430
  sshKeyPath,
431
+ envConfig,
430
432
  });
431
433
  }
432
434
  } catch (error) {
@@ -645,7 +647,21 @@ async function setRestoreMarker(ip, sshKeyPath, value) {
645
647
  ]);
646
648
  }
647
649
 
648
- async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath }) {
650
+ export async function runK8sRestore({
651
+ s,
652
+ chosenSource,
653
+ envName,
654
+ serverIp,
655
+ sshKeyPath,
656
+ envConfig = {},
657
+ // Injected so the k8s-HA standby re-seed dispatch is unit-testable without a
658
+ // real cluster (mirrors runComposeRestore). Production passes none.
659
+ reseedStandbyFromPrimary = realReseedStandbyFromPrimary,
660
+ readReplicationState = realReadK8sReplicationState,
661
+ // Pacing for the post-reseed streaming verify — injectable so tests don't wait
662
+ // on real timers. Production uses the default setTimeout-backed sleep.
663
+ verifySleep = (ms) => new Promise((r) => setTimeout(r, ms)),
664
+ }) {
649
665
  if (chosenSource.kind === 'local') {
650
666
  throw new Error(
651
667
  'k8s restore is wal-g-based and pulls from S3 — local backup files are not supported. Use `-source latest` (or a timestamp for PITR).',
@@ -695,6 +711,78 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
695
711
 
696
712
  p.log.success('Restore completed successfully');
697
713
  p.log.info('The application has been scaled back up.');
714
+
715
+ // k8s-HA: the standby is a SEPARATE cluster deployed with `restore: null` — a
716
+ // streaming replica, never wal-g-restored (a wal-g fetch on the standby would
717
+ // create a divergent timeline; see k8s/ha/index.js). This restore rewound the
718
+ // PRIMARY to an earlier LSN, so the standby now has WAL AHEAD of the primary
719
+ // and CANNOT resume streaming (timeline divergence).
720
+ //
721
+ // Finding #2: instead of the old warn-and-point-to-`deploy`, perform a
722
+ // first-class VERIFIED re-seed of the standby using the SHARED reseed
723
+ // primitive (the same hardened staged-basebackup+atomic-swap the failover path
724
+ // uses), then confirm streaming from the restored primary. Fail loudly if it
725
+ // can't complete — never silently leave a diverged standby.
726
+ const isK8sHA = !!(envConfig.ha?.enabled || envConfig.ha === true || envConfig.secondaryRegion);
727
+ if (isK8sHA) {
728
+ const servers = identifyServers(envName, envConfig, {});
729
+ const standbyIp = servers?.standby?.ip;
730
+ const primarySupabaseIp = servers?.primary?.supabaseIp || servers?.primary?.ip;
731
+ if (!standbyIp || !primarySupabaseIp) {
732
+ // We can't locate the standby / primary source — fail loudly rather than
733
+ // report a clean restore while the standby silently diverges.
734
+ throw new Error(
735
+ 'HA restore: the primary was restored, but the standby cluster or the primary ' +
736
+ 'supabase IP could not be resolved from config, so the standby could NOT be ' +
737
+ `re-seeded and now has a DIVERGED timeline. DR is not guaranteed. Resync with ` +
738
+ `\`vibecarbon deploy ${envName}\` (re-runs the verified replication setup).`,
739
+ );
740
+ }
741
+
742
+ s.start('Re-seeding standby cluster from restored primary (pg_basebackup)');
743
+ let reseedResult;
744
+ try {
745
+ reseedResult = await reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp);
746
+ } catch (err) {
747
+ s.stop('Standby re-seed failed');
748
+ throw new Error(
749
+ `HA restore: the primary was restored and is serving, but re-seeding the standby ` +
750
+ `FAILED: ${err.message}\nThe standby has a DIVERGED timeline and is NOT replicating — ` +
751
+ `DR is not guaranteed. Resync with \`vibecarbon deploy ${envName}\`.`,
752
+ );
753
+ }
754
+ if (reseedResult === 'skipped') {
755
+ s.stop('Standby re-seed skipped — primary unreachable from standby');
756
+ throw new Error(
757
+ 'HA restore: the standby could NOT reach the restored primary to re-seed ' +
758
+ '(cross-cluster connectivity). The standby has a DIVERGED timeline and is NOT ' +
759
+ `replicating — DR is not guaranteed. Verify the replication firewall/network, then ` +
760
+ `resync with \`vibecarbon deploy ${envName}\`.`,
761
+ );
762
+ }
763
+ s.stop('Standby re-seeded from restored primary');
764
+
765
+ // Confirm streaming from the restored primary — a re-seed that boots but
766
+ // never streams is still a diverged standby.
767
+ s.start('Verifying standby is streaming from restored primary');
768
+ const { streaming, lastState } = await verifyStreaming({
769
+ readState: async () => readReplicationState(servers.primary.ip, sshKeyPath),
770
+ attempts: 15,
771
+ delaysMs: [1000, 2000, 3000],
772
+ sleep: verifySleep,
773
+ });
774
+ if (!streaming) {
775
+ s.stop('Standby streaming NOT confirmed');
776
+ throw new Error(
777
+ `HA restore: the standby was re-seeded but streaming replication is NOT confirmed ` +
778
+ `(last pg_stat_replication.state=${
779
+ lastState ? JSON.stringify(lastState) : 'no replica connected'
780
+ }). DR is not guaranteed. Resync with \`vibecarbon deploy ${envName}\`.`,
781
+ );
782
+ }
783
+ s.stop('Standby streaming from restored primary — DR restored');
784
+ }
785
+
698
786
  p.log.info(`Verify with: ${c.info(`vibecarbon status ${envName}`)}`);
699
787
  }
700
788
 
package/src/scale.js CHANGED
@@ -41,7 +41,6 @@ import { buildRemote } from './lib/deploy/remote-build.js';
41
41
  import { DEFAULT_WORKER_MAX, DEFAULT_WORKER_MIN } from './lib/deploy/utils.js';
42
42
  import { waitForDNSPropagation } from './lib/dns-propagation.js';
43
43
  import { getApiToken, getS3Credentials } from './lib/hetzner-guided-setup.js';
44
- import { requireLicense } from './lib/licensing/index.js';
45
44
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
46
45
  import { perfAsync } from './lib/perf.js';
47
46
  import { assertInProjectDir } from './lib/project-guard.js';
@@ -764,7 +763,6 @@ export async function run(args) {
764
763
  // scale` from a parent directory emits the canonical message.
765
764
  const projectConfig = assertInProjectDir();
766
765
 
767
- requireLicense('scale');
768
766
  printBanner();
769
767
  p.intro(`${c.bold('vibecarbon scale')} ${c.dim(`v${VERSION}`)}`);
770
768
 
@@ -1140,6 +1138,10 @@ export async function run(args) {
1140
1138
  bucket: envConfig.s3.bucket,
1141
1139
  region: envConfig.s3.region,
1142
1140
  endpoint: envConfig.s3.endpoint,
1141
+ // Dedicated Pulumi-state bucket. Undefined for envs deployed before
1142
+ // the state-bucket split — resolveBackendUrl then falls back to the
1143
+ // app bucket, matching where their state actually still lives.
1144
+ stateBucket: envConfig.s3.stateBucket,
1143
1145
  accessKey: s3Creds.accessKey,
1144
1146
  secretKey: s3Creds.secretKey,
1145
1147
  };
package/src/status.js CHANGED
@@ -20,6 +20,7 @@ import { c, printBanner } from './lib/colors.js';
20
20
  import { runCommand } from './lib/command.js';
21
21
  import { cleanStaleProjects, loadGlobalRegistry, loadProjectConfig } from './lib/config.js';
22
22
  import { HetznerProvider } from './lib/providers/hetzner.js';
23
+ import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRun } from './lib/ssh.js';
23
24
  import { VERSION } from './lib/version.js';
24
25
 
25
26
  /** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, description?: string, examples?: Array<{ command: string, description?: string }> }} */
@@ -324,6 +325,108 @@ async function checkRemoteHealth(domain) {
324
325
  };
325
326
  }
326
327
 
328
+ /**
329
+ * Read the REAL streaming-replication state for an HA environment by querying
330
+ * `pg_stat_replication` on the CURRENT primary. Surfaces honest DR state instead
331
+ * of a hardcoded "streaming" string (finding #4). Best-effort and hard-bounded:
332
+ * any failure (unreachable, db down, non-HA) resolves to a value the renderer
333
+ * can degrade on, and the whole probe is raced against a short timeout so
334
+ * `status` never hangs.
335
+ *
336
+ * @returns {Promise<{ streaming: boolean, state: string, lagBytes: number|null } | null>}
337
+ */
338
+ async function checkReplication(envName, envConfig, projectName, deps = {}) {
339
+ const {
340
+ sshRun: _sshRun = sshRun,
341
+ sshKubectl: _sshKubectl = sshKubectl,
342
+ getPostgresPod: _getPod = getPostgresPod,
343
+ getSSHKeyPath: _getKey = getSSHKeyPath,
344
+ timeoutMs = 10_000,
345
+ } = deps;
346
+
347
+ const isHA = !!(envConfig.ha?.enabled || envConfig.ha === true || envConfig.secondaryRegion);
348
+ if (!isHA) return null;
349
+
350
+ const servers = envConfig.servers || [];
351
+ const primary = servers.find((sv) => sv.role === 'primary') || servers[0];
352
+ if (!primary?.ip) return null;
353
+
354
+ const sshKeyPath = _getKey(envName);
355
+ if (!existsSync(sshKeyPath)) return null;
356
+
357
+ const isCompose = envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha';
358
+ // No string literals in the SQL → no shell/psql quoting hazards. `-tA` uses
359
+ // '|' as the field separator, so a connected standby yields `streaming|<lag>`.
360
+ const sql =
361
+ 'SELECT state, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) FROM pg_stat_replication';
362
+
363
+ const probe = (async () => {
364
+ let out;
365
+ if (isCompose) {
366
+ out = await _sshRun(
367
+ primary.ip,
368
+ sshKeyPath,
369
+ [
370
+ 'bash',
371
+ '-lc',
372
+ `cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "${sql}"`,
373
+ ],
374
+ { timeout: timeoutMs },
375
+ );
376
+ } else {
377
+ const pod = await _getPod(primary.ip, sshKeyPath);
378
+ out = await _sshKubectl(
379
+ primary.ip,
380
+ sshKeyPath,
381
+ [
382
+ 'exec',
383
+ '-n',
384
+ 'vibecarbon',
385
+ pod,
386
+ '--',
387
+ 'psql',
388
+ '-U',
389
+ 'supabase_admin',
390
+ '-d',
391
+ 'postgres',
392
+ '-tAc',
393
+ sql,
394
+ ],
395
+ { timeout: timeoutMs },
396
+ );
397
+ }
398
+ const line = (typeof out === 'string' ? out : '')
399
+ .split('\n')
400
+ .map((l) => l.trim())
401
+ .filter(Boolean)[0];
402
+ if (!line) return { streaming: false, state: 'no standby connected', lagBytes: null };
403
+ const [state, lagStr] = line.split('|');
404
+ const lag = lagStr ? Number.parseInt(lagStr, 10) : Number.NaN;
405
+ return {
406
+ streaming: state === 'streaming',
407
+ state: state || 'unknown',
408
+ lagBytes: Number.isNaN(lag) ? null : lag,
409
+ };
410
+ })();
411
+
412
+ try {
413
+ return await Promise.race([
414
+ probe,
415
+ new Promise((resolve) => {
416
+ // .unref() so a fast probe win doesn't leave this timer holding the
417
+ // event loop open and delaying `status` exit by the timeout window.
418
+ const t = setTimeout(
419
+ () => resolve({ streaming: false, state: 'unknown', lagBytes: null }),
420
+ timeoutMs + 2_000,
421
+ );
422
+ if (typeof t?.unref === 'function') t.unref();
423
+ }),
424
+ ]);
425
+ } catch {
426
+ return { streaming: false, state: 'unknown', lagBytes: null };
427
+ }
428
+ }
429
+
327
430
  async function getServerInfo(serverId) {
328
431
  const apiToken = process.env.HETZNER_API_TOKEN;
329
432
  if (!apiToken) return null;
@@ -506,6 +609,20 @@ function renderEnvironment(envName, envConfig, checks) {
506
609
  ? `${c.success('Enabled')} ${c.dim(`(failover: ${failoverRegion})`)}`
507
610
  : c.success('Enabled');
508
611
  lines.push(`${c.dim('HA')} ${haDisplay}`);
612
+
613
+ // Real replication state (finding #4) — never a hardcoded "streaming".
614
+ if (checks.replication) {
615
+ const r = checks.replication;
616
+ let replDisplay;
617
+ if (r.streaming) {
618
+ const lag =
619
+ r.lagBytes != null ? c.dim(` (lag: ${(r.lagBytes / 1024).toFixed(0)} KiB)`) : '';
620
+ replDisplay = `${c.success('streaming')}${lag}`;
621
+ } else {
622
+ replDisplay = `${c.error(r.state || 'not streaming')} ${c.dim('— DR not guaranteed')}`;
623
+ }
624
+ lines.push(`${c.dim('Replication')} ${replDisplay}`);
625
+ }
509
626
  }
510
627
 
511
628
  // Servers
@@ -816,6 +933,10 @@ async function main() {
816
933
  // Git sync
817
934
  checks.gitSync = checkGitSync(envName, envConfig);
818
935
 
936
+ // Real replication state for HA envs (best-effort, hard-bounded). null
937
+ // for non-HA or when the primary/key isn't locally reachable.
938
+ checks.replication = await checkReplication(envName, envConfig, projectConfig.projectName);
939
+
819
940
  return { envName, config: envConfig, checks };
820
941
  }),
821
942
  );
@@ -902,6 +1023,7 @@ export {
902
1023
  checkHttpEndpoint,
903
1024
  checkLocalDev,
904
1025
  checkRemoteHealth,
1026
+ checkReplication,
905
1027
  formatRelativeTime,
906
1028
  getBranchName,
907
1029
  getServerInfo,
package/src/upgrade.js CHANGED
@@ -26,7 +26,6 @@ import { renderHelp } from './lib/cli/help.js';
26
26
  import { parseFlags } from './lib/cli/parse-flags.js';
27
27
  import { c, printBanner } from './lib/colors.js';
28
28
  import { runCommandAsync } from './lib/command.js';
29
- import { requireLicense } from './lib/licensing/index.js';
30
29
  import { mergePackageJson } from './lib/merge-package-json.js';
31
30
  import {
32
31
  adaptDockerfileForPackageManager,
@@ -294,9 +293,6 @@ async function main(cliArgs) {
294
293
  // Detect project
295
294
  assertInProjectDir();
296
295
 
297
- // Require a paid license (Diamond or Fullerene)
298
- requireLicense('upgrade');
299
-
300
296
  const cwd = process.cwd();
301
297
  const manifest = loadManifest(cwd);
302
298
  const currentTemplateVersion = manifest.templateVersion || '0.0.0';
@@ -1,52 +0,0 @@
1
- #!/bin/bash
2
- # Promote a standby PostgreSQL to primary and scale up services.
3
- #
4
- # This script runs ON the standby master node and uses kubectl to:
5
- # 1. Promote PostgreSQL from standby to primary
6
- # 2. Wait for the promotion to complete
7
- # 3. Scale up Supabase and application services
8
- #
9
- # Optional env vars:
10
- # NAMESPACE — Kubernetes namespace (default: vibecarbon)
11
-
12
- set -euo pipefail
13
-
14
- NAMESPACE="${NAMESPACE:-vibecarbon}"
15
- PGDATA="/var/lib/postgresql/data"
16
-
17
- # Find the postgres pod
18
- POD=$(kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/name=supabase-db -o jsonpath='{.items[0].metadata.name}')
19
- if [ -z "$POD" ]; then
20
- echo "ERROR: No postgres pod found in namespace $NAMESPACE"
21
- exit 1
22
- fi
23
-
24
- echo "Promoting standby: pod=$POD"
25
-
26
- # 1. Promote PostgreSQL
27
- echo "Promoting PostgreSQL standby to primary..."
28
- kubectl exec -n "$NAMESPACE" "$POD" -- su postgres -c "pg_ctl promote -D $PGDATA" 2>/dev/null || true
29
-
30
- # 2. Wait for promotion to complete
31
- echo "Waiting for promotion..."
32
- for i in $(seq 1 30); do
33
- result=$(kubectl exec -n "$NAMESPACE" "$POD" -- psql -U supabase_admin -tAc 'SELECT pg_is_in_recovery()' 2>/dev/null || echo "t")
34
- if [ "$result" = "f" ]; then
35
- echo "PostgreSQL promoted successfully"
36
- break
37
- fi
38
- if [ "$i" = "30" ]; then
39
- echo "WARNING: Promotion not confirmed after 60 seconds"
40
- fi
41
- sleep 2
42
- done
43
-
44
- # 3. Scale up services
45
- echo "Scaling up services..."
46
- kubectl scale deployment app -n "$NAMESPACE" --replicas=2
47
- kubectl scale deployment auth -n "$NAMESPACE" --replicas=2
48
- kubectl scale deployment rest -n "$NAMESPACE" --replicas=2
49
- kubectl scale deployment realtime -n "$NAMESPACE" --replicas=2
50
- kubectl scale deployment storage -n "$NAMESPACE" --replicas=1
51
-
52
- echo "Standby activation complete"