vibecarbon 0.2.0 → 0.3.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/carbon/.env.example +28 -0
- package/carbon/docker-compose.yml +19 -1
- package/carbon/src/server/index.ts +6 -0
- package/carbon/src/server/lib/jwt.ts +27 -0
- package/carbon/src/server/lib/mfa-settings.ts +40 -0
- package/carbon/src/server/middleware/requireAal2.ts +49 -0
- package/carbon/src/server/routes/v1/billing.ts +14 -4
- package/carbon/src/server/routes/v1/index.ts +14 -3
- package/carbon/src/server/types.ts +2 -0
- package/carbon/tests/integration/server/middleware/require-aal2.test.ts +123 -0
- package/carbon/tests/integration/server/routes/billing-aal2.test.ts +63 -0
- package/carbon/tests/integration/server/routes/v1-aal2.test.ts +102 -0
- package/carbon/tests/unit/server/jwt.test.ts +44 -0
- package/carbon/tests/unit/server/mfa-settings.test.ts +64 -0
- package/package.json +1 -1
- package/src/create.js +15 -0
- package/src/destroy.js +23 -1
- package/src/lib/deploy/compose/ha.js +11 -8
- package/src/lib/deploy/image.js +10 -2
- package/src/lib/deploy/k8s/k3s.js +23 -4
- package/src/lib/deploy/remote-build.js +29 -8
- package/src/scale.js +14 -1
package/carbon/.env.example
CHANGED
|
@@ -218,3 +218,31 @@ VITE_ENVIRONMENT="development"
|
|
|
218
218
|
|
|
219
219
|
# App version shown in Super Admin (set automatically during deployment)
|
|
220
220
|
VITE_APP_VERSION="0.1.0"
|
|
221
|
+
|
|
222
|
+
# =============================================================================
|
|
223
|
+
# CONNECTION POOLING (Supavisor — production)
|
|
224
|
+
# =============================================================================
|
|
225
|
+
# The internal Supabase stack (auth, rest, realtime, storage, meta) connects to
|
|
226
|
+
# Postgres DIRECTLY and must stay that way — a transaction pooler breaks
|
|
227
|
+
# realtime's replication slot, PostgREST's NOTIFY-based schema reload, and
|
|
228
|
+
# migration advisory locks.
|
|
229
|
+
#
|
|
230
|
+
# Supavisor (started by docker-compose.prod.yml) is the pooler for EXTERNAL
|
|
231
|
+
# direct-DB clients — e.g. a BI tool, a one-off script, or a serverless function
|
|
232
|
+
# you connect to your database yourself. Nothing internal routes through it.
|
|
233
|
+
#
|
|
234
|
+
# Supavisor needs VAULT_ENC_KEY (vault encryption) and REALTIME_SECRET
|
|
235
|
+
# (SECRET_KEY_BASE, also used by Realtime). `vibecarbon create` AUTO-GENERATES
|
|
236
|
+
# both into .env.local alongside JWT_SECRET / POSTGRES_PASSWORD — you do not set
|
|
237
|
+
# them by hand. The `:-default` fallbacks in docker-compose.prod.yml are dev-only
|
|
238
|
+
# safety nets that a created project never uses.
|
|
239
|
+
#
|
|
240
|
+
# External connection strings (POOLER_TENANT_ID defaults to PROJECT_NAME; the
|
|
241
|
+
# username carries the tenant id):
|
|
242
|
+
# Transaction mode (short, stateless queries / serverless):
|
|
243
|
+
# postgres://postgres.<PROJECT_NAME>:<POSTGRES_PASSWORD>@<host>:5432/postgres
|
|
244
|
+
# Session mode (needs prepared statements / LISTEN-NOTIFY / advisory locks):
|
|
245
|
+
# postgres://postgres.<PROJECT_NAME>:<POSTGRES_PASSWORD>@<host>:6543/postgres
|
|
246
|
+
#
|
|
247
|
+
# NOTE: the pooler ships configured but is not yet exercised by an automated
|
|
248
|
+
# end-to-end test — validate connectivity on a real deploy before relying on it.
|
|
@@ -144,7 +144,12 @@ services:
|
|
|
144
144
|
# PITR gap, but the DB stays up — strictly better than the previous
|
|
145
145
|
# behavior for a SaaS template. Grep `docker logs db` for
|
|
146
146
|
# WAL_ARCHIVE_FAILED to detect silent backup regressions.
|
|
147
|
-
|
|
147
|
+
# Invoke via `bash` rather than relying on the script's exec bit: the
|
|
148
|
+
# create-time scaffold copy (copyFileSync) drops +x, and a 0644 script
|
|
149
|
+
# made postgres fail archiving with exit 126 ("not executable"), which
|
|
150
|
+
# silently re-pins WAL. bash-invoking it (like reconcile.sh) is immune to
|
|
151
|
+
# the file mode, so the fault-tolerant wrapper always runs.
|
|
152
|
+
- archive_command=bash /etc/postgresql/wal-archive.sh %p
|
|
148
153
|
- -c
|
|
149
154
|
# 60s was too aggressive — on an idle Supabase DB, that forces a
|
|
150
155
|
# 16 MiB segment switch every minute regardless of write volume (PG14+
|
|
@@ -166,6 +171,14 @@ services:
|
|
|
166
171
|
# failover — pg_promote retried 5 times against an unreachable
|
|
167
172
|
# standby psql before aborting.
|
|
168
173
|
- hot_standby=on
|
|
174
|
+
- -c
|
|
175
|
+
# Explicit connection ceiling (image default is 100). Real consumers —
|
|
176
|
+
# PostgREST pool + auth + storage + realtime + meta + Supavisor's server
|
|
177
|
+
# pool + replication + superuser_reserved — sum to ~65; 200 leaves
|
|
178
|
+
# headroom for the HA standby and scale-ups so the ceiling can't be hit.
|
|
179
|
+
# Connection pooling for EXTERNAL direct-DB clients is Supavisor (prod
|
|
180
|
+
# overlay); the internal stack connects directly. See docs/security.md.
|
|
181
|
+
- max_connections=200
|
|
169
182
|
environment:
|
|
170
183
|
POSTGRES_HOST: /var/run/postgresql
|
|
171
184
|
PGPORT: 5432
|
|
@@ -325,6 +338,11 @@ services:
|
|
|
325
338
|
PGRST_DB_USE_LEGACY_GUCS: "false"
|
|
326
339
|
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
|
327
340
|
PGRST_APP_SETTINGS_JWT_EXP: 3600
|
|
341
|
+
# Explicit DB connection pool (= PostgREST's default, made intentional)
|
|
342
|
+
# plus a fail-fast acquisition timeout so a saturated pool returns a clear
|
|
343
|
+
# error instead of hanging. Part of the max_connections budget on `db`.
|
|
344
|
+
PGRST_DB_POOL: "10"
|
|
345
|
+
PGRST_DB_POOL_ACQUISITION_TIMEOUT: "10"
|
|
328
346
|
command: postgrest
|
|
329
347
|
depends_on:
|
|
330
348
|
db:
|
|
@@ -6,6 +6,7 @@ import { logger as honoLogger } from 'hono/logger';
|
|
|
6
6
|
import { secureHeaders } from 'hono/secure-headers';
|
|
7
7
|
import { timeout } from 'hono/timeout';
|
|
8
8
|
import { env } from './lib/env';
|
|
9
|
+
import { decodeAalFromJwt } from './lib/jwt';
|
|
9
10
|
import { logger } from './lib/logger';
|
|
10
11
|
import { createRateLimiter } from './lib/rate-limiter';
|
|
11
12
|
import { getSupabaseClient } from './lib/supabase';
|
|
@@ -138,6 +139,7 @@ app.use('/api/*', async (c, next) => {
|
|
|
138
139
|
app.use('/api/*', async (c, next) => {
|
|
139
140
|
if (c.req.path === '/api/health' || c.req.path === '/api/health/ready') {
|
|
140
141
|
c.set('user', null);
|
|
142
|
+
c.set('aal', null);
|
|
141
143
|
await next();
|
|
142
144
|
return;
|
|
143
145
|
}
|
|
@@ -154,11 +156,15 @@ app.use('/api/*', async (c, next) => {
|
|
|
154
156
|
} = await supabase.auth.getUser();
|
|
155
157
|
if (!error && user) {
|
|
156
158
|
c.set('user', user);
|
|
159
|
+
// Token signature already verified by getUser() above — safe to read claims.
|
|
160
|
+
c.set('aal', decodeAalFromJwt(authHeader));
|
|
157
161
|
} else {
|
|
158
162
|
c.set('user', null);
|
|
163
|
+
c.set('aal', null);
|
|
159
164
|
}
|
|
160
165
|
} else {
|
|
161
166
|
c.set('user', null);
|
|
167
|
+
c.set('aal', null);
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
await next();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Supabase Authenticator Assurance Level. */
|
|
2
|
+
export type Aal = 'aal1' | 'aal2';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read the `aal` (Authenticator Assurance Level) claim from a Supabase JWT.
|
|
6
|
+
*
|
|
7
|
+
* This does NOT verify the signature — callers must only pass a token that has
|
|
8
|
+
* already been verified upstream (the session middleware validates it via
|
|
9
|
+
* `supabase.auth.getUser()` before decoding here). It is a pure payload read.
|
|
10
|
+
*
|
|
11
|
+
* Returns 'aal1' | 'aal2', or null when the token is absent, malformed, or
|
|
12
|
+
* carries no recognizable aal claim.
|
|
13
|
+
*/
|
|
14
|
+
export function decodeAalFromJwt(token: string | undefined): Aal | null {
|
|
15
|
+
if (!token) return null;
|
|
16
|
+
const jwt = token.startsWith('Bearer ') ? token.slice('Bearer '.length) : token;
|
|
17
|
+
const parts = jwt.split('.');
|
|
18
|
+
if (parts.length !== 3) return null;
|
|
19
|
+
try {
|
|
20
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
|
|
21
|
+
aal?: unknown;
|
|
22
|
+
};
|
|
23
|
+
return payload.aal === 'aal1' || payload.aal === 'aal2' ? payload.aal : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { logger } from './logger';
|
|
2
|
+
import { supabaseAdmin } from './supabase';
|
|
3
|
+
|
|
4
|
+
const TTL_MS = 60_000;
|
|
5
|
+
let cache: { value: boolean; expiresAt: number } | null = null;
|
|
6
|
+
|
|
7
|
+
/** Test-only: clear the cached global-MFA flag. */
|
|
8
|
+
export function __resetMfaSettingsCache(): void {
|
|
9
|
+
cache = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether MFA is enabled application-wide (app_settings.mfa_enabled.enabled).
|
|
14
|
+
*
|
|
15
|
+
* Cached for 60s to keep the aal2 gate off the per-request DB hot path. On a
|
|
16
|
+
* read failure this FAILS OPEN — returns the last-known value, or false (gate
|
|
17
|
+
* inert) if none — because this control is opt-in and defaults off, so failing
|
|
18
|
+
* open only restores pre-feature behavior. A held aal2 session is always
|
|
19
|
+
* honored by the gate regardless of this value.
|
|
20
|
+
*/
|
|
21
|
+
export async function isMfaGloballyEnabled(): Promise<boolean> {
|
|
22
|
+
if (cache && cache.expiresAt > Date.now()) {
|
|
23
|
+
return cache.value;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const { data, error } = await supabaseAdmin
|
|
27
|
+
.from('app_settings')
|
|
28
|
+
.select('value')
|
|
29
|
+
.eq('key', 'mfa_enabled')
|
|
30
|
+
.maybeSingle();
|
|
31
|
+
if (error) throw error;
|
|
32
|
+
const value = (data?.value ?? null) as { enabled?: boolean } | null;
|
|
33
|
+
const enabled = value?.enabled === true;
|
|
34
|
+
cache = { value: enabled, expiresAt: Date.now() + TTL_MS };
|
|
35
|
+
return enabled;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logger.warn({ err }, 'mfa-settings: failed to read app_settings.mfa_enabled; failing open');
|
|
38
|
+
return cache?.value ?? false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono';
|
|
2
|
+
import { isMfaGloballyEnabled } from '../lib/mfa-settings';
|
|
3
|
+
import type { HonoVariables } from '../types';
|
|
4
|
+
|
|
5
|
+
type Ctx = Context<{ Variables: HonoVariables }>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared decision for aal2 gating. Returns a Response to short-circuit with, or
|
|
9
|
+
* null when the request may proceed.
|
|
10
|
+
*
|
|
11
|
+
* Order: no user -> 401; already aal2 -> allow; MFA globally disabled -> allow
|
|
12
|
+
* (gate inert, respects the operator toggle); otherwise -> 403 mfa_required.
|
|
13
|
+
*/
|
|
14
|
+
export async function checkAal2(c: Ctx): Promise<Response | null> {
|
|
15
|
+
const user = c.get('user');
|
|
16
|
+
if (!user) {
|
|
17
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const aal = c.get('aal');
|
|
21
|
+
if (aal === 'aal2') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!(await isMfaGloballyEnabled())) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return c.json({ error: 'mfa_required', current_aal: aal ?? 'aal1', aal_required: 'aal2' }, 403);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hono middleware: gate a route behind aal2.
|
|
34
|
+
* v1Routes.delete('/me', requireAal2, handler)
|
|
35
|
+
*/
|
|
36
|
+
export async function requireAal2(c: Ctx, next: Next): Promise<Response | undefined> {
|
|
37
|
+
const blocked = await checkAal2(c);
|
|
38
|
+
if (blocked) return blocked;
|
|
39
|
+
await next();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Inline guard for conditional gating inside a handler (returns a Response to
|
|
44
|
+
* return early with, or null to proceed):
|
|
45
|
+
* if (body.role === 'OWNER') { const b = await assertAal2(c); if (b) return b; }
|
|
46
|
+
*/
|
|
47
|
+
export function assertAal2(c: Ctx): Promise<Response | null> {
|
|
48
|
+
return checkAal2(c);
|
|
49
|
+
}
|
|
@@ -6,6 +6,7 @@ import { getBillingProvider, getProviderPriceIds, isBillingConfigured } from '..
|
|
|
6
6
|
import { env } from '../../lib/env';
|
|
7
7
|
import { sanitizeError } from '../../lib/errors';
|
|
8
8
|
import { supabaseAdmin } from '../../lib/supabase';
|
|
9
|
+
import { requireAal2 } from '../../middleware/requireAal2';
|
|
9
10
|
import type { HonoVariables } from '../../types';
|
|
10
11
|
|
|
11
12
|
// Helper to access tables not yet in generated types
|
|
@@ -119,7 +120,16 @@ async function getOrCreateCustomer(
|
|
|
119
120
|
// Create database record
|
|
120
121
|
// NOTE: stripe_customer_id column stores the provider's customer ID regardless
|
|
121
122
|
// of which provider is active. The column name is kept for backward compatibility.
|
|
122
|
-
|
|
123
|
+
// Single explicit shape (not a discriminated union of two object literals):
|
|
124
|
+
// PostgREST's insert overload rejects the union because the org branch's
|
|
125
|
+
// `user_id?: undefined` is incompatible with the user branch's `user_id: string`.
|
|
126
|
+
// `organizationId` is guaranteed defined in the org path by the guard above.
|
|
127
|
+
const insertData: {
|
|
128
|
+
user_id?: string;
|
|
129
|
+
organization_id?: string;
|
|
130
|
+
stripe_customer_id: string;
|
|
131
|
+
email: string;
|
|
132
|
+
} =
|
|
123
133
|
type === 'user'
|
|
124
134
|
? { user_id: userId, stripe_customer_id: providerCustomerId, email }
|
|
125
135
|
: { organization_id: organizationId, stripe_customer_id: providerCustomerId, email };
|
|
@@ -355,7 +365,7 @@ billingRoutes.get('/subscription', async (c) => {
|
|
|
355
365
|
/**
|
|
356
366
|
* Create a checkout session via the active billing provider
|
|
357
367
|
*/
|
|
358
|
-
billingRoutes.post('/checkout', async (c) => {
|
|
368
|
+
billingRoutes.post('/checkout', requireAal2, async (c) => {
|
|
359
369
|
const user = c.get('user');
|
|
360
370
|
|
|
361
371
|
if (!user) {
|
|
@@ -462,7 +472,7 @@ const setupSchema = z.object({
|
|
|
462
472
|
* method collection differently (during checkout or via their portal).
|
|
463
473
|
* For non-Stripe providers, this endpoint returns 501.
|
|
464
474
|
*/
|
|
465
|
-
billingRoutes.post('/setup', async (c) => {
|
|
475
|
+
billingRoutes.post('/setup', requireAal2, async (c) => {
|
|
466
476
|
const user = c.get('user');
|
|
467
477
|
|
|
468
478
|
if (!user) {
|
|
@@ -559,7 +569,7 @@ billingRoutes.post('/setup', async (c) => {
|
|
|
559
569
|
/**
|
|
560
570
|
* Create a customer portal session via the active billing provider
|
|
561
571
|
*/
|
|
562
|
-
billingRoutes.post('/portal', async (c) => {
|
|
572
|
+
billingRoutes.post('/portal', requireAal2, async (c) => {
|
|
563
573
|
const user = c.get('user');
|
|
564
574
|
|
|
565
575
|
if (!user) {
|
|
@@ -7,6 +7,7 @@ import { sendEmail } from '../../lib/email';
|
|
|
7
7
|
import { sanitizeError } from '../../lib/errors';
|
|
8
8
|
import { logger } from '../../lib/logger';
|
|
9
9
|
import { type getSupabaseClient, supabaseAdmin } from '../../lib/supabase';
|
|
10
|
+
import { assertAal2, requireAal2 } from '../../middleware/requireAal2';
|
|
10
11
|
import type { HonoVariables } from '../../types';
|
|
11
12
|
|
|
12
13
|
// Helper to access tables not yet in generated types (notifications, etc.)
|
|
@@ -145,7 +146,7 @@ v1Routes.get('/me', async (c) => {
|
|
|
145
146
|
});
|
|
146
147
|
|
|
147
148
|
// Delete current user's account
|
|
148
|
-
v1Routes.delete('/me', async (c) => {
|
|
149
|
+
v1Routes.delete('/me', requireAal2, async (c) => {
|
|
149
150
|
const user = c.get('user');
|
|
150
151
|
|
|
151
152
|
if (!user) {
|
|
@@ -534,6 +535,15 @@ v1Routes.patch('/organizations/:orgId/members/:userId', async (c) => {
|
|
|
534
535
|
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
535
536
|
}
|
|
536
537
|
|
|
538
|
+
// Promoting/transferring a member to OWNER is a high-privilege action —
|
|
539
|
+
// require an aal2 (MFA-validated) session. Checked here, before the authz
|
|
540
|
+
// lookups: an unauthorized caller is still rejected by the role checks below,
|
|
541
|
+
// and gating early keeps the check cheap and trivially testable.
|
|
542
|
+
if (body.role === 'OWNER') {
|
|
543
|
+
const blocked = await assertAal2(c);
|
|
544
|
+
if (blocked) return blocked;
|
|
545
|
+
}
|
|
546
|
+
|
|
537
547
|
// Get current user's role
|
|
538
548
|
const currentUserRole = await getUserOrgRole(supabase, user.id, orgId);
|
|
539
549
|
|
|
@@ -675,14 +685,15 @@ v1Routes.delete('/organizations/:orgId/members/:userId', async (c) => {
|
|
|
675
685
|
});
|
|
676
686
|
|
|
677
687
|
// Delete an organization (OWNER only)
|
|
678
|
-
v1Routes.delete('/organizations/:orgId', async (c) => {
|
|
688
|
+
v1Routes.delete('/organizations/:orgId', requireAal2, async (c) => {
|
|
679
689
|
const user = c.get('user');
|
|
680
690
|
|
|
681
691
|
if (!user) {
|
|
682
692
|
return c.json({ error: 'Unauthorized' }, 401);
|
|
683
693
|
}
|
|
684
694
|
|
|
685
|
-
|
|
695
|
+
// biome-ignore lint/style/noNonNullAssertion: orgId is always defined when this route matches
|
|
696
|
+
const orgId = c.req.param('orgId')!;
|
|
686
697
|
const supabase = c.get('supabase');
|
|
687
698
|
|
|
688
699
|
// Only the owner can delete
|
|
@@ -8,4 +8,6 @@ import type { getSupabaseClient } from './lib/supabase';
|
|
|
8
8
|
export type HonoVariables = {
|
|
9
9
|
user: User | null;
|
|
10
10
|
supabase: ReturnType<typeof getSupabaseClient>;
|
|
11
|
+
/** Authenticator Assurance Level of the current session, or null if unknown. */
|
|
12
|
+
aal: 'aal1' | 'aal2' | null;
|
|
11
13
|
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { HonoVariables } from '@server/types';
|
|
4
|
+
|
|
5
|
+
const { maybeSingleMock, warnMock } = vi.hoisted(() => ({
|
|
6
|
+
maybeSingleMock: vi.fn(),
|
|
7
|
+
warnMock: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
11
|
+
supabaseAdmin: {
|
|
12
|
+
from: () => ({ select: () => ({ eq: () => ({ maybeSingle: maybeSingleMock }) }) }),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('@server/lib/logger', () => ({
|
|
16
|
+
logger: { error: vi.fn(), info: vi.fn(), warn: warnMock, debug: vi.fn() },
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { requireAal2, assertAal2 } = await import('@server/middleware/requireAal2');
|
|
20
|
+
const { __resetMfaSettingsCache } = await import('@server/lib/mfa-settings');
|
|
21
|
+
|
|
22
|
+
type Aal = 'aal1' | 'aal2' | null;
|
|
23
|
+
|
|
24
|
+
// Build a fresh app whose injector middleware seeds user + aal BEFORE the
|
|
25
|
+
// gated route (mountRoute can't be used here — it adds no pre-route middleware).
|
|
26
|
+
function gatedApp(opts: { user: unknown; aal: Aal }) {
|
|
27
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
28
|
+
app.use('*', async (c, next) => {
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: test injects a minimal user
|
|
30
|
+
c.set('user', opts.user as any);
|
|
31
|
+
c.set('aal', opts.aal);
|
|
32
|
+
await next();
|
|
33
|
+
});
|
|
34
|
+
app.delete('/gated', requireAal2, (c) => c.json({ ok: true }));
|
|
35
|
+
return app;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const aUser = { id: 'user-1', email: 'u@test.com' };
|
|
39
|
+
|
|
40
|
+
function enableMfa() {
|
|
41
|
+
maybeSingleMock.mockResolvedValue({ data: { value: { enabled: true } }, error: null });
|
|
42
|
+
}
|
|
43
|
+
function disableMfa() {
|
|
44
|
+
maybeSingleMock.mockResolvedValue({ data: { value: { enabled: false } }, error: null });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
__resetMfaSettingsCache();
|
|
49
|
+
maybeSingleMock.mockReset();
|
|
50
|
+
warnMock.mockReset();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('requireAal2', () => {
|
|
54
|
+
it('passes an aal2 session through (no settings read needed)', async () => {
|
|
55
|
+
const res = await gatedApp({ user: aUser, aal: 'aal2' }).request('/gated', { method: 'DELETE' });
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
expect(maybeSingleMock).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('blocks an aal1 session with 403 mfa_required when MFA is globally enabled', async () => {
|
|
61
|
+
enableMfa();
|
|
62
|
+
const res = await gatedApp({ user: aUser, aal: 'aal1' }).request('/gated', { method: 'DELETE' });
|
|
63
|
+
expect(res.status).toBe(403);
|
|
64
|
+
expect(await res.json()).toEqual({
|
|
65
|
+
error: 'mfa_required',
|
|
66
|
+
current_aal: 'aal1',
|
|
67
|
+
aal_required: 'aal2',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports current_aal as aal1 when the session carries no aal claim', async () => {
|
|
72
|
+
enableMfa();
|
|
73
|
+
const res = await gatedApp({ user: aUser, aal: null }).request('/gated', { method: 'DELETE' });
|
|
74
|
+
expect(res.status).toBe(403);
|
|
75
|
+
expect(await res.json()).toEqual({
|
|
76
|
+
error: 'mfa_required',
|
|
77
|
+
current_aal: 'aal1',
|
|
78
|
+
aal_required: 'aal2',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('passes an aal1 session through when MFA is globally disabled (gate inert)', async () => {
|
|
83
|
+
disableMfa();
|
|
84
|
+
const res = await gatedApp({ user: aUser, aal: 'aal1' }).request('/gated', { method: 'DELETE' });
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns 401 when there is no authenticated user', async () => {
|
|
90
|
+
const res = await gatedApp({ user: null, aal: null }).request('/gated', { method: 'DELETE' });
|
|
91
|
+
expect(res.status).toBe(401);
|
|
92
|
+
expect(await res.json()).toEqual({ error: 'Unauthorized' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('fails open (allows) and warns when the settings read throws', async () => {
|
|
96
|
+
maybeSingleMock.mockRejectedValueOnce(new Error('db down'));
|
|
97
|
+
const res = await gatedApp({ user: aUser, aal: 'aal1' }).request('/gated', { method: 'DELETE' });
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(warnMock).toHaveBeenCalledWith(
|
|
100
|
+
expect.objectContaining({ err: expect.any(Error) }),
|
|
101
|
+
expect.stringContaining('mfa-settings'),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('assertAal2 returns a 403 inline for aal1 when MFA is enabled', async () => {
|
|
106
|
+
enableMfa();
|
|
107
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
108
|
+
app.use('*', async (c, next) => {
|
|
109
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal injected user
|
|
110
|
+
c.set('user', aUser as any);
|
|
111
|
+
c.set('aal', 'aal1');
|
|
112
|
+
await next();
|
|
113
|
+
});
|
|
114
|
+
app.post('/inline', async (c) => {
|
|
115
|
+
const blocked = await assertAal2(c);
|
|
116
|
+
if (blocked) return blocked;
|
|
117
|
+
return c.json({ ok: true });
|
|
118
|
+
});
|
|
119
|
+
const res = await app.request('/inline', { method: 'POST' });
|
|
120
|
+
expect(res.status).toBe(403);
|
|
121
|
+
expect((await res.json()).error).toBe('mfa_required');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { jsonPost } from '../../../_helpers/app';
|
|
4
|
+
import type { HonoVariables } from '@server/types';
|
|
5
|
+
|
|
6
|
+
const { maybeSingleMock } = vi.hoisted(() => ({ maybeSingleMock: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
9
|
+
supabaseAdmin: {
|
|
10
|
+
from: () => ({ select: () => ({ eq: () => ({ maybeSingle: maybeSingleMock }) }) }),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('@server/lib/logger', () => ({
|
|
14
|
+
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
15
|
+
}));
|
|
16
|
+
// Billing is "configured" so the gate (which runs first) is what we observe,
|
|
17
|
+
// not a 503. getBillingProvider is never reached because the gate short-circuits.
|
|
18
|
+
vi.mock('@server/billing', () => ({
|
|
19
|
+
isBillingConfigured: () => true,
|
|
20
|
+
getBillingProvider: () => ({}),
|
|
21
|
+
getProviderPriceIds: () => ({}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { billingRoutes } = await import('@server/routes/v1/billing');
|
|
25
|
+
const { __resetMfaSettingsCache } = await import('@server/lib/mfa-settings');
|
|
26
|
+
|
|
27
|
+
function appWith(aal: 'aal1' | 'aal2') {
|
|
28
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
29
|
+
app.use('*', async (c, next) => {
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal injected user
|
|
31
|
+
c.set('user', { id: 'u1', email: 'u@test.com' } as any);
|
|
32
|
+
c.set('aal', aal);
|
|
33
|
+
await next();
|
|
34
|
+
});
|
|
35
|
+
app.route('/api/v1/billing', billingRoutes);
|
|
36
|
+
return app;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
__resetMfaSettingsCache();
|
|
41
|
+
maybeSingleMock.mockReset();
|
|
42
|
+
maybeSingleMock.mockResolvedValue({ data: { value: { enabled: true } }, error: null });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('billing endpoints are aal2-gated when MFA is globally enabled', () => {
|
|
46
|
+
for (const path of ['/checkout', '/portal', '/setup']) {
|
|
47
|
+
it(`POST ${path} returns 403 mfa_required for an aal1 session`, async () => {
|
|
48
|
+
const res = await appWith('aal1').request(
|
|
49
|
+
`/api/v1/billing${path}`,
|
|
50
|
+
jsonPost({ type: 'user' }),
|
|
51
|
+
);
|
|
52
|
+
expect(res.status).toBe(403);
|
|
53
|
+
expect((await res.json()).error).toBe('mfa_required');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('POST /checkout is NOT gated for an aal2 session (gate passes through)', async () => {
|
|
58
|
+
const res = await appWith('aal2').request('/api/v1/billing/checkout', jsonPost({ type: 'user' }));
|
|
59
|
+
// Gate satisfied → handler runs. Whatever it returns, it is NOT the mfa_required 403.
|
|
60
|
+
const body = await res.json();
|
|
61
|
+
expect(body.error).not.toBe('mfa_required');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { HonoVariables } from '@server/types';
|
|
4
|
+
|
|
5
|
+
const { maybeSingleMock } = vi.hoisted(() => ({ maybeSingleMock: vi.fn() }));
|
|
6
|
+
|
|
7
|
+
// supabaseAdmin is used both for the app_settings read (gate) and by handlers.
|
|
8
|
+
// The gate short-circuits gated paths before handlers run, so an app_settings-
|
|
9
|
+
// shaped chain is enough. The PATCH "skip" case proceeds into the handler,
|
|
10
|
+
// which calls getUserOrgRole on the USER client (c.get('supabase')) — injected
|
|
11
|
+
// separately below.
|
|
12
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
13
|
+
supabaseAdmin: {
|
|
14
|
+
from: () => ({ select: () => ({ eq: () => ({ maybeSingle: maybeSingleMock }) }) }),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('@server/lib/logger', () => ({
|
|
18
|
+
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@server/lib/email', () => ({ sendEmail: vi.fn() }));
|
|
21
|
+
|
|
22
|
+
const { v1Routes } = await import('@server/routes/v1/index');
|
|
23
|
+
const { __resetMfaSettingsCache } = await import('@server/lib/mfa-settings');
|
|
24
|
+
|
|
25
|
+
// User-scoped supabase stub whose membership lookups resolve to "no role", so a
|
|
26
|
+
// request that passes the gate cleanly 404s instead of crashing.
|
|
27
|
+
const noRoleSupabase = {
|
|
28
|
+
from: () => ({
|
|
29
|
+
select: () => ({ eq: () => ({ eq: () => ({ single: async () => ({ data: null }) }) }) }),
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function appWith(aal: 'aal1' | 'aal2', user: unknown = { id: 'u1', email: 'u@test.com' }) {
|
|
34
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
35
|
+
app.use('*', async (c, next) => {
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal injected context
|
|
37
|
+
c.set('user', user as any);
|
|
38
|
+
c.set('aal', aal);
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal user-client stub
|
|
40
|
+
c.set('supabase', noRoleSupabase as any);
|
|
41
|
+
await next();
|
|
42
|
+
});
|
|
43
|
+
app.route('/api/v1', v1Routes);
|
|
44
|
+
return app;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function jsonPatch(body: unknown): RequestInit {
|
|
48
|
+
return { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
__resetMfaSettingsCache();
|
|
53
|
+
maybeSingleMock.mockReset();
|
|
54
|
+
maybeSingleMock.mockResolvedValue({ data: { value: { enabled: true } }, error: null });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('account + org endpoints are aal2-gated when MFA is globally enabled', () => {
|
|
58
|
+
it('DELETE /me returns 403 mfa_required for aal1', async () => {
|
|
59
|
+
const res = await appWith('aal1').request('/api/v1/me', { method: 'DELETE' });
|
|
60
|
+
expect(res.status).toBe(403);
|
|
61
|
+
expect((await res.json()).error).toBe('mfa_required');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('DELETE /organizations/:id returns 403 mfa_required for aal1', async () => {
|
|
65
|
+
const res = await appWith('aal1').request('/api/v1/organizations/org-1', { method: 'DELETE' });
|
|
66
|
+
expect(res.status).toBe(403);
|
|
67
|
+
expect((await res.json()).error).toBe('mfa_required');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('PATCH member role -> OWNER returns 403 mfa_required for aal1 (transfer gated)', async () => {
|
|
71
|
+
const res = await appWith('aal1').request(
|
|
72
|
+
'/api/v1/organizations/org-1/members/u2',
|
|
73
|
+
jsonPatch({ role: 'OWNER' }),
|
|
74
|
+
);
|
|
75
|
+
expect(res.status).toBe(403);
|
|
76
|
+
expect((await res.json()).error).toBe('mfa_required');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('PATCH member role -> ADMIN is NOT blocked by the MFA gate for aal1', async () => {
|
|
80
|
+
const res = await appWith('aal1').request(
|
|
81
|
+
'/api/v1/organizations/org-1/members/u2',
|
|
82
|
+
jsonPatch({ role: 'ADMIN' }),
|
|
83
|
+
);
|
|
84
|
+
// Proceeds past the gate into the handler, which 404s on the null-role
|
|
85
|
+
// membership lookup. The point: it is NOT the mfa_required 403.
|
|
86
|
+
const body = await res.json();
|
|
87
|
+
expect(body.error).not.toBe('mfa_required');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('aal2 session passes the gate and reaches the handler on DELETE /organizations/:id', async () => {
|
|
91
|
+
// Same route as the aal1 -> 403 test above. With aal2 the gate is satisfied
|
|
92
|
+
// and the handler runs; getUserOrgRole (on the null-role stub) then returns
|
|
93
|
+
// 404 "not found / access denied". Asserting the concrete 404 — not merely
|
|
94
|
+
// "not 403" — proves the handler actually executed past the gate. The aal1
|
|
95
|
+
// companion test (403 mfa_required on this same route) is what guards
|
|
96
|
+
// against the middleware being accidentally removed: if it were, aal1 would
|
|
97
|
+
// also reach the handler and return 404 instead of 403.
|
|
98
|
+
const del = await appWith('aal2').request('/api/v1/organizations/org-1', { method: 'DELETE' });
|
|
99
|
+
expect(del.status).toBe(404);
|
|
100
|
+
expect((await del.json()).error).not.toBe('mfa_required');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { decodeAalFromJwt } from '@server/lib/jwt';
|
|
3
|
+
|
|
4
|
+
// Build a JWT with the given payload. Signature is irrelevant — decodeAalFromJwt
|
|
5
|
+
// never verifies it (the real session middleware already did via getUser()).
|
|
6
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
7
|
+
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
8
|
+
return `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.fakesig`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('decodeAalFromJwt', () => {
|
|
12
|
+
it('returns aal2 for an aal2 token', () => {
|
|
13
|
+
expect(decodeAalFromJwt(makeJwt({ aal: 'aal2', sub: 'u1' }))).toBe('aal2');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns aal1 for an aal1 token', () => {
|
|
17
|
+
expect(decodeAalFromJwt(makeJwt({ aal: 'aal1', sub: 'u1' }))).toBe('aal1');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns null when the aal claim is absent', () => {
|
|
21
|
+
expect(decodeAalFromJwt(makeJwt({ sub: 'u1' }))).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns null for an unexpected aal value', () => {
|
|
25
|
+
expect(decodeAalFromJwt(makeJwt({ aal: 'aal3' }))).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns null for a non-JWT string (wrong segment count)', () => {
|
|
29
|
+
expect(decodeAalFromJwt('not-a-jwt')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns null for malformed base64/JSON without throwing', () => {
|
|
33
|
+
expect(decodeAalFromJwt('aaa.@@@notbase64@@@.bbb')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns null for undefined/empty input', () => {
|
|
37
|
+
expect(decodeAalFromJwt(undefined)).toBeNull();
|
|
38
|
+
expect(decodeAalFromJwt('')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('tolerates a leading "Bearer " prefix', () => {
|
|
42
|
+
expect(decodeAalFromJwt(`Bearer ${makeJwt({ aal: 'aal2' })}`)).toBe('aal2');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { maybeSingleMock, warnMock } = vi.hoisted(() => ({
|
|
4
|
+
maybeSingleMock: vi.fn(),
|
|
5
|
+
warnMock: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// supabaseAdmin.from('app_settings').select('value').eq('key','mfa_enabled').maybeSingle()
|
|
9
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
10
|
+
supabaseAdmin: {
|
|
11
|
+
from: () => ({ select: () => ({ eq: () => ({ maybeSingle: maybeSingleMock }) }) }),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@server/lib/logger', () => ({
|
|
16
|
+
logger: { error: vi.fn(), info: vi.fn(), warn: warnMock, debug: vi.fn() },
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { isMfaGloballyEnabled, __resetMfaSettingsCache } = await import('@server/lib/mfa-settings');
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
__resetMfaSettingsCache();
|
|
23
|
+
maybeSingleMock.mockReset();
|
|
24
|
+
warnMock.mockReset();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('isMfaGloballyEnabled', () => {
|
|
28
|
+
it('returns true when the setting is { enabled: true }', async () => {
|
|
29
|
+
maybeSingleMock.mockResolvedValueOnce({ data: { value: { enabled: true } }, error: null });
|
|
30
|
+
expect(await isMfaGloballyEnabled()).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns false when the setting is { enabled: false }', async () => {
|
|
34
|
+
maybeSingleMock.mockResolvedValueOnce({ data: { value: { enabled: false } }, error: null });
|
|
35
|
+
expect(await isMfaGloballyEnabled()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns false when the row is missing', async () => {
|
|
39
|
+
maybeSingleMock.mockResolvedValueOnce({ data: null, error: null });
|
|
40
|
+
expect(await isMfaGloballyEnabled()).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('caches within the TTL — a second call issues no query', async () => {
|
|
44
|
+
maybeSingleMock.mockResolvedValueOnce({ data: { value: { enabled: true } }, error: null });
|
|
45
|
+
expect(await isMfaGloballyEnabled()).toBe(true);
|
|
46
|
+
expect(await isMfaGloballyEnabled()).toBe(true);
|
|
47
|
+
expect(maybeSingleMock).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('__resetMfaSettingsCache forces a re-query', async () => {
|
|
51
|
+
maybeSingleMock.mockResolvedValueOnce({ data: { value: { enabled: true } }, error: null });
|
|
52
|
+
expect(await isMfaGloballyEnabled()).toBe(true);
|
|
53
|
+
__resetMfaSettingsCache();
|
|
54
|
+
maybeSingleMock.mockResolvedValueOnce({ data: { value: { enabled: false } }, error: null });
|
|
55
|
+
expect(await isMfaGloballyEnabled()).toBe(false);
|
|
56
|
+
expect(maybeSingleMock).toHaveBeenCalledTimes(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('fails open to false (gate inert) and warns when the read throws', async () => {
|
|
60
|
+
maybeSingleMock.mockRejectedValueOnce(new Error('db down'));
|
|
61
|
+
expect(await isMfaGloballyEnabled()).toBe(false);
|
|
62
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/package.json
CHANGED
package/src/create.js
CHANGED
|
@@ -780,6 +780,16 @@ async function bootstrap(cliArgs) {
|
|
|
780
780
|
join(projectDir, 'volumes/db/set-passwords.sh'),
|
|
781
781
|
variables,
|
|
782
782
|
);
|
|
783
|
+
// wal-archive.sh is postgres's archive_command. It was missing from this copy
|
|
784
|
+
// list, so scaffolds shipped no source file — Docker then bind-mounted an
|
|
785
|
+
// empty DIRECTORY at /etc/postgresql/wal-archive.sh and postgres failed
|
|
786
|
+
// archiving with exit 126 ("is a directory"), re-pinning WAL. Copy it (the
|
|
787
|
+
// chmod block below makes it +x; the script has only shell $vars, no {{}}).
|
|
788
|
+
copyTemplate(
|
|
789
|
+
'volumes/db/wal-archive.sh',
|
|
790
|
+
join(projectDir, 'volumes/db/wal-archive.sh'),
|
|
791
|
+
variables,
|
|
792
|
+
);
|
|
783
793
|
copyTemplate('volumes/db/jwt.sql', join(projectDir, 'volumes/db/jwt.sql'), variables);
|
|
784
794
|
copyTemplate('volumes/db/realtime.sql', join(projectDir, 'volumes/db/realtime.sql'), variables);
|
|
785
795
|
copyTemplate(
|
|
@@ -854,6 +864,11 @@ async function bootstrap(cliArgs) {
|
|
|
854
864
|
'docker-entrypoint.sh',
|
|
855
865
|
'volumes/kong/docker-entrypoint.sh',
|
|
856
866
|
'volumes/db/set-passwords.sh',
|
|
867
|
+
// Postgres execs this directly as its archive_command, so it MUST be +x.
|
|
868
|
+
// copyTemplate (copyFileSync) drops the source's exec bit, and omitting it
|
|
869
|
+
// here shipped a 0644 script → archive_command failed with exit 126 ("not
|
|
870
|
+
// executable") → WAL pinned → disk-fill risk the wrapper exists to prevent.
|
|
871
|
+
'volumes/db/wal-archive.sh',
|
|
857
872
|
];
|
|
858
873
|
for (const script of shellScripts) {
|
|
859
874
|
const scriptPath = join(projectDir, script);
|
package/src/destroy.js
CHANGED
|
@@ -693,7 +693,29 @@ async function handleBackupBucket(envConfig, projectConfig, args, spinner) {
|
|
|
693
693
|
}
|
|
694
694
|
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, region);
|
|
695
695
|
try {
|
|
696
|
-
|
|
696
|
+
// Bound the purge so a slow/contended S3 endpoint can't consume the whole
|
|
697
|
+
// destroy budget. The Hetzner resources are already freed (Pulumi destroy
|
|
698
|
+
// ran before this), but a single S3 op can hang up to ~9 min under the
|
|
699
|
+
// SDK (maxAttempts 3 × 60s requestTimeout) × _send (maxAttempts 3) retry
|
|
700
|
+
// layers — observed on k8s teardown, where the storage bucket doubles as
|
|
701
|
+
// the Pulumi state backend (slow/contended), blowing the 600s
|
|
702
|
+
// final-destroy timeout and getting the whole step SIGKILLed. On timeout
|
|
703
|
+
// we leave the bucket for the orphan sweep / next destroy rather than hang.
|
|
704
|
+
const PURGE_TIMEOUT_MS = 180_000;
|
|
705
|
+
const result = await Promise.race([
|
|
706
|
+
s3Provider.emptyAndDeleteBucket(bucketName),
|
|
707
|
+
new Promise((_, reject) =>
|
|
708
|
+
setTimeout(
|
|
709
|
+
() =>
|
|
710
|
+
reject(
|
|
711
|
+
new Error(
|
|
712
|
+
`backup bucket purge exceeded ${PURGE_TIMEOUT_MS / 1000}s — left for the orphan sweep / a retried destroy`,
|
|
713
|
+
),
|
|
714
|
+
),
|
|
715
|
+
PURGE_TIMEOUT_MS,
|
|
716
|
+
),
|
|
717
|
+
),
|
|
718
|
+
]);
|
|
697
719
|
spinner.stop(
|
|
698
720
|
result.deleted
|
|
699
721
|
? result.objectsRemoved > 0
|
|
@@ -1043,15 +1043,18 @@ export async function deployComposeHA(options) {
|
|
|
1043
1043
|
]),
|
|
1044
1044
|
);
|
|
1045
1045
|
|
|
1046
|
-
// Run migrations on primary only
|
|
1046
|
+
// Run migrations on primary only. Let failures propagate — silently shipping
|
|
1047
|
+
// an empty/partial schema is far worse than a visibly-failed deploy the
|
|
1048
|
+
// operator can retry. The old swallowing try/catch ("non-fatal") masked
|
|
1049
|
+
// exactly that: HA could deploy a schema-less DB that 500s on every DB-backed
|
|
1050
|
+
// route with no error surfaced. runMigrations now waits for supabase_admin AND
|
|
1051
|
+
// the storage schema (storage.buckets.public) before applying with
|
|
1052
|
+
// ON_ERROR_STOP=1, so the first-run races that motivated the swallow are gone
|
|
1053
|
+
// and a failure here is a real one that must abort the deploy.
|
|
1047
1054
|
onProgress('Running database migrations on primary...');
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
);
|
|
1052
|
-
} catch {
|
|
1053
|
-
// Migrations may partially fail on first run, non-fatal
|
|
1054
|
-
}
|
|
1055
|
+
await perfAsync('deploy.ha.compose.migrations', () =>
|
|
1056
|
+
runMigrations(primaryIp, sshKeyPath, projectName),
|
|
1057
|
+
);
|
|
1055
1058
|
|
|
1056
1059
|
// Create admin user in production Supabase (same as single-server deploy)
|
|
1057
1060
|
onProgress('Creating admin user...');
|
package/src/lib/deploy/image.js
CHANGED
|
@@ -92,11 +92,11 @@ export function inspectGitState(cwd) {
|
|
|
92
92
|
* pod (Phase 1 wired the registries.yaml; Phase 6 wires the push).
|
|
93
93
|
*
|
|
94
94
|
* @param {string} projectDir - Project root with the Dockerfile.
|
|
95
|
-
* @param {{projectName: string, timestamp?: string, rebuild?: boolean, tagPrefix?: string}} options
|
|
95
|
+
* @param {{projectName: string, timestamp?: string, rebuild?: boolean, tagPrefix?: string, buildArgs?: Record<string,string>}} options
|
|
96
96
|
* @returns {{tag: string, gitSha: string, isDirty: boolean}}
|
|
97
97
|
*/
|
|
98
98
|
export function buildLocalImage(projectDir, options) {
|
|
99
|
-
const { projectName, rebuild = false, tagPrefix } = options;
|
|
99
|
+
const { projectName, rebuild = false, tagPrefix, buildArgs = {} } = options;
|
|
100
100
|
if (!projectName) {
|
|
101
101
|
throw new Error('buildLocalImage: projectName is required');
|
|
102
102
|
}
|
|
@@ -112,6 +112,14 @@ export function buildLocalImage(projectDir, options) {
|
|
|
112
112
|
|
|
113
113
|
const args = ['build'];
|
|
114
114
|
if (rebuild) args.push('--no-cache');
|
|
115
|
+
// Vite inlines import.meta.env.VITE_* at build time, so the browser bundle
|
|
116
|
+
// bakes whatever these ARGs resolve to. Without passing them the bundle ships
|
|
117
|
+
// empty VITE_SUPABASE_* and crashes at page load with "Missing Supabase
|
|
118
|
+
// environment variables" — k8s shipped exactly this because buildAppImage
|
|
119
|
+
// passed no build args (compose plumbs them via collectComposeBuildArgs).
|
|
120
|
+
for (const [k, v] of Object.entries(buildArgs)) {
|
|
121
|
+
if (v !== undefined && v !== null && v !== '') args.push('--build-arg', `${k}=${v}`);
|
|
122
|
+
}
|
|
115
123
|
args.push('-t', tag, projectDir);
|
|
116
124
|
|
|
117
125
|
execFileSync('docker', args, { stdio: 'inherit' });
|
|
@@ -556,9 +556,25 @@ export function loadEnvLocal(envPath) {
|
|
|
556
556
|
* @param {boolean} [rebuild]
|
|
557
557
|
* @returns {Promise<{tag: string, gitSha: string, isDirty: boolean}>}
|
|
558
558
|
*/
|
|
559
|
-
export async function buildAppImage(projectDir, projectName, rebuild = false) {
|
|
559
|
+
export async function buildAppImage(projectDir, projectName, rebuild = false, domain = undefined) {
|
|
560
560
|
const { buildLocalImage } = await import('../image.js');
|
|
561
|
-
|
|
561
|
+
// App image: collect VITE_* build args (incl. VITE_SUPABASE_URL rewritten to
|
|
562
|
+
// https://api.<domain>) so the browser bundle ships with real config instead
|
|
563
|
+
// of empty strings. buildLocalImage previously passed NO build args, so the
|
|
564
|
+
// k8s SPA crashed on load with "Missing Supabase environment variables" —
|
|
565
|
+
// compose plumbs these via collectComposeBuildArgs, k8s did not. The backup
|
|
566
|
+
// image build (no frontend) calls this without a domain → no VITE args.
|
|
567
|
+
let buildArgs = {};
|
|
568
|
+
if (domain) {
|
|
569
|
+
const { collectComposeBuildArgs } = await import('../compose/build-args.js');
|
|
570
|
+
buildArgs = collectComposeBuildArgs(projectDir, { projectName, domain });
|
|
571
|
+
}
|
|
572
|
+
return buildLocalImage(projectDir, {
|
|
573
|
+
projectName,
|
|
574
|
+
rebuild,
|
|
575
|
+
tagPrefix: '10.0.1.1:5000',
|
|
576
|
+
buildArgs,
|
|
577
|
+
});
|
|
562
578
|
}
|
|
563
579
|
|
|
564
580
|
/**
|
|
@@ -2436,11 +2452,14 @@ export async function deployK3s(options) {
|
|
|
2436
2452
|
}
|
|
2437
2453
|
|
|
2438
2454
|
// 6. Build the app image locally
|
|
2439
|
-
|
|
2455
|
+
// domain is part of the cache key + passed to the build: it's baked into the
|
|
2456
|
+
// browser bundle (VITE_SUPABASE_URL=https://api.<domain>), so a domain change
|
|
2457
|
+
// must force a rebuild rather than serving a stale-domain bundle.
|
|
2458
|
+
const buildInputs = { projectName: options.projectName, domain: options.domain };
|
|
2440
2459
|
if (!state.shouldSkip('k3s-build', buildInputs)) {
|
|
2441
2460
|
state.startStep('k3s-build', buildInputs);
|
|
2442
2461
|
s.start('Building app image (10.0.1.1:5000/...)');
|
|
2443
|
-
const built = await buildAppImage(projectDir, options.projectName);
|
|
2462
|
+
const built = await buildAppImage(projectDir, options.projectName, false, options.domain);
|
|
2444
2463
|
state.completeStep('k3s-build', {
|
|
2445
2464
|
tag: built.tag,
|
|
2446
2465
|
gitSha: built.gitSha,
|
|
@@ -36,8 +36,13 @@ export async function buildRemote(ip, sshKeyPath, imageTag, cwd, buildArgs = {})
|
|
|
36
36
|
// key path via env var rather than string-interpolating into the script
|
|
37
37
|
// to keep shell-escaping concerns out of the picture (paths with spaces
|
|
38
38
|
// or quotes would otherwise break the wrapper).
|
|
39
|
+
// ServerAliveInterval/CountMax: BuildKit holds a long-lived SSH session for
|
|
40
|
+
// the build; on a congested/cross-region link it can stall and the http2
|
|
41
|
+
// session helper drops with "error reading preface ... file already closed".
|
|
42
|
+
// Keepalives let ssh detect a dead peer (~60s) and fail the attempt cleanly
|
|
43
|
+
// so the retry below can re-establish, instead of hanging to the build timeout.
|
|
39
44
|
const sshWrapper = `#!/bin/bash
|
|
40
|
-
exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 "$@"
|
|
45
|
+
exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
|
|
41
46
|
`;
|
|
42
47
|
writeFileSync(sshWrapperPath, sshWrapper, { mode: 0o755 });
|
|
43
48
|
|
|
@@ -103,15 +108,31 @@ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKn
|
|
|
103
108
|
// (compose-ha 2026-05-09T00:38 e2 failure). Treat the false return as a
|
|
104
109
|
// hard failure so the error path captures + reports it.
|
|
105
110
|
const buildT = perfTimer('deploy.image.remoteBuild');
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
// BuildKit-over-SSH is flaky on first connect to a freshly-booted (often
|
|
112
|
+
// cross-region) VPS: the session helper intermittently drops mid-build with
|
|
113
|
+
// `http2: server: error reading preface ... file already closed` / an SSH
|
|
114
|
+
// reset, and runCommand returns false. Retry a few times — each attempt
|
|
115
|
+
// re-establishes a fresh SSH/BuildKit session, and BuildKit's cache makes
|
|
116
|
+
// retries cheap. Without this a single transient drop failed the whole
|
|
117
|
+
// deploy (compose-ha standby) or left no image so `docker compose up` later
|
|
118
|
+
// hit "pull access denied" (compose scale, e2e loop run #1).
|
|
119
|
+
const BUILD_ATTEMPTS = 3;
|
|
120
|
+
let buildOk = false;
|
|
121
|
+
for (let attempt = 1; attempt <= BUILD_ATTEMPTS; attempt++) {
|
|
122
|
+
buildOk = runCommand(args, { cwd, stdio: 'inherit', env, timeout: 600000 });
|
|
123
|
+
if (buildOk !== false) break;
|
|
124
|
+
if (attempt < BUILD_ATTEMPTS) {
|
|
125
|
+
console.error(
|
|
126
|
+
`[remote-build] docker build on ${ip} failed (attempt ${attempt}/${BUILD_ATTEMPTS}) — retrying in ${3 * attempt}s`,
|
|
127
|
+
);
|
|
128
|
+
await new Promise((r) => setTimeout(r, 3000 * attempt));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
112
131
|
buildT.end();
|
|
113
132
|
if (buildOk === false) {
|
|
114
|
-
throw new Error(
|
|
133
|
+
throw new Error(
|
|
134
|
+
`docker build failed on ${ip} for tag ${imageTag} after ${BUILD_ATTEMPTS} attempts`,
|
|
135
|
+
);
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
s.stop(`Image built natively: ${imageTag}`);
|
package/src/scale.js
CHANGED
|
@@ -439,9 +439,22 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
439
439
|
// Reuses the same code that does direct-mode deploy builds.
|
|
440
440
|
if (oldAppImage && isLocalOnlyImageTag(oldAppImage)) {
|
|
441
441
|
s.start(`Building image ${oldAppImage} on new server...`);
|
|
442
|
-
await perfAsync('scale.sideloadImage', () =>
|
|
442
|
+
const built = await perfAsync('scale.sideloadImage', () =>
|
|
443
443
|
buildRemote(newIp, sshKeyPath, oldAppImage, process.cwd()),
|
|
444
444
|
);
|
|
445
|
+
// buildRemote returns false on failure (after its own retries). The app
|
|
446
|
+
// image is local-only, so if it isn't built on the new server, the
|
|
447
|
+
// `docker compose up` below pulls it and dies ~a minute later with
|
|
448
|
+
// "pull access denied / repository does not exist" — a confusing
|
|
449
|
+
// downstream symptom. Fail loudly here instead; this guard was missing
|
|
450
|
+
// (the HA deploy path has the equivalent check). RCA: e2e loop run #1.
|
|
451
|
+
if (!built) {
|
|
452
|
+
s.stop('Remote image build failed on new server', 1);
|
|
453
|
+
throw new Error(
|
|
454
|
+
`scale: failed to build local app image ${oldAppImage} on new server ${newIp} ` +
|
|
455
|
+
`(buildRemote exhausted its retries) — aborting before compose up.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
445
458
|
s.stop('Image built on new server');
|
|
446
459
|
}
|
|
447
460
|
|