vibecarbon 0.9.0 → 0.11.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 (88) 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 +968 -886
  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 +106 -57
  60. package/src/lib/deploy/compose/single.js +421 -0
  61. package/src/lib/deploy/image.js +38 -30
  62. package/src/lib/deploy/k8s/ha/index.js +397 -150
  63. package/src/lib/deploy/k8s/k3s.js +411 -279
  64. package/src/lib/deploy/orchestrator.js +274 -453
  65. package/src/lib/deploy/remote-build.js +8 -1
  66. package/src/lib/deploy/replication.js +540 -0
  67. package/src/lib/deploy/state.js +62 -5
  68. package/src/lib/deploy/tier-registry.js +30 -0
  69. package/src/lib/deploy/utils.js +30 -2
  70. package/src/lib/deploy/wireguard.js +146 -0
  71. package/src/lib/host-keys.js +120 -5
  72. package/src/lib/iac/index.js +57 -23
  73. package/src/lib/licensing/gate.js +59 -0
  74. package/src/lib/licensing/index.js +1 -1
  75. package/src/lib/licensing/tiers.js +6 -5
  76. package/src/lib/prod-confirm.js +65 -0
  77. package/src/lib/providers/hetzner-s3.js +85 -0
  78. package/src/lib/retry.js +59 -0
  79. package/src/lib/scale-plan.js +159 -0
  80. package/src/lib/ssh.js +35 -35
  81. package/src/restore.js +102 -14
  82. package/src/scale.js +105 -133
  83. package/src/status.js +122 -0
  84. package/src/upgrade.js +0 -4
  85. package/carbon/ha/activate-standby.sh +0 -52
  86. package/carbon/ha/standby-init.sh +0 -74
  87. package/carbon/src/server/lib/request.ts +0 -34
  88. package/src/lib/pod-backups.js +0 -53
@@ -0,0 +1,76 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // getClientIp imports env (which validates real env vars at import time) and
4
+ // logger. Mock both so this unit test is hermetic and can drive NODE_ENV /
5
+ // TRUSTED_PROXY_HOPS directly.
6
+ const { fakeEnv } = vi.hoisted(() => ({
7
+ fakeEnv: { NODE_ENV: 'production', TRUSTED_PROXY_HOPS: 1 } as {
8
+ NODE_ENV: string;
9
+ TRUSTED_PROXY_HOPS: number;
10
+ },
11
+ }));
12
+
13
+ vi.mock('@server/lib/env', () => ({ env: fakeEnv }));
14
+ vi.mock('@server/lib/logger', () => ({
15
+ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
16
+ }));
17
+
18
+ const { getClientIp } = await import('@server/lib/client-ip');
19
+
20
+ // Minimal context double: only needs req.header(name).
21
+ function ctx(headers: Record<string, string>) {
22
+ const lower: Record<string, string> = {};
23
+ for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
24
+ return { req: { header: (name: string) => lower[name.toLowerCase()] } };
25
+ }
26
+
27
+ beforeEach(() => {
28
+ fakeEnv.NODE_ENV = 'production';
29
+ fakeEnv.TRUSTED_PROXY_HOPS = 1;
30
+ });
31
+
32
+ describe('getClientIp — trusted-proxy-aware resolution', () => {
33
+ it('returns the entry Traefik appended (rightmost) with a single trusted hop', () => {
34
+ // Traefik appends the real client IP to the right of whatever the client sent.
35
+ const ip = getClientIp(ctx({ 'x-forwarded-for': '203.0.113.9, 198.51.100.7' }));
36
+ expect(ip).toBe('198.51.100.7');
37
+ });
38
+
39
+ it('SECURITY: a spoofed leftmost XFF cannot change the resolved identity', () => {
40
+ // Same real client (rightmost), different attacker-supplied leftmost values.
41
+ // If the resolver keyed off the leftmost, these would resolve differently and
42
+ // let an attacker mint a fresh rate-limit / lockout bucket per request.
43
+ const a = getClientIp(ctx({ 'x-forwarded-for': '1.1.1.1, 198.51.100.7' }));
44
+ const b = getClientIp(ctx({ 'x-forwarded-for': '2.2.2.2, 198.51.100.7' }));
45
+ const c = getClientIp(ctx({ 'x-forwarded-for': 'attacker-junk, 198.51.100.7' }));
46
+ expect(a).toBe('198.51.100.7');
47
+ expect(b).toBe('198.51.100.7');
48
+ expect(c).toBe('198.51.100.7');
49
+ });
50
+
51
+ it('handles a single-entry XFF (direct client → Traefik)', () => {
52
+ expect(getClientIp(ctx({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7');
53
+ });
54
+
55
+ it('honours a deeper trusted-proxy hop count (counts from the right)', () => {
56
+ fakeEnv.TRUSTED_PROXY_HOPS = 2;
57
+ const ip = getClientIp(ctx({ 'x-forwarded-for': '1.1.1.1, 198.51.100.7, 203.0.113.1' }));
58
+ expect(ip).toBe('198.51.100.7');
59
+ });
60
+
61
+ it('does NOT trust x-real-ip / cf-connecting-ip (Traefik does not set them)', () => {
62
+ const ip = getClientIp(
63
+ ctx({ 'x-real-ip': '6.6.6.6', 'cf-connecting-ip': '7.7.7.7' })
64
+ );
65
+ expect(ip).toBe('unknown');
66
+ });
67
+
68
+ it('fails closed to a shared sentinel when no trusted IP is derivable (prod)', () => {
69
+ expect(getClientIp(ctx({}))).toBe('unknown');
70
+ });
71
+
72
+ it('falls back to localhost in development', () => {
73
+ fakeEnv.NODE_ENV = 'development';
74
+ expect(getClientIp(ctx({}))).toBe('localhost');
75
+ });
76
+ });
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // env.ts validates process.env at import time and would call process.exit on
4
+ // failure, so we always supply the required vars and only vary the booleans.
5
+ const REQUIRED = {
6
+ SUPABASE_URL: 'http://supabase.test',
7
+ SUPABASE_ANON_KEY: 'anon',
8
+ SUPABASE_SERVICE_ROLE_KEY: 'service',
9
+ NODE_ENV: 'test',
10
+ };
11
+
12
+ const TOUCHED = [
13
+ ...Object.keys(REQUIRED),
14
+ 'GOOGLE_ENABLED',
15
+ 'MAGIC_LINK_ENABLED',
16
+ ];
17
+
18
+ const saved: Record<string, string | undefined> = {};
19
+
20
+ async function loadEnv(overrides: Record<string, string | undefined>) {
21
+ for (const k of TOUCHED) saved[k] = process.env[k];
22
+ vi.resetModules();
23
+ for (const [k, v] of Object.entries({ ...REQUIRED, ...overrides })) {
24
+ if (v === undefined) delete process.env[k];
25
+ else process.env[k] = v;
26
+ }
27
+ const mod = await import('@server/lib/env');
28
+ return mod.env;
29
+ }
30
+
31
+ afterEach(() => {
32
+ for (const [k, v] of Object.entries(saved)) {
33
+ if (v === undefined) delete process.env[k];
34
+ else process.env[k] = v;
35
+ }
36
+ });
37
+
38
+ describe('env boolean parsing', () => {
39
+ it('SECURITY: parses the literal "false" as false (z.coerce.boolean did NOT)', async () => {
40
+ const env = await loadEnv({ GOOGLE_ENABLED: 'false' });
41
+ expect(env.GOOGLE_ENABLED).toBe(false);
42
+ });
43
+
44
+ it('parses "true" as true', async () => {
45
+ const env = await loadEnv({ GOOGLE_ENABLED: 'true' });
46
+ expect(env.GOOGLE_ENABLED).toBe(true);
47
+ });
48
+
49
+ it('applies the default (false) when unset', async () => {
50
+ const env = await loadEnv({ GOOGLE_ENABLED: undefined });
51
+ expect(env.GOOGLE_ENABLED).toBe(false);
52
+ });
53
+
54
+ it('applies the default (true) for MAGIC_LINK_ENABLED when unset', async () => {
55
+ const env = await loadEnv({ MAGIC_LINK_ENABLED: undefined });
56
+ expect(env.MAGIC_LINK_ENABLED).toBe(true);
57
+ });
58
+
59
+ it('treats any non-"true" string as false', async () => {
60
+ const env = await loadEnv({ GOOGLE_ENABLED: '1' });
61
+ expect(env.GOOGLE_ENABLED).toBe(false);
62
+ });
63
+ });
@@ -0,0 +1,57 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ // `test_`-prefixed so the repo secret-scanner treats it as an obvious test
5
+ // fixture (not a real leaked secret) — the HMAC key value is arbitrary here.
6
+ const SECRET = 'test_pdl_webhook_secret';
7
+
8
+ vi.mock('@server/lib/env', () => ({
9
+ env: { PADDLE_WEBHOOK_SECRET: SECRET, PADDLE_ENVIRONMENT: 'sandbox' },
10
+ }));
11
+ vi.mock('@server/lib/logger', () => ({
12
+ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
13
+ }));
14
+
15
+ const { PaddleProvider } = await import('@server/billing/providers/paddle');
16
+
17
+ const body = JSON.stringify({ event_type: 'subscription.created', data: { id: 'sub_1' } });
18
+
19
+ function sign(ts: number, payloadBody: string): string {
20
+ const hash = createHmac('sha256', SECRET).update(`${ts}:${payloadBody}`).digest('hex');
21
+ return `ts=${ts};h1=${hash}`;
22
+ }
23
+
24
+ describe('PaddleProvider webhook verification', () => {
25
+ const provider = new PaddleProvider();
26
+
27
+ it('accepts a validly signed, recent event', async () => {
28
+ const ts = Math.floor(Date.now() / 1000);
29
+ const result = await provider.handleWebhook({ body, signature: sign(ts, body) });
30
+ expect(result.type).toBe('subscription.created');
31
+ });
32
+
33
+ it('SECURITY: rejects a stale event outside the 5-minute tolerance (replay)', async () => {
34
+ const staleTs = Math.floor(Date.now() / 1000) - 6 * 60; // 6 minutes old
35
+ await expect(
36
+ provider.handleWebhook({ body, signature: sign(staleTs, body) })
37
+ ).rejects.toThrow(/timestamp outside tolerance/);
38
+ });
39
+
40
+ it('SECURITY: rejects a tampered signature', async () => {
41
+ const ts = Math.floor(Date.now() / 1000);
42
+ const good = sign(ts, body);
43
+ // Flip the last hex char of h1 to forge a mismatch of equal length.
44
+ const tampered = good.replace(/.$/, (ch) => (ch === '0' ? '1' : '0'));
45
+ await expect(
46
+ provider.handleWebhook({ body, signature: tampered })
47
+ ).rejects.toThrow(/Invalid Paddle webhook signature/);
48
+ });
49
+
50
+ it('rejects a signature computed over a different body', async () => {
51
+ const ts = Math.floor(Date.now() / 1000);
52
+ const sigForOtherBody = sign(ts, '{"event_type":"other"}');
53
+ await expect(
54
+ provider.handleWebhook({ body, signature: sigForOtherBody })
55
+ ).rejects.toThrow(/Invalid Paddle webhook signature/);
56
+ });
57
+ });
@@ -0,0 +1,63 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ // Polar secrets are base64, prefixed with "whsec_".
5
+ const SECRET_RAW = Buffer.from('polar_test_webhook_secret').toString('base64');
6
+ const SECRET = `whsec_${SECRET_RAW}`;
7
+
8
+ vi.mock('@server/lib/env', () => ({
9
+ env: { POLAR_WEBHOOK_SECRET: SECRET, POLAR_ENVIRONMENT: 'sandbox' },
10
+ }));
11
+ vi.mock('@server/lib/logger', () => ({
12
+ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
13
+ }));
14
+
15
+ const { PolarProvider } = await import('@server/billing/providers/polar');
16
+
17
+ const body = JSON.stringify({ type: 'subscription.created', data: { id: 'sub_1' } });
18
+ const WEBHOOK_ID = 'msg_1';
19
+
20
+ // Mirror the combined header the billing webhook route builds:
21
+ // `${webhookId}.${timestamp}.v1,${signature}`
22
+ function sign(ts: number, payloadBody: string): string {
23
+ const secretBytes = Buffer.from(SECRET_RAW, 'base64');
24
+ const sig = createHmac('sha256', secretBytes)
25
+ .update(`${WEBHOOK_ID}.${ts}.${payloadBody}`)
26
+ .digest('base64');
27
+ return `${WEBHOOK_ID}.${ts}.v1,${sig}`;
28
+ }
29
+
30
+ describe('PolarProvider webhook verification', () => {
31
+ const provider = new PolarProvider();
32
+
33
+ it('accepts a validly signed, recent event', async () => {
34
+ const ts = Math.floor(Date.now() / 1000);
35
+ await expect(
36
+ provider.handleWebhook({ body, signature: sign(ts, body) })
37
+ ).resolves.toBeDefined();
38
+ });
39
+
40
+ it('SECURITY: rejects a stale event outside the 5-minute tolerance (replay)', async () => {
41
+ const staleTs = Math.floor(Date.now() / 1000) - 6 * 60; // 6 minutes old
42
+ await expect(
43
+ provider.handleWebhook({ body, signature: sign(staleTs, body) })
44
+ ).rejects.toThrow(/timestamp outside tolerance/);
45
+ });
46
+
47
+ it('SECURITY: rejects a tampered signature', async () => {
48
+ const ts = Math.floor(Date.now() / 1000);
49
+ const good = sign(ts, body);
50
+ const tampered = good.replace(/.$/, (ch) => (ch === 'A' ? 'B' : 'A'));
51
+ await expect(
52
+ provider.handleWebhook({ body, signature: tampered })
53
+ ).rejects.toThrow(/Invalid Polar webhook signature/);
54
+ });
55
+
56
+ it('rejects a signature computed over a different body', async () => {
57
+ const ts = Math.floor(Date.now() / 1000);
58
+ const sigForOtherBody = sign(ts, '{"type":"other"}');
59
+ await expect(
60
+ provider.handleWebhook({ body, signature: sigForOtherBody })
61
+ ).rejects.toThrow(/Invalid Polar webhook signature/);
62
+ });
63
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ const ENV = {
4
+ SUPABASE_URL: 'http://supabase.test',
5
+ SUPABASE_ANON_KEY: 'the-anon-key',
6
+ SUPABASE_SERVICE_ROLE_KEY: 'the-service-role-key',
7
+ };
8
+
9
+ vi.mock('@server/lib/env', () => ({ env: ENV }));
10
+
11
+ const { createClientMock } = vi.hoisted(() => ({ createClientMock: vi.fn(() => ({})) }));
12
+ vi.mock('@supabase/supabase-js', () => ({ createClient: createClientMock }));
13
+
14
+ const { createSupabaseClient } = await import('@server/lib/supabase');
15
+
16
+ describe('createSupabaseClient (per-user, RLS-enforced client)', () => {
17
+ it('SECURITY: uses the ANON key (not service role) as the apikey, with the user JWT', () => {
18
+ createClientMock.mockClear();
19
+ createSupabaseClient('user-jwt-token');
20
+
21
+ expect(createClientMock).toHaveBeenCalledTimes(1);
22
+ const [url, key, options] = createClientMock.mock.calls[0];
23
+ expect(url).toBe(ENV.SUPABASE_URL);
24
+ // Fail-safe: if the Authorization header is ever dropped, this degrades to
25
+ // the anon role under RLS, NOT to service_role BYPASSRLS.
26
+ expect(key).toBe(ENV.SUPABASE_ANON_KEY);
27
+ expect(key).not.toBe(ENV.SUPABASE_SERVICE_ROLE_KEY);
28
+ expect(options?.global?.headers?.Authorization).toBe('Bearer user-jwt-token');
29
+ });
30
+ });
@@ -25,6 +25,42 @@ WAL_PATH="${1:?archive_command requires a WAL file path}"
25
25
  RETRIES="${WAL_ARCHIVE_RETRIES:-3}"
26
26
  SLEEP_BASE="${WAL_ARCHIVE_SLEEP:-2}"
27
27
 
28
+ # WRITE-GUARD (finding #3): a standby node must NEVER push WAL into the single
29
+ # canonical WALG_S3_PREFIX. WALG_ROLE=standby is set on the standby's db env
30
+ # (compose .env / k8s supabase.values.yaml). This closes the bring-up window
31
+ # that the pg_is_in_recovery() gate below can't: a freshly-provisioned standby
32
+ # db can briefly be an INDEPENDENT primary (not yet in recovery) before it is
33
+ # reseeded — WALG_ROLE=standby stops it archiving into the primary's stream even
34
+ # then. Exit 0 (not error) so PG recycles the segment instead of pinning WAL.
35
+ if [ "${WALG_ROLE:-primary}" = "standby" ]; then
36
+ echo "wal-archive: WALG_ROLE=standby — skipping archive of '${WAL_PATH}' (only the primary writes to the canonical prefix)." >&2
37
+ exit 0
38
+ fi
39
+
40
+ # Recovery gate: a node in recovery (a streaming standby) must NEVER archive
41
+ # WAL. Doing so would let a standby write into the shared/canonical WAL stream
42
+ # and corrupt the PITR timeline (invisible until a restore during an incident).
43
+ # PostgreSQL with archive_mode=on already skips archive_command during recovery,
44
+ # but gate explicitly as defense in depth (and in case archive_mode=always is
45
+ # ever set, or a split-brain leaves two writers). Only SKIP when we can
46
+ # POSITIVELY confirm recovery — never fail-closed in a way that would drop a
47
+ # real primary's WAL.
48
+ PGDATA_DIR="${PGDATA:-/var/lib/postgresql/data}"
49
+ in_recovery=""
50
+ if command -v psql >/dev/null 2>&1; then
51
+ in_recovery="$(psql -U "${PGUSER:-supabase_admin}" -d postgres -tAXc 'SELECT pg_is_in_recovery()' 2>/dev/null | tr -d '[:space:]')"
52
+ fi
53
+ # Fall back to the standby.signal marker if psql couldn't answer (e.g. socket
54
+ # not reachable from the archive_command context). Postgres removes this file
55
+ # atomically on promotion, so its presence == "still a standby".
56
+ if [ -z "$in_recovery" ] && [ -f "${PGDATA_DIR}/standby.signal" ]; then
57
+ in_recovery="t"
58
+ fi
59
+ if [ "$in_recovery" = "t" ]; then
60
+ echo "wal-archive: node is in recovery (standby) — skipping archive of '${WAL_PATH}' (only the primary archives)." >&2
61
+ exit 0
62
+ fi
63
+
28
64
  attempt=1
29
65
  while [ "$attempt" -le "$RETRIES" ]; do
30
66
  if /usr/local/bin/wal-g wal-push "$WAL_PATH"; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
@@ -51,7 +51,7 @@
51
51
  "boilerplate"
52
52
  ],
53
53
  "author": "",
54
- "license": "AGPL-3.0-only",
54
+ "license": "FSL-1.1-MIT",
55
55
  "repository": {
56
56
  "type": "git",
57
57
  "url": "https://github.com/hyperformant/vibecarbon.git"
package/src/backup.js CHANGED
@@ -28,7 +28,6 @@ import {
28
28
  loadS3Config,
29
29
  } from './lib/config.js';
30
30
  import { getS3Credentials } from './lib/hetzner-guided-setup.js';
31
- import { requireLicense } from './lib/licensing/index.js';
32
31
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
33
32
  import { perfAsync } from './lib/perf.js';
34
33
  import { assertInProjectDir } from './lib/project-guard.js';
@@ -307,7 +306,6 @@ export async function run(args) {
307
306
  // backup` from a parent directory emits the canonical message.
308
307
  assertInProjectDir();
309
308
 
310
- requireLicense('backup');
311
309
  printBanner();
312
310
  p.intro(`${c.bold('vibecarbon backup')} ${c.dim(`v${VERSION}`)}`);
313
311
 
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import dns from 'node:dns';
16
16
  import { realpathSync } from 'node:fs';
17
17
  import { c } from './lib/colors.js';
18
+ import { shouldGate } from './lib/licensing/gate.js';
18
19
  import { perfTimer } from './lib/perf.js';
19
20
  import { VERSION } from './lib/version.js';
20
21
 
@@ -80,6 +81,7 @@ export const KNOWN_COMMANDS = [
80
81
  'shell',
81
82
  'diagnose',
82
83
  'console',
84
+ 'access',
83
85
  ];
84
86
 
85
87
  function showHelp() {
@@ -184,6 +186,20 @@ async function main() {
184
186
  // e2e runner forces it on so we always have total + sub-step
185
187
  // breakdowns per CLI invocation.
186
188
  const subcommandArgs = args.slice(1);
189
+
190
+ // Central license gate: paid commands are gated HERE, pre-dispatch, so a
191
+ // new command can never ship unguarded (COMMAND_GATES must classify every
192
+ // KNOWN_COMMAND — enforced by tests/unit/licensing/command-gates.test.ts).
193
+ // Ordering contract: the canonical "not in a project" refusal must win
194
+ // over the license upsell (all paid commands are project-scoped), and
195
+ // -h/-v invocations are never gated — shouldGate() handles the bypass.
196
+ if (shouldGate(command, subcommandArgs)) {
197
+ const { assertInProjectDir } = await import('./lib/project-guard.js');
198
+ assertInProjectDir();
199
+ const { requireLicense } = await import('./lib/licensing/index.js');
200
+ requireLicense(command);
201
+ }
202
+
187
203
  const commandTimer = perfTimer(`cli.${command}.total`);
188
204
 
189
205
  switch (command) {
package/src/deploy.js CHANGED
@@ -50,7 +50,6 @@ import {
50
50
  setupHA as setupHetznerDnsHA,
51
51
  setupSimple as setupHetznerDnsSimple,
52
52
  } from './lib/hetzner-dns.js';
53
- import { requireLicense } from './lib/licensing/index.js';
54
53
  import { detectPackageManager } from './lib/project.js';
55
54
  import { assertInProjectDir } from './lib/project-guard.js';
56
55
  import { HetznerS3Provider, sanitizeBucketName } from './lib/providers/hetzner-s3.js';
@@ -124,6 +123,12 @@ const SPEC = {
124
123
  description:
125
124
  'Disaster recovery: seed the fresh DB from the latest wal-g backup in S3 (or PITR to an ISO-8601 timestamp). Skips migrations — the restored DB is authoritative. k8s only.',
126
125
  },
126
+ {
127
+ name: 'allow-degraded',
128
+ boolean: true,
129
+ description:
130
+ 'HA only: proceed even if the standby is not verifiably streaming (warm-standby / degraded DR). By default an HA deploy FAILS unless replication is confirmed streaming.',
131
+ },
127
132
  ],
128
133
  examples: [
129
134
  { command: 'vibecarbon deploy', description: 'prompts for env (defaults to prod)' },
@@ -140,6 +145,10 @@ const SPEC = {
140
145
  command: 'vibecarbon deploy prod -mode k8s -restore latest -y',
141
146
  description: 'stand up a fresh cluster and restore the DB from S3 (DR)',
142
147
  },
148
+ {
149
+ command: 'vibecarbon deploy prod -mode k8s-ha -allow-degraded',
150
+ description: 'finalize an HA deploy even if the standby is not yet streaming (degraded DR)',
151
+ },
143
152
  ],
144
153
  };
145
154
 
@@ -188,6 +197,9 @@ function buildLegacyArgs(values, positional) {
188
197
  push: false,
189
198
  // DR: seed the fresh DB from S3 via wal-g (k8s only). null = normal deploy.
190
199
  restore: values.restore || null,
200
+ // HA gate opt-out: accept a warm/degraded standby instead of failing when
201
+ // replication isn't verifiably streaming. Ignored for non-HA modes.
202
+ allowDegraded: !!values['allow-degraded'],
191
203
  };
192
204
  }
193
205
 
@@ -226,10 +238,9 @@ async function main() {
226
238
  // upsell path) outside a project, tripping the not-in-project contract.
227
239
  assertInProjectDir();
228
240
 
229
- // 0b. Deploying to production requires a paid license. Graphite (free)
230
- // covers local create/up/test; deploy/scale/backup/etc. need Diamond+.
231
- // Gate after the project check — help/version already returned above.
232
- requireLicense('deploy');
241
+ // 0b. The paid-license gate for deploy lives in cli.js (central
242
+ // COMMAND_GATES chokepoint), which runs project-guard → license gate
243
+ // before dispatching here.
233
244
 
234
245
  // 0c. Refuse to deploy if the working tree contains likely secrets.
235
246
  // We push the project's tracked files to GitHub during deploy (CI,