vibecarbon 0.5.0 → 0.6.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/README.md +1 -1
- package/carbon/.env.example +3 -0
- package/carbon/.github/workflows/deploy.yml +24 -21
- package/carbon/.github/workflows/vibecarbon-build.yml +14 -0
- package/carbon/Dockerfile +3 -0
- package/carbon/biome.json +1 -1
- package/carbon/cloud-init/k3s/master-init.sh +26 -2
- package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
- package/carbon/cloud-init/k3s/worker-init.sh +22 -2
- package/carbon/content/docs/cli.mdx +1 -1
- package/carbon/docker-compose.yml +6 -0
- package/carbon/k8s/base/app/deployment.yaml +5 -0
- package/carbon/k8s/values/supabase.values.yaml +30 -0
- package/carbon/package.json +34 -36
- package/carbon/scripts/generate-sitemap.ts +18 -3
- package/carbon/src/client/App.tsx +2 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
- package/carbon/src/client/components/PricingSection.tsx +44 -1
- package/carbon/src/client/components/SEO.tsx +6 -3
- package/carbon/src/client/components/ui/calendar.tsx +1 -1
- package/carbon/src/client/index.css +9 -1
- package/carbon/src/client/index.html +3 -3
- package/carbon/src/client/locales/en.json +1 -1
- package/carbon/src/client/pages/Pricing.tsx +169 -0
- package/carbon/src/client/pages/settings/Billing.tsx +2 -6
- package/carbon/src/server/index.ts +23 -4
- package/carbon/src/shared/billing-catalog.ts +25 -0
- package/carbon/src/shared/billing-catalog.types.ts +35 -0
- package/carbon/src/shared/pricing.ts +56 -1
- package/package.json +13 -13
- package/src/activate.js +1 -1
- package/src/backup.js +33 -87
- package/src/configure.js +365 -137
- package/src/create.js +5 -2
- package/src/deploy.js +24 -7
- package/src/destroy.js +11 -4
- package/src/failover.js +64 -47
- package/src/lib/backup-format.js +84 -0
- package/src/lib/billing/stripe-catalog.js +152 -0
- package/src/lib/billing/write-catalog.js +123 -0
- package/src/lib/ci-setup.js +11 -1
- package/src/lib/config-registry.js +116 -0
- package/src/lib/deploy/compose/build-args.js +6 -0
- package/src/lib/deploy/compose/ha.js +25 -25
- package/src/lib/deploy/compose/index.js +29 -18
- package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
- package/src/lib/deploy/k8s/k3s.js +20 -10
- package/src/lib/deploy/orchestrator.js +22 -1
- package/src/lib/deploy/prompts.js +21 -2
- package/src/lib/deploy/utils.js +56 -32
- package/src/lib/github-environments.js +29 -0
- package/src/lib/licensing/index.js +8 -3
- package/src/lib/licensing/tiers.js +0 -3
- package/src/lib/pod-backups.js +3 -3
- package/src/lib/providers/base.js +1 -1
- package/src/lib/providers/hetzner.js +74 -82
- package/src/lib/server-types.js +3 -31
- package/src/lib/ssh.js +19 -18
- package/src/lib/walg-backups.js +81 -0
- package/src/restore.js +122 -202
- package/src/scale.js +25 -29
- package/src/status.js +0 -69
- package/src/lib/cost.js +0 -103
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe catalog fetch — pulls the customer's live products + prices so
|
|
3
|
+
* `vibecarbon configure` (Billing → Stripe) can let them pick which products to
|
|
4
|
+
* activate and snapshot the display fields into the project.
|
|
5
|
+
*
|
|
6
|
+
* Contains NO secrets in its output — product names, amounts, and price IDs are
|
|
7
|
+
* all public. The secret key only ever travels in the Authorization header of
|
|
8
|
+
* the live API call; it is never persisted by this module.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { fetchWithRetry } from '../fetch-retry.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} NormalizedPrice
|
|
15
|
+
* @property {string} priceId Stripe price id (`price_...`).
|
|
16
|
+
* @property {string} name Expanded product name (falls back to nickname/id).
|
|
17
|
+
* @property {string | null} description Expanded product description.
|
|
18
|
+
* @property {string[]} features Product marketing features (names).
|
|
19
|
+
* @property {number} amount Smallest currency unit (cents).
|
|
20
|
+
* @property {string} currency Lowercase ISO 4217 (e.g. `usd`).
|
|
21
|
+
* @property {'month' | 'year' | 'one_time'} interval
|
|
22
|
+
* @property {'recurring' | 'one_time'} type
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format a smallest-unit amount + currency for human-readable select labels,
|
|
27
|
+
* e.g. (1900, 'usd') -> "$19.00". Falls back to a generic "<major> <CODE>"
|
|
28
|
+
* form for currencies Intl doesn't render with a symbol.
|
|
29
|
+
*
|
|
30
|
+
* @param {number} amount Smallest currency unit (cents).
|
|
31
|
+
* @param {string} currency Lowercase ISO 4217 code.
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function formatAmount(amount, currency) {
|
|
35
|
+
const code = (currency || 'usd').toUpperCase();
|
|
36
|
+
try {
|
|
37
|
+
return new Intl.NumberFormat('en-US', {
|
|
38
|
+
style: 'currency',
|
|
39
|
+
currency: code,
|
|
40
|
+
}).format(amount / 100);
|
|
41
|
+
} catch {
|
|
42
|
+
// Unknown / unsupported currency code — render the major-unit number plainly.
|
|
43
|
+
return `${(amount / 100).toFixed(2)} ${code}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a raw Stripe price object (with `product` expanded) into the shape
|
|
49
|
+
* our billing catalog stores. Returns null for prices we can't represent
|
|
50
|
+
* (metered / null `unit_amount`) so the caller can skip them.
|
|
51
|
+
*
|
|
52
|
+
* @param {any} price
|
|
53
|
+
* @returns {NormalizedPrice | null}
|
|
54
|
+
*/
|
|
55
|
+
function normalizePrice(price) {
|
|
56
|
+
if (!price || typeof price !== 'object') return null;
|
|
57
|
+
// Metered prices have a null unit_amount — we can't display a fixed amount,
|
|
58
|
+
// so skip them rather than emit a bogus 0.
|
|
59
|
+
if (price.unit_amount === null || price.unit_amount === undefined) return null;
|
|
60
|
+
|
|
61
|
+
const product = price.product && typeof price.product === 'object' ? price.product : null;
|
|
62
|
+
const name = product?.name || price.nickname || price.id;
|
|
63
|
+
const description = product?.description ?? null;
|
|
64
|
+
// Stripe product marketing_features is an array of { name }; flatten to the
|
|
65
|
+
// feature strings, dropping any with an empty/missing name.
|
|
66
|
+
const features = Array.isArray(product?.marketing_features)
|
|
67
|
+
? product.marketing_features.map((f) => f?.name).filter(Boolean)
|
|
68
|
+
: [];
|
|
69
|
+
|
|
70
|
+
const recurring = price.recurring && typeof price.recurring === 'object' ? price.recurring : null;
|
|
71
|
+
// Our catalog type only allows month/year/one_time. Use the Stripe interval
|
|
72
|
+
// when it's month or year; for any other recurring interval (week/day) fall
|
|
73
|
+
// back to a safe 'month' default; non-recurring prices are one_time.
|
|
74
|
+
let interval;
|
|
75
|
+
if (recurring) {
|
|
76
|
+
interval =
|
|
77
|
+
recurring.interval === 'month' || recurring.interval === 'year'
|
|
78
|
+
? recurring.interval
|
|
79
|
+
: 'month';
|
|
80
|
+
} else {
|
|
81
|
+
interval = 'one_time';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
priceId: price.id,
|
|
86
|
+
name,
|
|
87
|
+
description,
|
|
88
|
+
features,
|
|
89
|
+
amount: price.unit_amount,
|
|
90
|
+
currency: price.currency,
|
|
91
|
+
interval,
|
|
92
|
+
type: recurring ? 'recurring' : 'one_time',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fetch the customer's active Stripe prices (with products expanded),
|
|
98
|
+
* normalize them, and return them sorted by amount ascending.
|
|
99
|
+
*
|
|
100
|
+
* Single page (limit=100) — fine for MVP. If Stripe reports `has_more`, we log
|
|
101
|
+
* a note but don't paginate; a customer with >100 active prices is well past
|
|
102
|
+
* the activate-products use case this serves.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} secretKey Stripe secret key (`sk_test_...` / `sk_live_...`).
|
|
105
|
+
* @returns {Promise<NormalizedPrice[]>} Sorted by amount ascending.
|
|
106
|
+
* @throws {Error} on non-OK response (e.g. 401 invalid key) so the caller can
|
|
107
|
+
* warn and skip catalog writing.
|
|
108
|
+
*/
|
|
109
|
+
export async function listStripePrices(secretKey) {
|
|
110
|
+
// Stripe wants form-encoded query params; `expand[]=data.product` must be
|
|
111
|
+
// sent literally (URLSearchParams would percent-encode the brackets, which
|
|
112
|
+
// Stripe rejects), so build the query string by hand.
|
|
113
|
+
const url = 'https://api.stripe.com/v1/prices?active=true&limit=100&expand[]=data.product';
|
|
114
|
+
|
|
115
|
+
const response = await fetchWithRetry(url, {
|
|
116
|
+
headers: { Authorization: `Bearer ${secretKey}` },
|
|
117
|
+
label: 'stripe prices',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
// Surface Stripe's own error message when present — its body is
|
|
122
|
+
// { error: { message, type, ... } } for auth/validation failures.
|
|
123
|
+
let detail = `HTTP ${response.status}`;
|
|
124
|
+
try {
|
|
125
|
+
const body = await response.json();
|
|
126
|
+
if (body?.error?.message) detail = body.error.message;
|
|
127
|
+
} catch {
|
|
128
|
+
// Non-JSON body (rare) — keep the status-code detail.
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`Stripe price fetch failed: ${detail}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const body = await response.json();
|
|
134
|
+
const rows = Array.isArray(body?.data) ? body.data : [];
|
|
135
|
+
const prices = [];
|
|
136
|
+
for (const price of rows) {
|
|
137
|
+
const normalized = normalizePrice(price);
|
|
138
|
+
if (normalized) prices.push(normalized);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (body?.has_more) {
|
|
142
|
+
console.error(
|
|
143
|
+
'[stripe] more than 100 active prices exist; only the first page was loaded. Activation uses this page.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sort by amount ascending so the activate multiselect and the snapshot both
|
|
148
|
+
// present cheapest-first (free tiers, then paid).
|
|
149
|
+
prices.sort((a, b) => a.amount - b.amount);
|
|
150
|
+
|
|
151
|
+
return prices;
|
|
152
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* write-catalog — serializes a billing catalog snapshot into the project's
|
|
3
|
+
* `src/shared/billing-catalog.ts` data module.
|
|
4
|
+
*
|
|
5
|
+
* The companion `billing-catalog.types.ts` ships in the template and is never
|
|
6
|
+
* regenerated; this writer only replaces the data file so `vibecarbon
|
|
7
|
+
* configure` (Billing → Stripe) can refresh pricing without touching types.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Escaped TS string literal matching the repo's biome style: prefer single
|
|
15
|
+
* quotes, but switch to double quotes when the string contains more single than
|
|
16
|
+
* double quotes (biome's "fewer escapes" rule, e.g. `"Developer's Plan"`).
|
|
17
|
+
*/
|
|
18
|
+
function tsString(s) {
|
|
19
|
+
const str = String(s);
|
|
20
|
+
const singles = (str.match(/'/g) || []).length;
|
|
21
|
+
const doubles = (str.match(/"/g) || []).length;
|
|
22
|
+
const quote = singles > doubles ? '"' : "'";
|
|
23
|
+
const escaped = str
|
|
24
|
+
.replace(/\\/g, '\\\\')
|
|
25
|
+
.replaceAll(quote, `\\${quote}`)
|
|
26
|
+
.replace(/\n/g, '\\n')
|
|
27
|
+
.replace(/\r/g, '\\r')
|
|
28
|
+
.replace(/\t/g, '\\t');
|
|
29
|
+
return `${quote}${escaped}${quote}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
33
|
+
const LINE_WIDTH = 80; // biome's default; keep generated output collapse/break-equivalent
|
|
34
|
+
|
|
35
|
+
function isPrimitive(v) {
|
|
36
|
+
return v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Serialize plain JSON-ish data (objects / strings / numbers / booleans / null)
|
|
41
|
+
* into a biome-conformant TS object literal: unquoted identifier keys, single
|
|
42
|
+
* quotes, trailing commas, two-space indent. Avoids the double-quoted, quoted-key
|
|
43
|
+
* output of JSON.stringify, which fails the project's lint gate.
|
|
44
|
+
*
|
|
45
|
+
* `column` is the column at which this value begins, used to mirror biome's
|
|
46
|
+
* "inline an array if it fits within the line width, else break one-per-line"
|
|
47
|
+
* rule for primitive arrays (e.g. short `features` lists stay on one line).
|
|
48
|
+
*/
|
|
49
|
+
function serializeTs(value, indentLevel = 0, column = 0) {
|
|
50
|
+
if (value === null) return 'null';
|
|
51
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
52
|
+
if (typeof value === 'string') return tsString(value);
|
|
53
|
+
|
|
54
|
+
const pad = ' '.repeat(indentLevel);
|
|
55
|
+
const padInner = ' '.repeat(indentLevel + 1);
|
|
56
|
+
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
if (value.length === 0) return '[]';
|
|
59
|
+
if (value.every(isPrimitive)) {
|
|
60
|
+
const inline = `[${value.map((v) => serializeTs(v)).join(', ')}]`;
|
|
61
|
+
// +1 for the trailing comma that follows on the same line.
|
|
62
|
+
if (column + inline.length + 1 <= LINE_WIDTH) return inline;
|
|
63
|
+
}
|
|
64
|
+
const items = value
|
|
65
|
+
.map((v) => `${padInner}${serializeTs(v, indentLevel + 1, padInner.length)},`)
|
|
66
|
+
.join('\n');
|
|
67
|
+
return `[\n${items}\n${pad}]`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
71
|
+
if (entries.length === 0) return '{}';
|
|
72
|
+
const lines = entries
|
|
73
|
+
.map(([k, v]) => {
|
|
74
|
+
const key = IDENT_RE.test(k) ? k : tsString(k);
|
|
75
|
+
const prefix = `${padInner}${key}: `;
|
|
76
|
+
return `${prefix}${serializeTs(v, indentLevel + 1, prefix.length)},`;
|
|
77
|
+
})
|
|
78
|
+
.join('\n');
|
|
79
|
+
return `{\n${lines}\n${pad}}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Serialize + write the billing catalog into `<cwd>/src/shared/billing-catalog.ts`.
|
|
84
|
+
*
|
|
85
|
+
* The emitted module mirrors the shipped default's shape exactly: the
|
|
86
|
+
* `import type { BillingCatalog }` line, a generated-file header, and
|
|
87
|
+
* `export const billingCatalog: BillingCatalog = <json>;`.
|
|
88
|
+
*
|
|
89
|
+
* If `<cwd>/src/shared` does not exist this isn't an app project root — return
|
|
90
|
+
* false and skip rather than throwing, so the configure flow never blocks on a
|
|
91
|
+
* missing snapshot target.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} cwd Project root.
|
|
94
|
+
* @param {{ provider: string | null, generatedAt: string | null, tiers: Array<Record<string, unknown>> }} catalog
|
|
95
|
+
* `tiers` is a price-sorted list of activated products (CatalogTier[]).
|
|
96
|
+
* @returns {boolean} true if written, false if skipped (no src/shared).
|
|
97
|
+
*/
|
|
98
|
+
export function writeBillingCatalog(cwd, catalog) {
|
|
99
|
+
const sharedDir = join(cwd, 'src', 'shared');
|
|
100
|
+
if (!existsSync(sharedDir)) return false;
|
|
101
|
+
|
|
102
|
+
const targetPath = join(sharedDir, 'billing-catalog.ts');
|
|
103
|
+
// Emit a biome-conformant TS literal (single quotes, unquoted keys, trailing
|
|
104
|
+
// commas) so the generated file passes the project's lint gate as-is.
|
|
105
|
+
const body = serializeTs(catalog, 0);
|
|
106
|
+
|
|
107
|
+
const content = `/**
|
|
108
|
+
* Billing catalog snapshot — GENERATED by \`vibecarbon configure\` (Billing →
|
|
109
|
+
* Stripe). Do not hand-edit: re-run \`vibecarbon configure\` to refresh after
|
|
110
|
+
* changing a price or product name in the provider dashboard, then redeploy.
|
|
111
|
+
*
|
|
112
|
+
* Contains NO secrets — product names, amounts, and price IDs are all public.
|
|
113
|
+
* Types live in \`billing-catalog.types.ts\` (not regenerated).
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
import type { BillingCatalog } from './billing-catalog.types';
|
|
117
|
+
|
|
118
|
+
export const billingCatalog: BillingCatalog = ${body};
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
writeFileSync(targetPath, content);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
package/src/lib/ci-setup.js
CHANGED
|
@@ -449,7 +449,7 @@ export async function commitAndPushWorkflow(cwd = process.cwd()) {
|
|
|
449
449
|
let _ghAuthChecked = false;
|
|
450
450
|
|
|
451
451
|
export async function ensureCIImageReady(options = {}) {
|
|
452
|
-
const { yes = false, tracker, cwd = process.cwd() } = options;
|
|
452
|
+
const { yes = false, tracker, cwd = process.cwd(), buildArgs = null } = options;
|
|
453
453
|
|
|
454
454
|
if (!checkDependency('gh', 'GitHub CLI')) {
|
|
455
455
|
throw new Error(
|
|
@@ -473,6 +473,16 @@ export async function ensureCIImageReady(options = {}) {
|
|
|
473
473
|
);
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
// Seed the VITE_* build args as repo variables BEFORE the workflow runs, so
|
|
477
|
+
// the CI-built image bakes real values instead of empty strings. Must
|
|
478
|
+
// precede commitAndPushWorkflow / workflow dispatch below (both trigger the
|
|
479
|
+
// build). Skipped when the caller passes no buildArgs (e.g. older callers).
|
|
480
|
+
if (buildArgs && Object.keys(buildArgs).length > 0) {
|
|
481
|
+
const { seedBuildVars } = await import('./github-environments.js');
|
|
482
|
+
const applied = await seedBuildVars(buildArgs);
|
|
483
|
+
if (applied.length) p.log.info(`Seeded ${applied.length} VITE build var(s) for CI`);
|
|
484
|
+
}
|
|
485
|
+
|
|
476
486
|
// Install workflow if missing — first-deploy case
|
|
477
487
|
const installed = installWorkflowFile(cwd);
|
|
478
488
|
if (installed) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the env keys that `vibecarbon configure` manages.
|
|
3
|
+
*
|
|
4
|
+
* `configure` writes feature config/secrets to `.env.local`/`.env`. Several
|
|
5
|
+
* deploy paths each need to know *which* keys to propagate and *how* to treat
|
|
6
|
+
* each one (build-time vs runtime, secret vs non-secret). Historically each
|
|
7
|
+
* path carried its own hand-maintained list, so a key added to one feature
|
|
8
|
+
* silently failed to reach the cloud on another path. This registry is the one
|
|
9
|
+
* list they all derive from; coverage tests assert registry ⊆ each path.
|
|
10
|
+
*
|
|
11
|
+
* Scope: feature keys only (billing, OAuth, SMTP, analytics). Infra secrets
|
|
12
|
+
* (DB_PASSWORD/JWT_SECRET/ANON_KEY/…) are *generated*, not configured, and
|
|
13
|
+
* carry k8s-specific Supabase-chart translation logic — they stay local to the
|
|
14
|
+
* k8s deploy modules.
|
|
15
|
+
*
|
|
16
|
+
* Classification:
|
|
17
|
+
* - 'client-build' → baked into the client bundle at image build time
|
|
18
|
+
* (VITE_*). A change must force an image rebuild.
|
|
19
|
+
* - 'runtime-config' → non-secret server/auth runtime env.
|
|
20
|
+
* - 'runtime-secret' → secret server/auth runtime env.
|
|
21
|
+
*
|
|
22
|
+
* Keep this aligned with what `configure` writes (src/configure.js) and what
|
|
23
|
+
* the app/auth services read (carbon/src/server/lib/env.ts + the GoTrue
|
|
24
|
+
* `auth` service wiring in carbon/docker-compose.yml). Dependency-free on
|
|
25
|
+
* purpose so deploy code can import it without pulling in @clack/prompts.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {'client-build' | 'runtime-config' | 'runtime-secret'} ConfigClass
|
|
30
|
+
* @typedef {{ key: string, class: ConfigClass, feature: string }} ConfigKey
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @type {ConfigKey[]} */
|
|
34
|
+
export const CONFIG_KEYS = [
|
|
35
|
+
// ---- Billing: provider selection ----
|
|
36
|
+
{ key: 'BILLING_PROVIDER', class: 'runtime-config', feature: 'billing' },
|
|
37
|
+
|
|
38
|
+
// ---- Billing: Stripe ----
|
|
39
|
+
{ key: 'STRIPE_SECRET_KEY', class: 'runtime-secret', feature: 'billing' },
|
|
40
|
+
{ key: 'STRIPE_WEBHOOK_SECRET', class: 'runtime-secret', feature: 'billing' },
|
|
41
|
+
{ key: 'STRIPE_PRICE_STARTER', class: 'runtime-config', feature: 'billing' },
|
|
42
|
+
{ key: 'STRIPE_PRICE_PRO', class: 'runtime-config', feature: 'billing' },
|
|
43
|
+
|
|
44
|
+
// ---- Billing: Paddle ----
|
|
45
|
+
{ key: 'PADDLE_API_KEY', class: 'runtime-secret', feature: 'billing' },
|
|
46
|
+
{ key: 'PADDLE_WEBHOOK_SECRET', class: 'runtime-secret', feature: 'billing' },
|
|
47
|
+
{ key: 'PADDLE_ENVIRONMENT', class: 'runtime-config', feature: 'billing' },
|
|
48
|
+
{ key: 'PADDLE_PRICE_STARTER', class: 'runtime-config', feature: 'billing' },
|
|
49
|
+
{ key: 'PADDLE_PRICE_PRO', class: 'runtime-config', feature: 'billing' },
|
|
50
|
+
|
|
51
|
+
// ---- Billing: Polar ----
|
|
52
|
+
{ key: 'POLAR_ACCESS_TOKEN', class: 'runtime-secret', feature: 'billing' },
|
|
53
|
+
{ key: 'POLAR_WEBHOOK_SECRET', class: 'runtime-secret', feature: 'billing' },
|
|
54
|
+
{ key: 'POLAR_ORGANIZATION_ID', class: 'runtime-config', feature: 'billing' },
|
|
55
|
+
{ key: 'POLAR_PRICE_STARTER', class: 'runtime-config', feature: 'billing' },
|
|
56
|
+
{ key: 'POLAR_PRICE_PRO', class: 'runtime-config', feature: 'billing' },
|
|
57
|
+
|
|
58
|
+
// ---- OAuth: Google (consumed by the GoTrue auth service) ----
|
|
59
|
+
{ key: 'GOOGLE_ENABLED', class: 'runtime-config', feature: 'oauth' },
|
|
60
|
+
{ key: 'GOOGLE_CLIENT_ID', class: 'runtime-config', feature: 'oauth' },
|
|
61
|
+
{ key: 'GOOGLE_CLIENT_SECRET', class: 'runtime-secret', feature: 'oauth' },
|
|
62
|
+
|
|
63
|
+
// ---- OAuth: Microsoft ----
|
|
64
|
+
{ key: 'MICROSOFT_ENABLED', class: 'runtime-config', feature: 'oauth' },
|
|
65
|
+
{ key: 'MICROSOFT_CLIENT_ID', class: 'runtime-config', feature: 'oauth' },
|
|
66
|
+
{ key: 'MICROSOFT_CLIENT_SECRET', class: 'runtime-secret', feature: 'oauth' },
|
|
67
|
+
{ key: 'MICROSOFT_TENANT_ID', class: 'runtime-config', feature: 'oauth' },
|
|
68
|
+
|
|
69
|
+
// ---- SMTP / Email (shared by the app and the auth service) ----
|
|
70
|
+
// Canonical name is SMTP_PASS everywhere (app env.ts, compose, manifests).
|
|
71
|
+
{ key: 'SMTP_HOST', class: 'runtime-config', feature: 'smtp' },
|
|
72
|
+
{ key: 'SMTP_PORT', class: 'runtime-config', feature: 'smtp' },
|
|
73
|
+
{ key: 'SMTP_USER', class: 'runtime-config', feature: 'smtp' },
|
|
74
|
+
{ key: 'SMTP_PASS', class: 'runtime-secret', feature: 'smtp' },
|
|
75
|
+
{ key: 'SMTP_ADMIN_EMAIL', class: 'runtime-config', feature: 'smtp' },
|
|
76
|
+
{ key: 'SMTP_SENDER_NAME', class: 'runtime-config', feature: 'smtp' },
|
|
77
|
+
|
|
78
|
+
// ---- Analytics (client-side, baked at build time) ----
|
|
79
|
+
{ key: 'VITE_PLAUSIBLE_DOMAIN', class: 'client-build', feature: 'analytics' },
|
|
80
|
+
{ key: 'VITE_PLAUSIBLE_SCRIPT_URL', class: 'client-build', feature: 'analytics' },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** Keys of a given classification. @param {ConfigClass} cls */
|
|
84
|
+
function keysOfClass(cls) {
|
|
85
|
+
return CONFIG_KEYS.filter((k) => k.class === cls).map((k) => k.key);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Secret runtime keys (Stripe/Paddle/Polar secrets, OAuth secrets, SMTP_PASS). */
|
|
89
|
+
export function featureSecretKeys() {
|
|
90
|
+
return keysOfClass('runtime-secret');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Non-secret runtime keys (BILLING_PROVIDER, SMTP_HOST, GOOGLE_CLIENT_ID, …). */
|
|
94
|
+
export function featureConfigKeys() {
|
|
95
|
+
return keysOfClass('runtime-config');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** All runtime keys (secret + non-secret) — everything that must reach a pod/container at runtime. */
|
|
99
|
+
export function featureRuntimeKeys() {
|
|
100
|
+
return [...featureConfigKeys(), ...featureSecretKeys()];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Client build-time keys (VITE_*) — a change must force an image rebuild. */
|
|
104
|
+
export function clientBuildKeys() {
|
|
105
|
+
return keysOfClass('client-build');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether `key` is a configure-managed secret. @param {string} key */
|
|
109
|
+
export function isSecretKey(key) {
|
|
110
|
+
return CONFIG_KEYS.some((k) => k.key === key && k.class === 'runtime-secret');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** All keys belonging to a feature, in registry order. @param {string} feature */
|
|
114
|
+
export function featureKeys(feature) {
|
|
115
|
+
return CONFIG_KEYS.filter((k) => k.feature === feature).map((k) => k.key);
|
|
116
|
+
}
|
|
@@ -58,6 +58,12 @@ export function collectComposeBuildArgs(cwd, opts = {}) {
|
|
|
58
58
|
// server's runtime .env so the client + server agree on the URL.
|
|
59
59
|
if (projectName) args.VITE_PROJECT_NAME = projectName;
|
|
60
60
|
if (domain) args.VITE_SUPABASE_URL = `https://api.${domain}`;
|
|
61
|
+
// Public canonical URL (apex domain) baked into the client at build time —
|
|
62
|
+
// drives og:url / og:image / twitter:image and the generated sitemap. Distinct
|
|
63
|
+
// from VITE_SUPABASE_URL's `api.` host. Without this the client falls back to
|
|
64
|
+
// the create-time SITE_URL (localhost), so social previews + sitemap point at
|
|
65
|
+
// localhost on every deployed site.
|
|
66
|
+
if (domain) args.VITE_PUBLIC_URL = `https://${domain}`;
|
|
61
67
|
return args;
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -215,7 +215,7 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
|
|
|
215
215
|
* 3. Set wal_level=replica and max_wal_senders
|
|
216
216
|
* 4. Restart PostgreSQL to apply changes
|
|
217
217
|
*/
|
|
218
|
-
function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
|
|
218
|
+
async function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
|
|
219
219
|
const remoteDir = `/opt/${projectName}`;
|
|
220
220
|
// replPassword is generated at create time from crypto.randomBytes and is
|
|
221
221
|
// restricted to base64url characters — no shell escaping needed.
|
|
@@ -264,7 +264,7 @@ BEGIN
|
|
|
264
264
|
END $$;
|
|
265
265
|
`;
|
|
266
266
|
const createRoleB64 = Buffer.from(createRoleSql).toString('base64');
|
|
267
|
-
sshRun(
|
|
267
|
+
await sshRun(
|
|
268
268
|
primaryIp,
|
|
269
269
|
sshKeyPath,
|
|
270
270
|
`cd ${remoteDir} && echo ${createRoleB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
|
|
@@ -279,7 +279,7 @@ END $$;
|
|
|
279
279
|
// pg_hba_file_rules reflected the actual /etc loaded contents (no replicator
|
|
280
280
|
// entry). Resolve the path at runtime via SHOW hba_file so we don't break
|
|
281
281
|
// again if the image moves the file.
|
|
282
|
-
sshRun(
|
|
282
|
+
await sshRun(
|
|
283
283
|
primaryIp,
|
|
284
284
|
sshKeyPath,
|
|
285
285
|
`cd ${remoteDir} && HBA_FILE=$(docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SHOW hba_file;' | tr -d '[:space:]') && ` +
|
|
@@ -289,7 +289,7 @@ END $$;
|
|
|
289
289
|
);
|
|
290
290
|
|
|
291
291
|
// Set WAL settings for replication
|
|
292
|
-
sshRun(
|
|
292
|
+
await sshRun(
|
|
293
293
|
primaryIp,
|
|
294
294
|
sshKeyPath,
|
|
295
295
|
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_level = 'replica';" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET max_wal_senders = 5;" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_keep_size = '512MB';"`,
|
|
@@ -297,14 +297,14 @@ END $$;
|
|
|
297
297
|
);
|
|
298
298
|
|
|
299
299
|
// Restart PostgreSQL to apply WAL changes
|
|
300
|
-
sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
|
|
300
|
+
await sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
|
|
301
301
|
timeout: 60_000,
|
|
302
302
|
});
|
|
303
303
|
|
|
304
304
|
// Wait for PostgreSQL to be ready again
|
|
305
305
|
for (let i = 0; i < 15; i++) {
|
|
306
306
|
try {
|
|
307
|
-
sshRun(
|
|
307
|
+
await sshRun(
|
|
308
308
|
primaryIp,
|
|
309
309
|
sshKeyPath,
|
|
310
310
|
`cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres`,
|
|
@@ -359,7 +359,7 @@ BEGIN
|
|
|
359
359
|
END IF;
|
|
360
360
|
END $$;`;
|
|
361
361
|
const ensureSlotB64 = Buffer.from(ensureSlotSql).toString('base64');
|
|
362
|
-
sshRun(
|
|
362
|
+
await sshRun(
|
|
363
363
|
primaryIp,
|
|
364
364
|
sshKeyPath,
|
|
365
365
|
`cd ${remoteDir} && echo ${ensureSlotB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
|
|
@@ -367,7 +367,7 @@ END $$;`;
|
|
|
367
367
|
);
|
|
368
368
|
|
|
369
369
|
// Stop the standby database
|
|
370
|
-
sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
|
|
370
|
+
await sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
|
|
371
371
|
timeout: 60_000,
|
|
372
372
|
});
|
|
373
373
|
|
|
@@ -430,7 +430,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
|
|
|
430
430
|
`;
|
|
431
431
|
const basebackupB64 = Buffer.from(basebackupScript).toString('base64');
|
|
432
432
|
try {
|
|
433
|
-
sshRun(
|
|
433
|
+
await sshRun(
|
|
434
434
|
standbyIp,
|
|
435
435
|
sshKeyPath,
|
|
436
436
|
// Use bash, not sh: the supabase/postgres image symlinks /bin/sh →
|
|
@@ -465,7 +465,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
|
|
|
465
465
|
}
|
|
466
466
|
|
|
467
467
|
// Start the standby database
|
|
468
|
-
sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose start db`, {
|
|
468
|
+
await sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose start db`, {
|
|
469
469
|
timeout: 60_000,
|
|
470
470
|
});
|
|
471
471
|
|
|
@@ -496,7 +496,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
|
|
|
496
496
|
let lastPrimarySignal = '';
|
|
497
497
|
for (let i = 0; i < PROBE_BUDGET_S / PROBE_INTERVAL_S; i++) {
|
|
498
498
|
try {
|
|
499
|
-
const result = sshRun(
|
|
499
|
+
const result = await sshRun(
|
|
500
500
|
standbyIp,
|
|
501
501
|
sshKeyPath,
|
|
502
502
|
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
|
|
@@ -508,7 +508,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
|
|
|
508
508
|
// Retry — database may still be applying initial WAL, hot_standby off
|
|
509
509
|
}
|
|
510
510
|
try {
|
|
511
|
-
const primaryResult = sshRun(
|
|
511
|
+
const primaryResult = await sshRun(
|
|
512
512
|
primaryIp,
|
|
513
513
|
sshKeyPath,
|
|
514
514
|
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT state FROM pg_stat_replication WHERE client_addr = '${standbyIp}'::inet"`,
|
|
@@ -629,7 +629,7 @@ async function openReplicationPortHetznerFirewall(serverName, peerIp, hetznerTok
|
|
|
629
629
|
*/
|
|
630
630
|
async function isPrimaryReachable(primaryIp, sshKeyPath, remoteDir) {
|
|
631
631
|
try {
|
|
632
|
-
const result = sshRun(
|
|
632
|
+
const result = await sshRun(
|
|
633
633
|
primaryIp,
|
|
634
634
|
sshKeyPath,
|
|
635
635
|
`cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres -t 3`,
|
|
@@ -1122,7 +1122,7 @@ export async function deployComposeHA(options) {
|
|
|
1122
1122
|
const healthConfirmed = await perfAsync('deploy.ha.compose.primaryHealthProbe', async () => {
|
|
1123
1123
|
for (let i = 0; i < 30; i++) {
|
|
1124
1124
|
try {
|
|
1125
|
-
const health = sshRun(
|
|
1125
|
+
const health = await sshRun(
|
|
1126
1126
|
primaryIp,
|
|
1127
1127
|
sshKeyPath,
|
|
1128
1128
|
`cd /opt/${projectName} && docker compose exec -T db pg_isready -h localhost -p 5432 2>/dev/null`,
|
|
@@ -1185,7 +1185,7 @@ export async function deployComposeHA(options) {
|
|
|
1185
1185
|
// dominated by pg_basebackup for the standby seed.
|
|
1186
1186
|
await perfAsync('deploy.ha.replication.setup', async () => {
|
|
1187
1187
|
onProgress('Configuring PostgreSQL replication on primary...');
|
|
1188
|
-
configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
|
|
1188
|
+
await configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
|
|
1189
1189
|
|
|
1190
1190
|
onProgress('Configuring standby as hot replica...');
|
|
1191
1191
|
let replicationOk = false;
|
|
@@ -1221,7 +1221,7 @@ export async function deployComposeHA(options) {
|
|
|
1221
1221
|
let replActive = false;
|
|
1222
1222
|
for (let i = 0; i < REPL_DELAYS_MS.length; i++) {
|
|
1223
1223
|
try {
|
|
1224
|
-
const count = sshRun(
|
|
1224
|
+
const count = await sshRun(
|
|
1225
1225
|
primaryIp,
|
|
1226
1226
|
sshKeyPath,
|
|
1227
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'"`,
|
|
@@ -1597,7 +1597,7 @@ export async function waitForNewPrimaryApi(ip, sshKeyPath, domain, opts = {}) {
|
|
|
1597
1597
|
let lastCode = '';
|
|
1598
1598
|
for (let i = 0; i < attempts; i++) {
|
|
1599
1599
|
try {
|
|
1600
|
-
const code = runner(ip, sshKeyPath, cmd, { silent: true, timeout: 12_000 });
|
|
1600
|
+
const code = await runner(ip, sshKeyPath, cmd, { silent: true, timeout: 12_000 });
|
|
1601
1601
|
lastCode = typeof code === 'string' ? code.trim() : '';
|
|
1602
1602
|
if (lastCode === '200') return true;
|
|
1603
1603
|
} catch {
|
|
@@ -1746,9 +1746,9 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1746
1746
|
// converted to "did not exit recovery mode after 5 attempts" because
|
|
1747
1747
|
// attempts 2-5 hit the same not-in-recovery error and never re-checked.
|
|
1748
1748
|
// RCA: compose-ha 2026-05-01 fanout4b failover.
|
|
1749
|
-
const probeRecovery = () => {
|
|
1749
|
+
const probeRecovery = async () => {
|
|
1750
1750
|
try {
|
|
1751
|
-
const r = sshRun(
|
|
1751
|
+
const r = await sshRun(
|
|
1752
1752
|
standbyServer.ip,
|
|
1753
1753
|
sshKeyPath,
|
|
1754
1754
|
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
|
|
@@ -1772,7 +1772,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1772
1772
|
const MAX_PROMOTE_ATTEMPTS = 5;
|
|
1773
1773
|
for (let attempt = 1; attempt <= MAX_PROMOTE_ATTEMPTS && !promoted; attempt++) {
|
|
1774
1774
|
try {
|
|
1775
|
-
const result = sshRun(
|
|
1775
|
+
const result = await sshRun(
|
|
1776
1776
|
standbyServer.ip,
|
|
1777
1777
|
sshKeyPath,
|
|
1778
1778
|
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT pg_promote(wait => true, wait_seconds => 120)"`,
|
|
@@ -1783,7 +1783,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1783
1783
|
promoted = true;
|
|
1784
1784
|
break;
|
|
1785
1785
|
}
|
|
1786
|
-
const probeTrimmed = probeRecovery();
|
|
1786
|
+
const probeTrimmed = await probeRecovery();
|
|
1787
1787
|
if (probeTrimmed === 'f') {
|
|
1788
1788
|
promoted = true;
|
|
1789
1789
|
break;
|
|
@@ -1792,7 +1792,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1792
1792
|
`pg_promote returned ${JSON.stringify(trimmed)}; pg_is_in_recovery=${JSON.stringify(probeTrimmed)}`,
|
|
1793
1793
|
);
|
|
1794
1794
|
} catch (err) {
|
|
1795
|
-
const probeTrimmed = probeRecovery();
|
|
1795
|
+
const probeTrimmed = await probeRecovery();
|
|
1796
1796
|
if (probeTrimmed === 'f') {
|
|
1797
1797
|
promoted = true;
|
|
1798
1798
|
break;
|
|
@@ -1809,7 +1809,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1809
1809
|
// instead of guessing. Best-effort — failover-step abort is what matters.
|
|
1810
1810
|
let dbLogs = '';
|
|
1811
1811
|
try {
|
|
1812
|
-
dbLogs = sshRun(
|
|
1812
|
+
dbLogs = await sshRun(
|
|
1813
1813
|
standbyServer.ip,
|
|
1814
1814
|
sshKeyPath,
|
|
1815
1815
|
`cd ${remoteDir} && docker compose logs db --tail=60`,
|
|
@@ -1837,7 +1837,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1837
1837
|
// promotion. Restart is cheap (~10s) and forces a clean reconnect.
|
|
1838
1838
|
s.start('Restarting new-primary app-tier');
|
|
1839
1839
|
try {
|
|
1840
|
-
sshRun(
|
|
1840
|
+
await sshRun(
|
|
1841
1841
|
standbyServer.ip,
|
|
1842
1842
|
sshKeyPath,
|
|
1843
1843
|
`cd ${remoteDir} && docker compose restart supavisor auth rest realtime storage app`,
|
|
@@ -1868,7 +1868,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1868
1868
|
// Step 2: Stop old primary services to prevent split-brain
|
|
1869
1869
|
s.start('Stopping old primary services');
|
|
1870
1870
|
try {
|
|
1871
|
-
sshRun(
|
|
1871
|
+
await sshRun(
|
|
1872
1872
|
primaryServer.ip,
|
|
1873
1873
|
sshKeyPath,
|
|
1874
1874
|
`cd ${remoteDir} && docker compose stop app auth rest realtime storage supavisor`,
|