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.
- package/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +30 -15
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +968 -886
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +106 -57
- package/src/lib/deploy/compose/single.js +421 -0
- package/src/lib/deploy/image.js +38 -30
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +411 -279
- package/src/lib/deploy/orchestrator.js +274 -453
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/tier-registry.js +30 -0
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/retry.js +59 -0
- package/src/lib/scale-plan.js +159 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +105 -133
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/server/lib/request.ts +0 -34
- 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.
|
|
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": "
|
|
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.
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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,
|