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
@@ -21,10 +21,24 @@ import {
21
21
  saveProjectConfig,
22
22
  } from '../../config.js';
23
23
  import { fetchWithRetry } from '../../fetch-retry.js';
24
+ import { knownHostsPathForKey, seedKnownHosts } from '../../host-keys.js';
24
25
  import { perfAsync } from '../../perf.js';
25
26
  import { getProvider } from '../../providers/index.js';
26
27
  import { useDnsChallenge } from '../acme.js';
27
- import { generateSSHKeyPair, readReplPassword } from '../utils.js';
28
+ import {
29
+ assertReplicationStreamingOrDegraded,
30
+ buildPrimaryConninfo,
31
+ buildReplicationHbaLine,
32
+ buildStagedBasebackupScript,
33
+ verifyStreaming,
34
+ } from '../replication.js';
35
+ import {
36
+ generateSSHKeyPair,
37
+ mergeRemoteDotenv,
38
+ pinnedSshOptsString,
39
+ readReplPassword,
40
+ } from '../utils.js';
41
+ import { WG_SUBNET_CIDR } from '../wireguard.js';
28
42
  import {
29
43
  createAdminUser,
30
44
  dockerLoginOnServer,
@@ -211,11 +225,12 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
211
225
  * Configure the primary PostgreSQL server for streaming replication.
212
226
  *
213
227
  * 1. Create a dedicated replication user
214
- * 2. Allow the standby IP in pg_hba.conf
228
+ * 2. Allow replication from the WireGuard tunnel subnet in pg_hba.conf (plain
229
+ * `host` — WireGuard encrypts the wire, so Postgres doesn't also need TLS)
215
230
  * 3. Set wal_level=replica and max_wal_senders
216
231
  * 4. Restart PostgreSQL to apply changes
217
232
  */
218
- async function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
233
+ async function configurePrimaryReplication(primaryIp, sshKeyPath, projectName) {
219
234
  const remoteDir = `/opt/${projectName}`;
220
235
  // replPassword is generated at create time from crypto.randomBytes and is
221
236
  // restricted to base64url characters — no shell escaping needed.
@@ -239,8 +254,10 @@ async function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, pro
239
254
  // hold WAL until the standby confirms receipt, closing the drift
240
255
  // window.
241
256
  //
242
- // The SQL is base64-encoded so the password never appears in argv
243
- // the remote shell only sees the base64 blob.
257
+ // The SQL (which embeds the replication password) is piped to psql via STDIN
258
+ // (`input:`), so it never appears in argv / the ssh command string / the
259
+ // remote shell's /proc/cmdline. `docker compose exec -T` forwards our stdin
260
+ // straight into psql.
244
261
  const createRoleSql = `
245
262
  DO $$
246
263
  BEGIN
@@ -263,27 +280,29 @@ BEGIN
263
280
  END IF;
264
281
  END $$;
265
282
  `;
266
- const createRoleB64 = Buffer.from(createRoleSql).toString('base64');
267
283
  await sshRun(
268
284
  primaryIp,
269
285
  sshKeyPath,
270
- `cd ${remoteDir} && echo ${createRoleB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
271
- { timeout: 30_000 },
286
+ `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres`,
287
+ { timeout: 30_000, input: createRoleSql },
272
288
  );
273
289
 
274
- // Update pg_hba.conf to allow replication from standby. The supabase/postgres
275
- // image overrides hba_file to /etc/postgresql/pg_hba.conf (NOT the default
290
+ // Update pg_hba.conf to allow replication from the WireGuard tunnel subnet
291
+ // (WG_SUBNET_CIDR) one line covers both peers regardless of which side is
292
+ // currently primary/standby. The supabase/postgres image overrides hba_file
293
+ // to /etc/postgresql/pg_hba.conf (NOT the default
276
294
  // /var/lib/postgresql/data/pg_hba.conf). RCA: compose-ha 2026-05-01 fanout12
277
295
  // — appended to the wrong file, postgres never saw the rule, every
278
296
  // replication connection was rejected with "no pg_hba.conf entry" while
279
297
  // pg_hba_file_rules reflected the actual /etc loaded contents (no replicator
280
298
  // entry). Resolve the path at runtime via SHOW hba_file so we don't break
281
299
  // again if the image moves the file.
300
+ const hbaLine = buildReplicationHbaLine(WG_SUBNET_CIDR);
282
301
  await sshRun(
283
302
  primaryIp,
284
303
  sshKeyPath,
285
304
  `cd ${remoteDir} && HBA_FILE=$(docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SHOW hba_file;' | tr -d '[:space:]') && ` +
286
- `docker compose exec -T db sh -c "echo 'host replication replicator ${standbyIp}/32 md5' >> $HBA_FILE && ` +
305
+ `docker compose exec -T db sh -c "echo '${hbaLine}' >> $HBA_FILE && ` +
287
306
  `psql -U supabase_admin -d postgres -tAc 'SELECT pg_reload_conf();'"`,
288
307
  { timeout: 15_000 },
289
308
  );
@@ -296,6 +315,15 @@ END $$;
296
315
  { timeout: 30_000 },
297
316
  );
298
317
 
318
+ // TODO(wireguard-transport): tunnel bring-up — Task 6.
319
+ //
320
+ // This used to enable server-side TLS (verify-ca) here: copy the per-deploy
321
+ // CA/cert/key from the bind-mounted Secret into PGDATA and ALTER SYSTEM SET
322
+ // ssl = 'on' + ssl_cert_file/ssl_key_file/ssl_ca_file. That's public-IP-era
323
+ // plumbing for the TLS transport being replaced — WireGuard encrypts the
324
+ // wire instead, so there's no cert material to install here. Task 6 wires
325
+ // the actual wg0 tunnel bring-up for compose-ha.
326
+
299
327
  // Restart PostgreSQL to apply WAL changes
300
328
  await sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
301
329
  timeout: 60_000,
@@ -371,64 +399,34 @@ END $$;`;
371
399
  timeout: 60_000,
372
400
  });
373
401
 
374
- // Run pg_basebackup from primary into standby's data directory.
375
- //
376
- // -Fp: plain format (writes directly to PGDATA)
377
- // -Xs: stream WAL during backup
378
- // -R: auto-create standby.signal and write primary_conninfo to postgresql.auto.conf
379
- // -C --slot: use the physical replication slot created by
380
- // configurePrimaryReplication. This makes the primary hold
381
- // WAL for this standby specifically so streaming stays
382
- // caught up indefinitely instead of silently falling behind
383
- // when wal_keep_size runs out.
384
- //
385
- // After pg_basebackup, append `primary_slot_name` to postgresql.auto.conf
386
- // so the continuous-streaming connection (after initial backup) also uses
387
- // the slot. `-R` writes primary_conninfo but not primary_slot_name.
402
+ // Run pg_basebackup from primary into standby's data directory, using the
403
+ // SHARED staged-basebackup script — the SAME hardened script the k8s path +
404
+ // failover/restore re-seed use, so the deploy-time seed and the
405
+ // recovery-time re-seed can't drift apart. It stages into a tmp dir +
406
+ // atomically swaps in only after verifying PG_VERSION, so a failed/partial
407
+ // basebackup never leaves PGDATA half-formed (the `initdb: directory exists
408
+ // but is not empty` crash-loop). probeFirst aborts cleanly (exit 0, PGDATA
409
+ // untouched) when the primary's REPL_PORT is unreachable. Plaintext (no
410
+ // PGSSLMODE/PGSSLROOTCERT) see TODO(wireguard-transport) above; the
411
+ // connection is unencrypted until Task 6 lands the tunnel.
388
412
  //
389
- // `set -e -o pipefail` is load-bearing: without it, a failed
390
- // pg_basebackup leaves the data dir with only `postgresql.auto.conf`
391
- // (because the trailing `>>` succeeds creating it from scratch), no
392
- // `PG_VERSION`. The next `docker compose start db` then loops forever
393
- // on `initdb: directory exists but is not empty` (RCA from compose-ha
394
- // failover failure 2026-04-30). With `set -e`, basebackup failure
395
- // exits the script non-zero, sshRun throws, the caller can react.
396
- const basebackupScript = `
397
- set -e -o pipefail
398
- rm -rf /var/lib/postgresql/data/*
399
- PGPASSWORD='${replPassword}' pg_basebackup -h ${primaryIp} -p 5433 -U replicator -D /var/lib/postgresql/data -Fp -Xs -P -R -S vibecarbon_standby_slot -v
400
- # pg_basebackup -R is supposed to (a) create standby.signal and (b)
401
- # write primary_conninfo to postgresql.auto.conf. RCA from compose-ha
402
- # fanout9 2026-05-01: in supabase/postgres:15.8.1.085, neither happened
403
- # — postgresql.auto.conf came back at 88 bytes (just the ALTER SYSTEM
404
- # header) and standby.signal was missing. Postgres on next start saw
405
- # no standby.signal, came up as PRIMARY, started archiving WAL via
406
- # wal-g, and pg_is_in_recovery() returned 'f' for the entire 60s probe
407
- # window. Write everything ourselves — don't trust -R.
408
- touch /var/lib/postgresql/data/standby.signal
409
- # Replace postgresql.auto.conf wholesale (don't append — the upstream
410
- # image's first start can ALTER SYSTEM RESET ALL and wipe whatever -R
411
- # claimed to write). Both primary_conninfo and primary_slot_name are
412
- # load-bearing: missing primary_conninfo means recovery has nowhere
413
- # to read WAL from, missing primary_slot_name means primary won't
414
- # hold WAL for this standby and streaming silently falls behind.
415
- cat > /var/lib/postgresql/data/postgresql.auto.conf <<EOF
416
- # Do not edit this file manually!
417
- # It will be overwritten by the ALTER SYSTEM command.
418
- primary_conninfo = 'host=${primaryIp} port=5433 user=replicator password=${replPassword} application_name=standby'
419
- primary_slot_name = 'vibecarbon_standby_slot'
420
- EOF
421
- chown -R postgres:postgres /var/lib/postgresql/data
422
- # Final sanity checks. Each one is the postcondition for a different
423
- # downstream failure mode: PG_VERSION → docker compose start initdb
424
- # loop; standby.signal → postgres comes up as primary; primary_conninfo
425
- # in auto.conf → recovery has no source.
426
- test -f /var/lib/postgresql/data/PG_VERSION
427
- test -f /var/lib/postgresql/data/standby.signal
428
- grep -q '^primary_conninfo' /var/lib/postgresql/data/postgresql.auto.conf
429
- grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
413
+ // After the swap we pin primary_conninfo explicitly (last entry in
414
+ // postgresql.auto.conf wins): pg_basebackup -R records a conninfo from the
415
+ // backup connection, but we write our own anyway so it's byte-identical to
416
+ // what the shared builder produces. When probeFirst skips, the staged
417
+ // script exits 0 before reaching this tail, so the independent standby is
418
+ // left untouched.
419
+ const basebackupScript = `${buildStagedBasebackupScript({
420
+ replPassword,
421
+ primaryHost: primaryIp,
422
+ probeFirst: true,
423
+ label: 'compose-ha-repl',
424
+ })}
425
+ cat >> /var/lib/postgresql/data/postgresql.auto.conf <<'REPL_CONNINFO_EOF'
426
+ primary_conninfo = '${buildPrimaryConninfo({ primaryHost: primaryIp, replPassword })}'
427
+ REPL_CONNINFO_EOF
428
+ chown postgres:postgres /var/lib/postgresql/data/postgresql.auto.conf
430
429
  `;
431
- const basebackupB64 = Buffer.from(basebackupScript).toString('base64');
432
430
  try {
433
431
  await sshRun(
434
432
  standbyIp,
@@ -442,14 +440,22 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
442
440
  // missing standby.signal / wiped postgresql.auto.conf goes
443
441
  // undetected until pg_is_in_recovery() returns 'f' an entire
444
442
  // basebackup later. RCA: compose-ha 2026-05-01 fanout10 failover.
445
- `cd ${remoteDir} && echo ${basebackupB64} | base64 -d | docker compose run --rm -T --entrypoint bash db 2>&1`,
443
+ //
444
+ // The script (which embeds the replication password in PGPASSWORD +
445
+ // primary_conninfo) is piped to bash via STDIN (`input:` below), so it
446
+ // never appears in argv / the ssh command string. `-T` forwards stdin.
447
+ //
448
+ // Include the replication overlay for consistency with the other
449
+ // compose invocations against this server (docker-compose.replication.yml
450
+ // carries the REPL_PORT mapping used elsewhere in the HA flow).
451
+ `cd ${remoteDir} && docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.replication.yml run --rm -T --entrypoint bash db 2>&1`,
446
452
  // silent:true makes sshRun THROW on non-zero exit instead of returning
447
453
  // false. Without it, a failed basebackup (e.g. pg_hba.conf rejecting
448
454
  // the connection) is silently swallowed: try-catch never enters, code
449
455
  // proceeds to docker compose start db, probe loop times out, and the
450
456
  // operator sees "did not enter recovery mode within 60s" with no clue
451
457
  // that pg_basebackup never even ran. RCA: fanout12 2026-05-01.
452
- { timeout: 300_000, silent: true },
458
+ { timeout: 300_000, silent: true, input: basebackupScript },
453
459
  );
454
460
  } catch (err) {
455
461
  // Surface the actual basebackup output. sshRun on failure attaches
@@ -686,6 +692,7 @@ export async function deployComposeHA(options) {
686
692
  backupConfig = null,
687
693
  bundlePath = null,
688
694
  operatorCidrs = [],
695
+ allowDegraded = false,
689
696
  onProgress = () => {},
690
697
  } = options;
691
698
 
@@ -834,6 +841,9 @@ export async function deployComposeHA(options) {
834
841
  bucket: s3Config.bucket,
835
842
  region: s3Config.region,
836
843
  endpoint: s3Config.endpoint,
844
+ // Dedicated Pulumi-state bucket set by the orchestrator s3-setup step.
845
+ // Persisted so destroy / scale / failover resolve the same backend.
846
+ stateBucket: s3Config.stateBucket,
837
847
  },
838
848
  }),
839
849
  ...(backupS3Config && { backupS3: backupS3Config }),
@@ -860,6 +870,15 @@ export async function deployComposeHA(options) {
860
870
  throw new Error('Timed out waiting for SSH on one or both servers');
861
871
  }
862
872
 
873
+ // Seed the per-env known_hosts pin with BOTH servers' real host keys now that
874
+ // they answer SSH. Every subsequent ssh/scp (setupServerFiles, replication,
875
+ // failover) strict-checks against this file (accept-new), so pre-seeding via
876
+ // ssh-keyscan means we pin the real keys instead of blindly trusting whatever
877
+ // the first connection presents.
878
+ const haKnownHostsPath = knownHostsPathForKey(sshKeyPath);
879
+ await seedKnownHosts(haKnownHostsPath, primaryIp);
880
+ await seedKnownHosts(haKnownHostsPath, standbyIp);
881
+
863
882
  // Ensure Docker is running, configure firewall, enable auto-updates
864
883
  onProgress('Configuring servers...');
865
884
  await perfAsync('deploy.ha.compose.cloudInitReady.both', () =>
@@ -993,6 +1012,21 @@ export async function deployComposeHA(options) {
993
1012
  }
994
1013
  await perfAsync('deploy.ha.compose.setupFan', () => Promise.all(setupTasks));
995
1014
 
1015
+ // wal-g WRITE-GUARD (finding #3): write WALG_ROLE into each node's .env. It is
1016
+ // NOT a path segment — the S3 prefix is a single canonical
1017
+ // `…/walg` on both nodes so restore/reseed on the standby can READ the
1018
+ // primary's base backups. WALG_ROLE=standby instead makes wal-archive.sh +
1019
+ // compose-backup.sh no-op on the standby, so a standby never WRITES into the
1020
+ // canonical prefix (prevents WAL-timeline collision / split-brain corruption).
1021
+ // Runs after setupServerFiles copied the base .env; before compose-up so the
1022
+ // db container reads the role. mergeRemoteDotenv is idempotent across redeploys.
1023
+ const haSshOpts = pinnedSshOptsString(sshKeyPath);
1024
+ const haRemoteDir = `/opt/${projectName}`;
1025
+ await Promise.all([
1026
+ mergeRemoteDotenv(primaryIp, haSshOpts, haRemoteDir, { WALG_ROLE: 'primary' }),
1027
+ mergeRemoteDotenv(standbyIp, haSshOpts, haRemoteDir, { WALG_ROLE: 'standby' }),
1028
+ ]);
1029
+
996
1030
  // Pull app + base images on both servers in parallel. No remote build —
997
1031
  // image comes from ghcr.io (CI-built, multi-arch manifest; each node
998
1032
  // pulls its arch). Each server is independent; no cross-cluster dep.
@@ -1153,14 +1187,16 @@ export async function deployComposeHA(options) {
1153
1187
  }
1154
1188
 
1155
1189
  // Expose raw PostgreSQL on port 5433 for replication (supavisor owns 5432).
1156
- // Add a docker-compose.replication.yml overlay that maps db:5432 → host:5433.
1157
- // Fan out across primary + standby the overlay write + db-only `compose up`
1190
+ // The docker-compose.replication.yml overlay maps db:5432 → host:5433 on
1191
+ // both nodes so pg_basebackup + streaming can reach the peer. Fan out
1192
+ // across primary + standby — the overlay write + db-only `compose up`
1158
1193
  // touch disjoint VPS hosts and need to land on both before
1159
- // configurePrimaryReplication grants pub access. Previously serial via a
1160
- // for-loop using sync sshRun (~4 ssh calls × 2-3s each = ~8-12s blocking the
1161
- // event loop); Promise.all halves wall-clock and unblocks any concurrent
1162
- // perfAsync timers from being charged for the dead time.
1163
- const replOverlay = `version: "3.8"\nservices:\n db:\n ports:\n - "5433:5432"\n`;
1194
+ // configurePrimaryReplication grants pub access.
1195
+ //
1196
+ // TODO(wireguard-transport): tunnel bring-up Task 6. This overlay (direct
1197
+ // port-mapping, no TLS cert bind-mount) is the interim pre-WireGuard
1198
+ // transport; Task 6 replaces it with the wg0 tunnel.
1199
+ const replOverlay = 'version: "3.8"\nservices:\n db:\n ports:\n - "5433:5432"\n';
1164
1200
  await Promise.all(
1165
1201
  [primaryIp, standbyIp].map(async (ip) => {
1166
1202
  await sshRunAsync(
@@ -1185,7 +1221,7 @@ export async function deployComposeHA(options) {
1185
1221
  // dominated by pg_basebackup for the standby seed.
1186
1222
  await perfAsync('deploy.ha.replication.setup', async () => {
1187
1223
  onProgress('Configuring PostgreSQL replication on primary...');
1188
- await configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
1224
+ await configurePrimaryReplication(primaryIp, sshKeyPath, projectName);
1189
1225
 
1190
1226
  onProgress('Configuring standby as hot replica...');
1191
1227
  let replicationOk = false;
@@ -1197,13 +1233,15 @@ export async function deployComposeHA(options) {
1197
1233
  projectName,
1198
1234
  );
1199
1235
  } catch (err) {
1200
- // Soft-fail in deploy: replication has been chronically broken
1201
- // (see project_replication_broken.md) and hard-failing every
1202
- // compose-ha deploy here would block iteration on the rest of
1203
- // the scenario. Surface the captured pg_basebackup output so
1204
- // we can root-cause it, then continue with a clear warning.
1205
- // Failover-path still hard-fails (see line ~1185) so we don't
1206
- // cascade into a data-wipe + CrashLoopBackOff there.
1236
+ // DECISION (open, for the maintainer): replication failure is downgraded
1237
+ // to a WARNING and the deploy still finalizes as `deployed`. Replication
1238
+ // has been chronically fragile (memory: project_replication_broken) and
1239
+ // hard-failing every compose-ha deploy would block iteration on the rest
1240
+ // of the scenario. We keep the deploy green but report the REAL streaming
1241
+ // state below (never a hardcoded "streaming"). Promoting replication
1242
+ // failure to a hard deploy gate is a product decision NOT made here.
1243
+ // Failover-path still hard-fails (reseed abort) so a data-wipe +
1244
+ // CrashLoopBackOff can't cascade there.
1207
1245
  p.log.warn(`Replication setup error (continuing with warning): ${err.message}`);
1208
1246
  }
1209
1247
  if (!replicationOk) {
@@ -1217,30 +1255,53 @@ export async function deployComposeHA(options) {
1217
1255
  // hardcoded 1s/3s shell-out: fast happy path (probe at 250ms, 750ms,
1218
1256
  // 1.75s, …) without giving up too early under load. Total budget ~25s.
1219
1257
  onProgress('Verifying replication...');
1220
- const REPL_DELAYS_MS = [250, 500, 1000, 2000, 3000, 3000, 3000, 3000, 3000, 3000];
1221
- let replActive = false;
1222
- for (let i = 0; i < REPL_DELAYS_MS.length; i++) {
1223
- try {
1224
- const count = await sshRun(
1258
+ // Shared verify poller (finding #2) the k8s path uses the same primitive.
1259
+ // readState reports pg_stat_replication.state on the primary ('streaming' when
1260
+ // the standby is caught up, else another state / '' when no replica connected);
1261
+ // lastState feeds the finding-#1 hard-gate error message below.
1262
+ const { streaming: replActive, lastState: replLastState } = await verifyStreaming({
1263
+ readState: async () => {
1264
+ const state = await sshRun(
1225
1265
  primaryIp,
1226
1266
  sshKeyPath,
1227
- `cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'"`,
1267
+ `cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT state FROM pg_stat_replication ORDER BY (state = 'streaming') DESC LIMIT 1"`,
1228
1268
  { timeout: 10_000 },
1229
1269
  );
1230
- if (Number.parseInt(count?.trim(), 10) > 0) {
1231
- replActive = true;
1232
- break;
1233
- }
1234
- } catch {
1235
- // Retry
1236
- }
1237
- if (i === REPL_DELAYS_MS.length - 1) break;
1238
- await new Promise((r) => setTimeout(r, REPL_DELAYS_MS[i]));
1239
- }
1270
+ return typeof state === 'string' ? state.trim() : '';
1271
+ },
1272
+ attempts: 10,
1273
+ delaysMs: [250, 500, 1000, 2000, 3000],
1274
+ });
1275
+
1276
+ // DECISION (finding #1, hard-gate-by-default): a compose-HA deploy whose
1277
+ // standby is NOT verifiably streaming now FAILS by default rather than
1278
+ // finalizing as `deployed` with a warning. Warm-standby / degraded DR is only
1279
+ // accepted when the operator explicitly opts in with `-allow-degraded`
1280
+ // (options.allowDegraded). The failover path already hard-fails a bad re-seed;
1281
+ // this brings the deploy path to the same posture so a green `deployed` always
1282
+ // means a real replica exists. Reuses the honest pg_stat_replication verify
1283
+ // above (never a hardcoded "streaming").
1284
+ // Shared hard-gate (finding #1): throws by default when not streaming; with
1285
+ // -allow-degraded it returns { degraded: true } so we finalize a warm standby.
1286
+ const { degraded } = assertReplicationStreamingOrDegraded({
1287
+ streaming: replActive,
1288
+ lastState: replLastState,
1289
+ allowDegraded,
1290
+ fixHint:
1291
+ 'Verify the replication port (5433) is open between the two regions and the standby ' +
1292
+ 'completed pg_basebackup.',
1293
+ });
1240
1294
  if (replActive) {
1241
1295
  p.log.success('Streaming replication active');
1242
1296
  } else {
1243
- p.log.warn('Streaming replication not detected — standby may fall behind');
1297
+ p.log.warn(
1298
+ 'DEGRADED HA (-allow-degraded): streaming replication NOT detected ' +
1299
+ `(last pg_stat_replication.state=${
1300
+ replLastState ? JSON.stringify(replLastState) : 'no replica connected'
1301
+ }). The standby is a warm/cold spare and may be missing recent writes — ` +
1302
+ 'disaster recovery is NOT guaranteed. Resync with `vibecarbon deploy` (no ' +
1303
+ '-allow-degraded) once the replication issue is resolved.',
1304
+ );
1244
1305
  }
1245
1306
 
1246
1307
  // Install the scheduled wal-g backup cron on BOTH nodes. Invariant 2: the
@@ -1285,6 +1346,11 @@ export async function deployComposeHA(options) {
1285
1346
  [environment]: {
1286
1347
  ...pendingEnvConfig,
1287
1348
  status: 'deployed',
1349
+ // Record DR posture so `status` and downstream commands can see the env
1350
+ // is running with a warm-standby (only reachable via -allow-degraded;
1351
+ // the default gate throws before this point when not streaming).
1352
+ replication: degraded ? 'degraded' : 'streaming',
1353
+ degraded,
1288
1354
  deployedAt: new Date().toISOString(),
1289
1355
  lastAttempt: undefined,
1290
1356
  },
@@ -1300,7 +1366,17 @@ export async function deployComposeHA(options) {
1300
1366
  p.log.message(` ${c.dim('Primary')} ${c.info(primaryIp)} (${region})`);
1301
1367
  p.log.message(` ${c.dim('Standby')} ${c.info(standbyIp)} (${standbyRegion})`);
1302
1368
  p.log.message(` ${c.dim('Deploy method')} Docker Compose HA`);
1303
- p.log.message(` ${c.dim('Replication')} PostgreSQL streaming`);
1369
+ // Report the REAL replication state (verified via pg_stat_replication above),
1370
+ // not a hardcoded "streaming". A standby that isn't streaming means DR is not
1371
+ // guaranteed — say so loudly rather than implying a healthy replica.
1372
+ if (replActive) {
1373
+ p.log.message(` ${c.dim('Replication')} ${c.success('PostgreSQL streaming')}`);
1374
+ } else {
1375
+ // Only reachable via -allow-degraded (the default gate threw otherwise).
1376
+ p.log.message(
1377
+ ` ${c.dim('Replication')} ${c.warning('DEGRADED (warm standby) — NOT streaming; DR is not guaranteed')}`,
1378
+ );
1379
+ }
1304
1380
  p.log.message('');
1305
1381
 
1306
1382
  return {
@@ -1309,6 +1385,8 @@ export async function deployComposeHA(options) {
1309
1385
  standbyIp,
1310
1386
  domain,
1311
1387
  deployMode: 'compose-ha',
1388
+ degraded,
1389
+ replication: degraded ? 'degraded' : 'streaming',
1312
1390
  };
1313
1391
  }
1314
1392
 
@@ -17,9 +17,10 @@ import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import * as p from '@clack/prompts';
19
19
  import { runCommandAsync } from '../../command.js';
20
+ import { knownHostsPathForKey } from '../../host-keys.js';
20
21
  import { perfAsync, perfTimer } from '../../perf.js';
21
22
  import { parseDotenv, shEscape } from '../../shell.js';
22
- import { NO_PIN_SSH_OPTS, sshRunScript } from '../../ssh.js';
23
+ import { sshRunScript } from '../../ssh.js';
23
24
  import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
24
25
 
25
26
  const __filename = fileURLToPath(import.meta.url);
@@ -41,12 +42,52 @@ export function loadCloudInitScript() {
41
42
  return readFileSync(CLOUD_INIT_PATH, 'utf-8');
42
43
  }
43
44
 
44
- // Single-sourced from lib/ssh.js (sshHostKeyOpts) so the string form used by
45
- // the runners below can't drift from the argv form notably keeping the
46
- // ServerAlive keepalives that fail a banner-exchange hang fast instead of
47
- // stalling for ~600s. Re-exported as the canonical compose deploy SSH opts.
48
- // shell-safety-ignore: NO_PIN_SSH_OPTS is derived from lib/ssh.js sshHostKeyOpts(), which bakes in BatchMode=yes (validated there)
49
- const SSH_OPTS = NO_PIN_SSH_OPTS;
45
+ /**
46
+ * Build the compose deploy path's SSH `-o` option tokens, pinning host keys
47
+ * against the per-env known_hosts file DERIVED from the key path
48
+ * (knownHostsPathForKey) so this path no longer uses UserKnownHostsFile=
49
+ * /dev/null + StrictHostKeyChecking=no. accept-new bootstraps an empty pin and
50
+ * TOFU's a fresh/recycled Hetzner IP, but rejects a changed key for an
51
+ * already-pinned host (MITM on an established env fails). GlobalKnownHostsFile=
52
+ * /dev/null ignores the system file; the operator's ~/.ssh/known_hosts is never
53
+ * touched. Keeps the ServerAlive keepalives that fail a banner-exchange hang
54
+ * fast (~60s) instead of stalling ~600s.
55
+ *
56
+ * @param {string} sshKeyPath
57
+ * @returns {string[]} argv `-o` tokens
58
+ */
59
+ function composeSshOpts(sshKeyPath) {
60
+ return [
61
+ '-o',
62
+ `UserKnownHostsFile=${knownHostsPathForKey(sshKeyPath)}`,
63
+ '-o',
64
+ 'GlobalKnownHostsFile=/dev/null',
65
+ '-o',
66
+ 'StrictHostKeyChecking=accept-new',
67
+ '-o',
68
+ 'BatchMode=yes',
69
+ '-o',
70
+ 'ConnectTimeout=10',
71
+ '-o',
72
+ 'ServerAliveInterval=15',
73
+ '-o',
74
+ 'ServerAliveCountMax=4',
75
+ ];
76
+ }
77
+
78
+ /**
79
+ * The same opts as a shell-safe space-joined string for the string-command
80
+ * runners / raw `ssh ...` interpolations below. The known_hosts path is
81
+ * shEscaped so a project dir with spaces can't split the argument.
82
+ *
83
+ * @param {string} sshKeyPath
84
+ * @returns {string}
85
+ */
86
+ function composeSshOptsString(sshKeyPath) {
87
+ return composeSshOpts(sshKeyPath)
88
+ .map((tok) => (tok.startsWith('UserKnownHostsFile=') ? shEscape(tok) : tok))
89
+ .join(' ');
90
+ }
50
91
 
51
92
  /**
52
93
  * Run a command on a remote server via SSH.
@@ -58,7 +99,8 @@ export async function sshRun(ip, sshKeyPath, command, options = {}) {
58
99
  const { timeout = 120_000, ...rest } = options;
59
100
  try {
60
101
  return await runCommandAsync(
61
- ['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command],
102
+ // shell-safety-ignore: composeSshOpts() bakes in BatchMode=yes (validated in its definition)
103
+ ['ssh', ...composeSshOpts(sshKeyPath), '-i', sshKeyPath, `root@${ip}`, command],
62
104
  { silent: true, timeout, ...rest },
63
105
  );
64
106
  } catch {
@@ -76,7 +118,7 @@ export async function sshRun(ip, sshKeyPath, command, options = {}) {
76
118
  */
77
119
  export async function sshRunAsync(ip, sshKeyPath, command, options = {}) {
78
120
  const { timeout = 120_000, retries = 3, ignoreError, ...rest } = options;
79
- const sshArgs = ['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command];
121
+ const sshArgs = ['ssh', ...composeSshOpts(sshKeyPath), '-i', sshKeyPath, `root@${ip}`, command];
80
122
  // Strip ignoreError from inner call so connection errors always throw and
81
123
  // the retry loop can catch them. We apply ignoreError ourselves after retries.
82
124
  const runOpts = { silent: true, timeout, ...rest };
@@ -140,8 +182,9 @@ export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
140
182
  // this returned true on the very first probe regardless of SSH state.)
141
183
  await runCommandAsync(
142
184
  [
185
+ // shell-safety-ignore: composeSshOpts() bakes in BatchMode=yes (validated in its definition)
143
186
  'ssh',
144
- ...SSH_OPTS.split(' '),
187
+ ...composeSshOpts(sshKeyPath),
145
188
  '-i',
146
189
  sshKeyPath,
147
190
  '-o',
@@ -367,13 +410,17 @@ export async function setupServerFiles(ip, sshKeyPath, projectName, options = {}
367
410
  [
368
411
  'bash',
369
412
  '-c',
370
- 'cat "$1" | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -i "$2" "root@$3" "mkdir -p $4/backups && tar --no-xattrs --no-same-owner -xzf - -C $4 && cp $4/$5.service /etc/systemd/system/ && systemctl daemon-reload"',
413
+ // Host-key pinned: accept-new against the per-env known_hosts
414
+ // ($6, a positional so the path is safely quoted), never
415
+ // /dev/null + StrictHostKeyChecking=no.
416
+ 'cat "$1" | ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$6" -o GlobalKnownHostsFile=/dev/null -o BatchMode=yes -i "$2" "root@$3" "mkdir -p $4/backups && tar --no-xattrs --no-same-owner -xzf - -C $4 && cp $4/$5.service /etc/systemd/system/ && systemctl daemon-reload"',
371
417
  '--',
372
418
  tarPath,
373
419
  sshKeyPath,
374
420
  ip,
375
421
  remoteDir,
376
422
  projectName,
423
+ knownHostsPathForKey(sshKeyPath),
377
424
  ],
378
425
  { silent: true, timeout: 300_000 },
379
426
  ),
@@ -459,8 +506,9 @@ export async function transferImageBetweenServers(sourceIp, destIp, sshKeyPath,
459
506
  // images compress 3-5x; without gzip a ~600MB save streams uncompressed
460
507
  // across two regions on top of the operator's residential upload, which
461
508
  // historically tripped the 600s timeout under load.
462
- const saveCmd = `ssh ${SSH_OPTS} -i ${shEscape(sshKeyPath)} root@${sourceIp} docker save ${shEscape(imageRef)}`;
463
- const loadCmd = `ssh ${SSH_OPTS} -i ${shEscape(sshKeyPath)} root@${destIp} 'gunzip | docker load'`;
509
+ const sshOpts = composeSshOptsString(sshKeyPath);
510
+ const saveCmd = `ssh ${sshOpts} -i ${shEscape(sshKeyPath)} root@${sourceIp} docker save ${shEscape(imageRef)}`;
511
+ const loadCmd = `ssh ${sshOpts} -i ${shEscape(sshKeyPath)} root@${destIp} 'gunzip | docker load'`;
464
512
  await runCommandAsync(['bash', '-c', `set -o pipefail && ${saveCmd} | gzip -1 | ${loadCmd}`], {
465
513
  silent: true,
466
514
  timeout: 600_000,
@@ -534,7 +582,7 @@ export async function startComposeStack(ip, sshKeyPath, projectName, _options =
534
582
  // after the wrapper timed out) but the timer ate the run. 30 min covers
535
583
  // a slow-pull cold start on any of the three S3 regions plus margin
536
584
  // without masking a true regression (a real hang would still surface
537
- // via the 60s ServerAlive keepalives in lib/ssh.js + compose SSH_OPTS).
585
+ // via the 60s ServerAlive keepalives in lib/ssh.js + composeSshOpts).
538
586
  await perfAsync('deploy.compose.up', () =>
539
587
  sshRunAsync(ip, sshKeyPath, `/bin/bash ${remoteDir}/reconcile.sh`, { timeout: 1_800_000 }),
540
588
  );
@@ -1193,12 +1241,7 @@ export async function createAdminUser(serverIp, sshKeyPath, projectName) {
1193
1241
  const pf = spawn(
1194
1242
  'ssh',
1195
1243
  [
1196
- '-o',
1197
- 'StrictHostKeyChecking=no',
1198
- '-o',
1199
- 'UserKnownHostsFile=/dev/null',
1200
- '-o',
1201
- 'BatchMode=yes',
1244
+ ...composeSshOpts(sshKeyPath),
1202
1245
  '-i',
1203
1246
  sshKeyPath,
1204
1247
  '-N',