vibecarbon 0.2.1 → 0.3.1

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.
@@ -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.
@@ -171,6 +171,14 @@ services:
171
171
  # failover — pg_promote retried 5 times against an unreachable
172
172
  # standby psql before aborting.
173
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
174
182
  environment:
175
183
  POSTGRES_HOST: /var/run/postgresql
176
184
  PGPORT: 5432
@@ -330,6 +338,11 @@ services:
330
338
  PGRST_DB_USE_LEGACY_GUCS: "false"
331
339
  PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
332
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"
333
346
  command: postgrest
334
347
  depends_on:
335
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
- const insertData =
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
- const orgId = c.req.param('orgId');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
@@ -76,7 +76,8 @@
76
76
  "fast-xml-builder@<1.1.7": ">=1.1.7",
77
77
  "ip-address@<10.1.1": ">=10.1.1",
78
78
  "protobufjs@<7.5.6": "^7.5.6",
79
- "@protobufjs/utf8@<1.1.1": "^1.1.1"
79
+ "@protobufjs/utf8@<1.1.1": "^1.1.1",
80
+ "tmp@<0.2.6": ">=0.2.6"
80
81
  }
81
82
  },
82
83
  "devDependencies": {