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
@@ -37,9 +37,9 @@ import { tmpdir } from 'node:os';
37
37
  import { join } from 'node:path';
38
38
  import { setTimeout as delay } from 'node:timers/promises';
39
39
  import * as p from '@clack/prompts';
40
- import { runCommandAsync } from '../../command.js';
40
+ import { runCommandAsync, writeSecretFile } from '../../command.js';
41
41
  import { featureConfigKeys, featureSecretKeys } from '../../config-registry.js';
42
- import { knownHostsPath } from '../../host-keys.js';
42
+ import { knownHostsPath, seedKnownHosts } from '../../host-keys.js';
43
43
  import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
44
44
  import { dbImageRef } from '../../images.js';
45
45
  import { perfAsync } from '../../perf.js';
@@ -1101,21 +1101,28 @@ export async function pushImageToLocalRegistry({
1101
1101
  );
1102
1102
  });
1103
1103
 
1104
- // Three attempts with progressive backoff. Failure modes we're
1105
- // covering, in priority order:
1104
+ // Progressive backoff across attempts. Failure modes we're covering, in
1105
+ // priority order:
1106
1106
  // 1. Stale tunnel from a parallel run holds :5000 → reopen tunnel.
1107
1107
  // Recovers in seconds (handled by the per-attempt teardown).
1108
1108
  // 2. Registry pod restarted between back-to-back pushes (e.g.
1109
1109
  // probe-killed mid-prior-push) → manifest PUT returns EOF or
1110
1110
  // `manifest blob unknown to registry`. Pod recovery is ~10–20s,
1111
- // so the second/third attempt has the longer backoff to let the
1111
+ // so the later attempts have the longer backoff to let the
1112
1112
  // replacement pod become Ready before re-pushing.
1113
- // 3. S3 transient (Hetzner Object Storage 5xx during blob upload)
1114
- // → registry returns the same `blob unknown` after the layer is
1115
- // "uploaded" but not yet readable. Same recovery shape as (2).
1116
- // The 1s/15s/30s schedule keeps the happy path fast (single attempt
1117
- // ~6s end-to-end) while giving genuine restarts time to recover.
1118
- const settleDelays = settleDelaysMs ?? [1_000, 15_000, 30_000];
1113
+ // 3. S3 throttle (Hetzner Object Storage 503 SlowDown during blob
1114
+ // upload) the registry propagates a `503 Service Unavailable` to
1115
+ // the docker client. This is LOAD-induced: k8s-HA deploys the primary
1116
+ // and standby clusters in parallel, and BOTH registries write layers
1117
+ // to the same Hetzner S3 account at once, so the account throttles.
1118
+ // 3 short attempts (~46s of settle) couldn't ride it out. The longer
1119
+ // tail (60s/120s) extends the window past the throttle window AND past
1120
+ // the sibling cluster's push draining, which halves the S3 write load.
1121
+ // The 1s/15s head keeps the happy path fast (single attempt ~6s end-to-end)
1122
+ // while the 30s/60s/120s tail rides out genuine restarts + S3 throttling.
1123
+ // Note docker push is content-addressable: layers whose S3 write already
1124
+ // succeeded are skipped on retry, so later attempts get progressively cheaper.
1125
+ const settleDelays = settleDelaysMs ?? [1_000, 15_000, 30_000, 60_000, 120_000];
1119
1126
  const maxAttempts = settleDelays.length;
1120
1127
  const attemptErrors = [];
1121
1128
  const taggedAliases = new Set();
@@ -1325,6 +1332,7 @@ export async function installSupabase({
1325
1332
  envLocal,
1326
1333
  dbImageTag,
1327
1334
  backupBucketName,
1335
+ walgRole = 'primary',
1328
1336
  }) {
1329
1337
  const env = { ...process.env, KUBECONFIG: kubeconfig };
1330
1338
  // Render the values template — substitute every {{TOKEN}} the template
@@ -1351,6 +1359,15 @@ export async function installSupabase({
1351
1359
  // WAL-G S3 prefix — dedicated backup bucket if provided, else the storage
1352
1360
  // bucket (mirrors compose's S3_BACKUP_BUCKET:-S3_BUCKET fallback). Never
1353
1361
  // leave it as s3:/// (wal-g would error).
1362
+ //
1363
+ // SINGLE canonical prefix (NO role segment). Reads (backup-fetch/restore/
1364
+ // reseed) and writes (backup-push/wal-push) must all agree on ONE prefix — a
1365
+ // role-segmented prefix made the standby read an empty `…/walg/standby` and
1366
+ // fail restore/reseed with "No backups found" (caught by compose-ha scale
1367
+ // e2e). Anti-collision (finding #3: a standby / bring-up-phase independent
1368
+ // primary must never WRITE here) is enforced by the WALG_ROLE WRITE-GUARD:
1369
+ // wal-archive.sh and the backup CronJob both no-op when WALG_ROLE=standby.
1370
+ // walgRole is rendered into the db env below so those write guards can read it.
1354
1371
  const walgBucket = backupBucketName || s3Config?.bucket || '';
1355
1372
  const walgS3Prefix = walgBucket
1356
1373
  ? `s3://${walgBucket}/backups/${projectName || 'vibecarbon'}/walg`
@@ -1360,6 +1377,7 @@ export async function installSupabase({
1360
1377
  .replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
1361
1378
  .replace(/\{\{S3_BACKUP_BUCKET\}\}/g, walgBucket)
1362
1379
  .replace(/\{\{WALG_S3_PREFIX\}\}/g, walgS3Prefix)
1380
+ .replace(/\{\{WALG_ROLE\}\}/g, walgRole)
1363
1381
  .replace(/\{\{S3_ENDPOINT\}\}/g, s3Config?.endpoint ?? '')
1364
1382
  .replace(/\{\{S3_REGION\}\}/g, s3Config?.region ?? '')
1365
1383
  .replace(/\{\{DB_IMAGE\}\}/g, dbImageRepo)
@@ -1370,7 +1388,9 @@ export async function installSupabase({
1370
1388
  .replace(/\{\{ADMIN_EMAIL\}\}/g, envLocal?.ADMIN_EMAIL ?? '')
1371
1389
  .replace(/\{\{ADMIN_PASSWORD\}\}/g, envLocal?.ADMIN_PASSWORD ?? '');
1372
1390
  const tmpValues = join(tmpdir(), `vibecarbon-supabase-values-${process.pid}-${Date.now()}.yaml`);
1373
- writeFileSync(tmpValues, rendered);
1391
+ // SECURITY: the rendered values contain ADMIN_EMAIL/ADMIN_PASSWORD and live
1392
+ // for the whole `helm upgrade --wait` window — write 0o600, never 0o644.
1393
+ writeSecretFile(tmpValues, rendered);
1374
1394
  try {
1375
1395
  // Add the supabase-community helm repo (idempotent — non-zero if exists).
1376
1396
  try {
@@ -2344,6 +2364,12 @@ export async function applyK3sManifests({
2344
2364
  // timeout in chart). image.db points at the wal-g-equipped db image
2345
2365
  // (sideloaded in step 7b), and the chart mounts wal-archive.sh + the
2346
2366
  // non-secret WALG_* env from supabase.values.yaml.
2367
+ // Derive the wal-g WRITE-GUARD role (NOT a path segment). deployK8sHA calls
2368
+ // deployK3s with environment `${env}-primary` / `${env}-standby`; single-
2369
+ // cluster deploys pass the plain env name → 'primary'. Rendered into the db
2370
+ // container env as WALG_ROLE so wal-archive.sh + the backup CronJob no-op on
2371
+ // the standby. The S3 prefix itself stays canonical (no role segment).
2372
+ const walgRole = environment?.endsWith('-standby') ? 'standby' : 'primary';
2347
2373
  await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
2348
2374
  installSupabase({
2349
2375
  kubeconfig,
@@ -2354,6 +2380,7 @@ export async function applyK3sManifests({
2354
2380
  envLocal,
2355
2381
  dbImageTag,
2356
2382
  backupBucketName,
2383
+ walgRole,
2357
2384
  }),
2358
2385
  );
2359
2386
  // 7b. Enable continuous WAL archiving on the supabase-db. The chart can't
@@ -2593,7 +2620,7 @@ export async function applyK3sManifests({
2593
2620
  * App install + sideload land in a follow-up commit (Task 7).
2594
2621
  *
2595
2622
  * @param {K3sDeployOptions} options
2596
- * @returns {Promise<{masterIp: string, floatingIp: string, supabaseIp: string, workerIps: string[], networkId: string|number, kubeconfig: string, sshKeyPath: string}>}
2623
+ * @returns {Promise<{masterIp: string, floatingIp: string, supabaseIp: string, supabasePrivateIp: string, workerIps: string[], networkId: string|number, kubeconfig: string, sshKeyPath: string}>}
2597
2624
  */
2598
2625
  export async function deployK3s(options) {
2599
2626
  const { state } = options;
@@ -2689,7 +2716,14 @@ export async function deployK3s(options) {
2689
2716
  s.stop(`Cluster provisioned — master=${outputs.masterIp} floating=${outputs.floatingIp}`);
2690
2717
  }
2691
2718
  const infraOutputs = state.getStepResult('k3s-infra');
2692
- const { masterIp, floatingIp, supabaseIp, workerIps, networkId } = infraOutputs;
2719
+ const {
2720
+ masterIp,
2721
+ floatingIp,
2722
+ supabaseIp,
2723
+ supabasePrivateIp = '10.0.1.2',
2724
+ workerIps,
2725
+ networkId,
2726
+ } = infraOutputs;
2693
2727
 
2694
2728
  // 4. Wait for cloud-init to finish + k3s to be Ready
2695
2729
  if (!state.shouldSkip('k3s-ready', { masterIp })) {
@@ -2709,6 +2743,18 @@ export async function deployK3s(options) {
2709
2743
  s.stop('k3s reachable');
2710
2744
  }
2711
2745
 
2746
+ // Trusted host-key seed. The SSH opts above pin per-env with accept-new
2747
+ // (TOFU on first connect); seeding the known_hosts from a real ssh-keyscan
2748
+ // now — on (re)provision, once SSH is reachable — turns accept-new into a
2749
+ // strict pin for the rest of this deploy AND every later command, and
2750
+ // re-pins a Hetzner-recycled IP cleanly (seedKnownHosts drops the stale
2751
+ // per-IP line). Best-effort: if ssh-keyscan can't reach a node we fall back
2752
+ // to accept-new TOFU. See host-keys.js seedKnownHosts for the full security
2753
+ // rationale (why only provisioning re-seeds).
2754
+ for (const seedIp of [masterIp, supabaseIp, ...(workerIps ?? [])].filter(Boolean)) {
2755
+ await seedKnownHosts(khPath, seedIp);
2756
+ }
2757
+
2712
2758
  // 5. Fetch + patch kubeconfig
2713
2759
  let kubeconfig;
2714
2760
  if (!state.shouldSkip('k3s-kubeconfig', { masterIp })) {
@@ -2827,6 +2873,11 @@ export async function deployK3s(options) {
2827
2873
  masterIp,
2828
2874
  floatingIp,
2829
2875
  supabaseIp,
2876
+ // The supabase node's Hetzner private-network IP (deterministic in the IaC
2877
+ // program, but threaded through the output rather than hardcoded at the use
2878
+ // site). setupReplication renders it into the repl-gateway egress
2879
+ // NetworkPolicy + socat bind for this cluster.
2880
+ supabasePrivateIp,
2830
2881
  workerIps,
2831
2882
  networkId,
2832
2883
  kubeconfig,
@@ -16,6 +16,7 @@ import {
16
16
  saveProjectConfig,
17
17
  } from '../config.js';
18
18
  import { waitForDNSPropagation } from '../dns-propagation.js';
19
+ import { knownHostsPathForKey, seedKnownHosts } from '../host-keys.js';
19
20
  import { ensureOperatorIpAccess } from '../operator-ip.js';
20
21
  import { perfAsync, perfTimer } from '../perf.js';
21
22
  import { createTracker } from '../tracker.js';
@@ -223,16 +224,45 @@ export async function executeDeployment(args, gatheredConfig) {
223
224
  }
224
225
 
225
226
  const s3Spinner = p.spinner();
226
- const { HetznerS3Provider } = await import('../providers/hetzner-s3.js');
227
+ const { HetznerS3Provider, deriveStateBucketName } = await import('../providers/hetzner-s3.js');
227
228
  const s3Provider = new HetznerS3Provider(s3Config.accessKey, s3Config.secretKey, s3Config.region);
228
229
 
230
+ // Dedicated Pulumi-state bucket, derived once from the app storage bucket.
231
+ // Pulumi state used to live in the app storage bucket, which meant `destroy`
232
+ // deleting that bucket could yank the state backend mid-destroy. State now
233
+ // lives here instead. Both HA stacks share this ONE bucket.
234
+ const stateBucket = deriveStateBucketName(s3Config.bucket);
235
+
229
236
  // --- STEP 1: S3 Setup (Idempotent) ---
237
+ // `stateBucket` is part of the inputs hash so an upgraded CLI (whose prior
238
+ // deploy-state predates the dedicated state bucket) re-runs this step once to
239
+ // create + migrate the state bucket. Combined with the StateTracker version
240
+ // bump, no stale hash can skip the migration.
230
241
  const s3Inputs = {
231
242
  bucket: s3Config.bucket,
232
243
  backupBucket: backupS3Config.bucket,
244
+ stateBucket,
233
245
  region: s3Config.region,
234
246
  };
235
- if (!state.shouldSkip('s3-setup', s3Inputs)) {
247
+ // Verify hook: even on a matching hash, re-run s3-setup if any of the buckets
248
+ // were deleted out-of-band (e.g. by a prior `destroy`). Without this, a
249
+ // resumed deploy skips bucket creation then fails downstream on NoSuchBucket
250
+ // (the exact resume hazard called out in Finding 2). Other steps that would
251
+ // benefit from a similar probe — `compose-setup-server` (server may be gone)
252
+ // and `dns-setup` — are left on the plain hash check to keep this change
253
+ // focused; s3-setup is the highest-value one because the state backend lives
254
+ // here.
255
+ const verifyBucketsExist = async () => {
256
+ const [app, backup, stateB] = await Promise.all([
257
+ s3Provider.bucketExists(s3Config.bucket),
258
+ backupS3Config?.bucket
259
+ ? s3Provider.bucketExists(backupS3Config.bucket)
260
+ : Promise.resolve(true),
261
+ s3Provider.bucketExists(stateBucket),
262
+ ]);
263
+ return app && backup && stateB;
264
+ };
265
+ if (!(await state.shouldSkipWithVerify('s3-setup', s3Inputs, verifyBucketsExist))) {
236
266
  state.startStep('s3-setup', s3Inputs);
237
267
  try {
238
268
  s3Spinner.start(`Creating S3 bucket: ${s3Config.bucket}`);
@@ -245,7 +275,22 @@ export async function executeDeployment(args, gatheredConfig) {
245
275
 
246
276
  try {
247
277
  s3Spinner.start('Configuring bucket CORS');
248
- const corsOrigins = domain ? [`https://${domain}`] : ['*'];
278
+ // SECURITY: never fall back to a `*` wildcard — that would let any
279
+ // origin issue credentialed browser requests against the storage
280
+ // bucket. When a domain is configured we scope to its https origins
281
+ // (apex + api/app subdomains the app actually serves from); a
282
+ // domainless deploy has no browser origin to trust yet, so we scope
283
+ // to localhost dev origins and warn. The operator re-runs deploy once
284
+ // a domain is set to widen CORS to the real origin.
285
+ let corsOrigins;
286
+ if (domain) {
287
+ corsOrigins = [`https://${domain}`, `https://api.${domain}`, `https://app.${domain}`];
288
+ } else {
289
+ corsOrigins = ['http://localhost:3000', 'http://localhost:5173'];
290
+ p.log.warn(
291
+ 'No domain configured — scoping bucket CORS to localhost dev origins instead of `*`. Re-run deploy with a domain to allow your production origin.',
292
+ );
293
+ }
249
294
  await s3Provider.configureCORS(s3Config.bucket, corsOrigins);
250
295
  s3Spinner.stop('CORS configured');
251
296
  } catch {
@@ -264,7 +309,98 @@ export async function executeDeployment(args, gatheredConfig) {
264
309
  s3Spinner.stop(`Backup bucket creation failed: ${backupBucketError.message}`);
265
310
  }
266
311
 
267
- const result = { region: s3Provider.region, endpoint: s3Provider.getEndpoint() };
312
+ // Dedicated Pulumi-state bucket. Created imperatively (like the app +
313
+ // backup buckets) BEFORE any Pulumi stack op / initBackend. NOT
314
+ // web-facing: no CORS, not public.
315
+ s3Spinner.start(`Creating Pulumi state bucket: ${stateBucket}`);
316
+ const stateBucketResult = await s3Provider.createBucket(stateBucket);
317
+ s3Spinner.stop(
318
+ stateBucketResult.created
319
+ ? `Pulumi state bucket created: ${stateBucket}`
320
+ : `Pulumi state bucket exists: ${stateBucket}`,
321
+ );
322
+
323
+ // SECURITY/SAFETY: migrate legacy Pulumi state out of the app storage
324
+ // bucket. If the state bucket has no `.pulumi/` objects but the OLD app
325
+ // bucket does, this is an env deployed before the dedicated state bucket
326
+ // existed — copy the state prefix over so we resolve to real state, not
327
+ // empty state. Re-initializing an EMPTY state backend against live infra
328
+ // would make Pulumi believe nothing exists and either duplicate or orphan
329
+ // the running servers. So if the copy fails, we DO NOT proceed silently:
330
+ // abort loudly and tell the operator to migrate manually or
331
+ // destroy+redeploy. Fresh deploys hit neither branch (both buckets empty).
332
+ const PULUMI_STATE_PREFIX = '.pulumi/';
333
+ // Determine whether there is GENUINE legacy state to migrate. The check
334
+ // lists the just-created state bucket, which can race S3 list-after-create
335
+ // consistency and throw — that is NOT the orphan-risk case (a fresh deploy
336
+ // has no legacy state), so a check failure must NOT hard-abort. Only a
337
+ // CONFIRMED legacy-state copy failure aborts (below). Retry the checks a
338
+ // few times so a transient blip doesn't misfire either way.
339
+ let legacyState = false;
340
+ let checkOk = false;
341
+ for (let attempt = 1; attempt <= 3; attempt++) {
342
+ try {
343
+ const stateSeeded = await s3Provider.hasObjectsWithPrefix(
344
+ stateBucket,
345
+ PULUMI_STATE_PREFIX,
346
+ );
347
+ legacyState = stateSeeded
348
+ ? false
349
+ : await s3Provider.hasObjectsWithPrefix(s3Config.bucket, PULUMI_STATE_PREFIX);
350
+ checkOk = true;
351
+ break;
352
+ } catch (checkError) {
353
+ if (attempt === 3) {
354
+ // Couldn't determine migration need after retries (e.g. list-after-
355
+ // create consistency on the fresh state bucket). A fresh deploy — the
356
+ // overwhelming common case — has nothing to migrate, so proceed with
357
+ // a loud warning rather than blocking every deploy on a transient S3
358
+ // blip. (Pulumi then uses the dedicated state bucket normally.)
359
+ p.log.warn(
360
+ `Could not verify legacy Pulumi state after ${attempt} attempts — proceeding ` +
361
+ `(a fresh deploy has none to migrate): ${checkError.message}`,
362
+ );
363
+ } else {
364
+ await new Promise((r) => setTimeout(r, 1500 * attempt));
365
+ }
366
+ }
367
+ }
368
+ if (checkOk && legacyState) {
369
+ // CONFIRMED legacy state in the app bucket — migrate it. If THIS copy
370
+ // fails we DO NOT proceed silently: re-initializing an EMPTY state
371
+ // backend against live infra would orphan the running servers.
372
+ try {
373
+ s3Spinner.start(`Migrating Pulumi state ${s3Config.bucket} → ${stateBucket}`);
374
+ const { copied } = await s3Provider.copyPrefix(
375
+ s3Config.bucket,
376
+ stateBucket,
377
+ PULUMI_STATE_PREFIX,
378
+ );
379
+ s3Spinner.stop(`Migrated ${copied} Pulumi state object(s) to ${stateBucket}`);
380
+ } catch (copyError) {
381
+ s3Spinner.stop(`Pulumi state migration FAILED: ${copyError.message}`);
382
+ p.log.error(
383
+ [
384
+ 'Could not migrate existing Pulumi state from the app storage bucket to the',
385
+ `dedicated state bucket (${stateBucket}). Proceeding would re-initialize an`,
386
+ 'EMPTY state backend against your live infrastructure, which would orphan the',
387
+ 'running servers (Pulumi would no longer track them).',
388
+ '',
389
+ 'Aborting to protect your infrastructure. To recover, either:',
390
+ ` • Copy the ".pulumi/" prefix from ${s3Config.bucket} to ${stateBucket} manually`,
391
+ ' (e.g. via `aws s3 sync ... --endpoint-url <endpoint>`), then re-run deploy; or',
392
+ ' • Destroy this environment and redeploy from scratch.',
393
+ ].join('\n'),
394
+ );
395
+ process.exit(1);
396
+ }
397
+ }
398
+
399
+ const result = {
400
+ region: s3Provider.region,
401
+ endpoint: s3Provider.getEndpoint(),
402
+ stateBucket,
403
+ };
268
404
  state.completeStep('s3-setup', result);
269
405
  } catch (error) {
270
406
  const errMsg =
@@ -285,6 +421,11 @@ export async function executeDeployment(args, gatheredConfig) {
285
421
  backupS3Config.region = s3Config.region;
286
422
  backupS3Config.endpoint = s3Config.endpoint;
287
423
  }
424
+ // Always resolve stateBucket (deterministic derivation) so the Pulumi
425
+ // backend targets the dedicated bucket, whether s3-setup ran or was skipped.
426
+ // Every downstream iac call reads it via s3Config, and it's persisted into
427
+ // .vibecarbon.json so scale / failover / destroy resolve the same backend.
428
+ s3Config.stateBucket = s3Result?.stateBucket || stateBucket;
288
429
 
289
430
  // SKELETON SAVE: persist the minimum env entry that `vibecarbon destroy`
290
431
  // needs to recover, BEFORE any `pulumi up` runs. Without this, a crash
@@ -315,7 +456,12 @@ export async function executeDeployment(args, gatheredConfig) {
315
456
  ...(secondaryRegion ? { secondaryRegion } : {}),
316
457
  ...(domain ? { domain } : {}),
317
458
  ...(dnsProvider ? { dnsProvider } : {}),
318
- s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
459
+ s3: {
460
+ bucket: s3Config.bucket,
461
+ region: s3Config.region,
462
+ endpoint: s3Config.endpoint,
463
+ stateBucket: s3Config.stateBucket,
464
+ },
319
465
  backupS3: backupS3Config?.bucket
320
466
  ? {
321
467
  bucket: backupS3Config.bucket,
@@ -558,6 +704,9 @@ export async function executeDeployment(args, gatheredConfig) {
558
704
  backupS3Config,
559
705
  backupConfig,
560
706
  bundlePath,
707
+ // Finding #1: hard-gate replication unless the operator opts into a
708
+ // warm/degraded standby via `deploy -allow-degraded`.
709
+ allowDegraded: !!args.allowDegraded,
561
710
  onProgress: (msg) => s.message(msg),
562
711
  }),
563
712
  );
@@ -606,6 +755,14 @@ export async function executeDeployment(args, gatheredConfig) {
606
755
  serverHetznerId = result.outputs.serverId || null;
607
756
  serverHetznerName = `${projectConfig.projectName}-${environment}`;
608
757
  await perfAsync('deploy.iac.waitForSSH', () => waitForComposeSSH(serverIp, sshKeyPath, 30));
758
+ // Trusted host-key seed on fresh provision — captures the new VPS's
759
+ // host key via ssh-keyscan into the per-env known_hosts so the pinned
760
+ // SSH opts (accept-new) become a strict pin for this deploy and every
761
+ // later command. Re-seeding here also cleanly re-pins a Hetzner-
762
+ // recycled IP (destroy → redeploy). Best-effort: falls back to
763
+ // accept-new TOFU if the scan can't reach the server yet. See
764
+ // host-keys.js seedKnownHosts for the full security rationale.
765
+ await seedKnownHosts(knownHostsPathForKey(sshKeyPath), serverIp);
609
766
  }
610
767
 
611
768
  // --- Warm path (StateTracker) ---
@@ -961,6 +1118,9 @@ export async function executeDeployment(args, gatheredConfig) {
961
1118
  // container seeds PGDATA from S3 via wal-g and applyMigrations is
962
1119
  // skipped (the restored DB is authoritative).
963
1120
  restore: args.restore || null,
1121
+ // Finding #1: hard-gate k8s-HA replication unless the operator opts into a
1122
+ // warm/degraded standby via `deploy -allow-degraded`.
1123
+ allowDegraded: !!args.allowDegraded,
964
1124
  operatorCidrs: projectConfig.operatorCidrs ?? [],
965
1125
  imageReadyPromise,
966
1126
  tracker,
@@ -1405,6 +1565,11 @@ export async function executeDeployment(args, gatheredConfig) {
1405
1565
  region,
1406
1566
  ...(isHADeploy && {
1407
1567
  secondaryRegion,
1568
+ // Finding #1: persist DR posture. A default (gated) HA deploy that
1569
+ // reaches this point is streaming; only `-allow-degraded` finalizes a
1570
+ // warm/degraded standby, which the inner deploy flags on deployResult.
1571
+ replication: deployResult?.degraded ? 'degraded' : 'streaming',
1572
+ degraded: !!deployResult?.degraded,
1408
1573
  // Persist per-cluster floatingIp + supabaseIp under ha.primary /
1409
1574
  // ha.standby so failover.identifyServers takes the structured
1410
1575
  // path that surfaces floatingIp. Without these, identifyServers
@@ -1475,7 +1640,12 @@ export async function executeDeployment(args, gatheredConfig) {
1475
1640
  }),
1476
1641
  deployedAt: new Date().toISOString(),
1477
1642
  deployedCommit,
1478
- s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
1643
+ s3: {
1644
+ bucket: s3Config.bucket,
1645
+ region: s3Config.region,
1646
+ endpoint: s3Config.endpoint,
1647
+ stateBucket: s3Config.stateBucket,
1648
+ },
1479
1649
  ...(backupS3Persist && { backupS3: backupS3Persist }),
1480
1650
  backup: backupConfig,
1481
1651
  services,
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import * as p from '@clack/prompts';
11
11
  import { runCommand } from '../command.js';
12
+ import { knownHostsPathForKey } from '../host-keys.js';
12
13
  import { perfTimer } from '../perf.js';
13
14
 
14
15
  /**
@@ -41,8 +42,13 @@ export async function buildRemote(ip, sshKeyPath, imageTag, cwd, buildArgs = {})
41
42
  // session helper drops with "error reading preface ... file already closed".
42
43
  // Keepalives let ssh detect a dead peer (~60s) and fail the attempt cleanly
43
44
  // so the retry below can re-establish, instead of hanging to the build timeout.
45
+ // Host-key pinned against the per-env known_hosts (passed via env var so no
46
+ // shell-quoting concern), NOT /dev/null + no. accept-new TOFU's an
47
+ // ephemeral/recycled Hetzner IP but rejects a changed key for an already-
48
+ // pinned host (MITM on an established env fails); GlobalKnownHostsFile=
49
+ // /dev/null ignores the system file and never touches ~/.ssh/known_hosts.
44
50
  const sshWrapper = `#!/bin/bash
45
- exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
51
+ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$VIBECARBON_KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
46
52
  `;
47
53
  writeFileSync(sshWrapperPath, sshWrapper, { mode: 0o755 });
48
54
 
@@ -52,6 +58,7 @@ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKn
52
58
  DOCKER_BUILDKIT: '1',
53
59
  PATH: `${tempBinDir}:${process.env.PATH}`, // Prepend our wrapper
54
60
  VIBECARBON_SSH_KEY: sshKeyPath,
61
+ VIBECARBON_KNOWN_HOSTS: knownHostsPathForKey(sshKeyPath),
55
62
  };
56
63
 
57
64
  try {