vibecarbon 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +78 -0
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +63 -20
  60. package/src/lib/deploy/image.js +19 -23
  61. package/src/lib/deploy/k8s/ha/index.js +397 -150
  62. package/src/lib/deploy/k8s/k3s.js +65 -14
  63. package/src/lib/deploy/orchestrator.js +176 -6
  64. package/src/lib/deploy/remote-build.js +8 -1
  65. package/src/lib/deploy/replication.js +540 -0
  66. package/src/lib/deploy/state.js +62 -5
  67. package/src/lib/deploy/utils.js +30 -2
  68. package/src/lib/deploy/wireguard.js +146 -0
  69. package/src/lib/host-keys.js +120 -5
  70. package/src/lib/iac/index.js +57 -23
  71. package/src/lib/licensing/gate.js +59 -0
  72. package/src/lib/licensing/index.js +1 -1
  73. package/src/lib/licensing/tiers.js +6 -5
  74. package/src/lib/prod-confirm.js +65 -0
  75. package/src/lib/providers/hetzner-s3.js +85 -0
  76. package/src/lib/ssh.js +35 -35
  77. package/src/restore.js +102 -14
  78. package/src/scale.js +4 -2
  79. package/src/status.js +122 -0
  80. package/src/upgrade.js +0 -4
  81. package/carbon/ha/activate-standby.sh +0 -52
  82. package/carbon/ha/standby-init.sh +0 -74
  83. package/carbon/src/server/lib/request.ts +0 -34
  84. package/src/lib/pod-backups.js +0 -53
@@ -1,9 +1,8 @@
1
1
  import type { SupabaseClient } from '@supabase/supabase-js';
2
2
  import type { Context, Next } from 'hono';
3
3
  import type { PlanId } from '../../shared/pricing';
4
- import { planIdFromStripePriceId } from '../../shared/pricing';
5
- import { env } from '../lib/env';
6
- import { isStripeConfigured } from '../lib/stripe';
4
+ import { planIdFromPriceId } from '../../shared/pricing';
5
+ import { getProviderPriceIds, isBillingConfigured } from '../billing';
7
6
  import { supabaseAdmin } from '../lib/supabase';
8
7
  import type { HonoVariables } from '../types';
9
8
 
@@ -16,6 +15,62 @@ const planHierarchy: Record<PlanId, number> = {
16
15
  pro: 2,
17
16
  };
18
17
 
18
+ /**
19
+ * Resolve the highest active plan available to a user, considering BOTH their
20
+ * personal subscription and the subscriptions of any organization they belong
21
+ * to. A user is entitled to a plan if their own customer record OR any org they
22
+ * are a member of has an active/trialing subscription at that tier.
23
+ */
24
+ async function resolveHighestPlan(userId: string): Promise<PlanId> {
25
+ const priceIds = getProviderPriceIds();
26
+ const customerIds: string[] = [];
27
+
28
+ // Personal customer (organization_id IS NULL).
29
+ const { data: personal } = await adminDb
30
+ .from('customers')
31
+ .select('id')
32
+ .eq('user_id', userId)
33
+ .is('organization_id', null)
34
+ .maybeSingle();
35
+ if (personal?.id) customerIds.push(personal.id);
36
+
37
+ // Org customers for every organization the user is a member of.
38
+ const { data: memberships } = await adminDb
39
+ .from('memberships')
40
+ .select('organization_id')
41
+ .eq('user_id', userId);
42
+ const orgIds = (memberships ?? [])
43
+ .map((m: { organization_id: string | null }) => m.organization_id)
44
+ .filter((id: string | null): id is string => Boolean(id));
45
+
46
+ if (orgIds.length > 0) {
47
+ const { data: orgCustomers } = await adminDb
48
+ .from('customers')
49
+ .select('id')
50
+ .in('organization_id', orgIds);
51
+ for (const c of orgCustomers ?? []) {
52
+ if (c.id) customerIds.push(c.id);
53
+ }
54
+ }
55
+
56
+ if (customerIds.length === 0) return 'free';
57
+
58
+ const { data: subscriptions } = await adminDb
59
+ .from('subscriptions')
60
+ .select('stripe_price_id, status')
61
+ .in('customer_id', customerIds)
62
+ .in('status', ['active', 'trialing']);
63
+
64
+ let highest: PlanId = 'free';
65
+ for (const sub of subscriptions ?? []) {
66
+ const plan = planIdFromPriceId(sub.stripe_price_id, priceIds);
67
+ if (planHierarchy[plan] > planHierarchy[highest]) {
68
+ highest = plan;
69
+ }
70
+ }
71
+ return highest;
72
+ }
73
+
19
74
  /**
20
75
  * Hono middleware that gates API routes behind a minimum plan.
21
76
  *
@@ -25,8 +80,10 @@ const planHierarchy: Record<PlanId, number> = {
25
80
  */
26
81
  export function requirePlan(minimumPlan: PlanId) {
27
82
  return async (c: Context<{ Variables: HonoVariables }>, next: Next) => {
28
- // If billing isn't configured, allow all requests (dev mode / self-hosted)
29
- if (!isStripeConfigured()) {
83
+ // If billing isn't configured at all (dev / self-hosted with no provider),
84
+ // allow all requests. Uses isBillingConfigured() — provider-agnostic —
85
+ // rather than the Stripe-only check, so Paddle/Polar deployments gate too.
86
+ if (!isBillingConfigured()) {
30
87
  await next();
31
88
  return;
32
89
  }
@@ -36,40 +93,13 @@ export function requirePlan(minimumPlan: PlanId) {
36
93
  return c.json({ error: 'Unauthorized' }, 401);
37
94
  }
38
95
 
39
- // Free plan requires no subscription check
96
+ // Free plan requires no subscription check.
40
97
  if (minimumPlan === 'free') {
41
98
  await next();
42
99
  return;
43
100
  }
44
101
 
45
- // Find customer for this user
46
- const { data: customer } = await adminDb
47
- .from('customers')
48
- .select('id')
49
- .eq('user_id', user.id)
50
- .is('organization_id', null)
51
- .maybeSingle();
52
-
53
- if (!customer) {
54
- return c.json({ error: 'Upgrade required', requiredPlan: minimumPlan }, 403);
55
- }
56
-
57
- // Find active subscription
58
- const { data: subscription } = await adminDb
59
- .from('subscriptions')
60
- .select('stripe_price_id, status')
61
- .eq('customer_id', customer.id)
62
- .in('status', ['active', 'trialing'])
63
- .maybeSingle();
64
-
65
- if (!subscription) {
66
- return c.json({ error: 'Upgrade required', requiredPlan: minimumPlan }, 403);
67
- }
68
-
69
- const currentPlan = planIdFromStripePriceId(subscription.stripe_price_id, {
70
- starter: env.STRIPE_PRICE_STARTER,
71
- pro: env.STRIPE_PRICE_PRO,
72
- });
102
+ const currentPlan = await resolveHighestPlan(user.id);
73
103
 
74
104
  if (planHierarchy[currentPlan] < planHierarchy[minimumPlan]) {
75
105
  return c.json({ error: 'Upgrade required', requiredPlan: minimumPlan, currentPlan }, 403);
@@ -0,0 +1,26 @@
1
+ import type { Context, Next } from 'hono';
2
+ import { isSuperAdmin } from '../lib/auth';
3
+ import type { HonoVariables } from '../types';
4
+
5
+ /**
6
+ * Hono middleware: gate a route (or route group) behind the super_admin role.
7
+ *
8
+ * The role is read from the JWT's `app_metadata.role` (verified upstream by the
9
+ * session middleware via `supabase.auth.getUser()`), matching stats.ts/theme.ts.
10
+ *
11
+ * Usage:
12
+ * adminRoutes.use('*', requireSuperAdmin);
13
+ */
14
+ export async function requireSuperAdmin(
15
+ c: Context<{ Variables: HonoVariables }>,
16
+ next: Next
17
+ ): Promise<Response | undefined> {
18
+ const user = c.get('user');
19
+ if (!user) {
20
+ return c.json({ error: 'Unauthorized' }, 401);
21
+ }
22
+ if (!isSuperAdmin(user)) {
23
+ return c.json({ error: 'Super admin access required' }, 403);
24
+ }
25
+ await next();
26
+ }
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
3
3
  import { z } from 'zod';
4
4
  import { sanitizeError } from '../../../lib/errors';
5
5
  import { supabaseAdmin } from '../../../lib/supabase';
6
+ import { requireSuperAdmin } from '../../../middleware/requireSuperAdmin';
6
7
  import type { HonoVariables } from '../../../types';
7
8
 
8
9
  // biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
@@ -10,26 +11,10 @@ const adminDb = supabaseAdmin as SupabaseClient<any>;
10
11
 
11
12
  const adminContactRoutes = new Hono<{ Variables: HonoVariables }>();
12
13
 
13
- /**
14
- * Require super admin for all contact management endpoints
15
- */
16
- adminContactRoutes.use('*', async (c, next) => {
17
- const user = c.get('user');
18
- if (!user) return c.json({ error: 'Unauthorized' }, 401);
19
-
20
- const { data } = await adminDb
21
- .from('auth.users')
22
- .select('raw_app_meta_data')
23
- .eq('id', user.id)
24
- .single();
25
-
26
- const role = data?.raw_app_meta_data?.role;
27
- if (role !== 'super_admin') {
28
- return c.json({ error: 'Forbidden' }, 403);
29
- }
30
-
31
- await next();
32
- });
14
+ // Require super admin for all contact management endpoints (JWT-based role
15
+ // check see requireSuperAdmin). The prior `adminDb.from('auth.users')` guard
16
+ // errored on the unexposed auth schema and fell closed for everyone.
17
+ adminContactRoutes.use('*', requireSuperAdmin);
33
18
 
34
19
  /**
35
20
  * List contact submissions with pagination
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
3
3
  import { z } from 'zod';
4
4
  import { sanitizeError } from '../../../lib/errors';
5
5
  import { supabaseAdmin } from '../../../lib/supabase';
6
+ import { requireSuperAdmin } from '../../../middleware/requireSuperAdmin';
6
7
  import type { HonoVariables } from '../../../types';
7
8
 
8
9
  // biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
@@ -10,26 +11,11 @@ const adminDb = supabaseAdmin as SupabaseClient<any>;
10
11
 
11
12
  const jobsRoutes = new Hono<{ Variables: HonoVariables }>();
12
13
 
13
- /**
14
- * Require super admin for all job management endpoints
15
- */
16
- jobsRoutes.use('*', async (c, next) => {
17
- const user = c.get('user');
18
- if (!user) return c.json({ error: 'Unauthorized' }, 401);
19
-
20
- const { data } = await adminDb
21
- .from('auth.users')
22
- .select('raw_app_meta_data')
23
- .eq('id', user.id)
24
- .single();
25
-
26
- const role = data?.raw_app_meta_data?.role;
27
- if (role !== 'super_admin') {
28
- return c.json({ error: 'Forbidden' }, 403);
29
- }
30
-
31
- await next();
32
- });
14
+ // Require super admin for all job management endpoints. Uses the JWT-based
15
+ // role check (app_metadata.role) the previous `adminDb.from('auth.users')`
16
+ // query errored because the auth schema isn't exposed to PostgREST, which made
17
+ // this guard fail closed (403 for everyone) and broke the admin panels.
18
+ jobsRoutes.use('*', requireSuperAdmin);
33
19
 
34
20
  /**
35
21
  * List all scheduled cron jobs
@@ -6,6 +6,7 @@ import { env } from '../../../lib/env';
6
6
  import { sanitizeError } from '../../../lib/errors';
7
7
  import { logger } from '../../../lib/logger';
8
8
  import { supabaseAdmin } from '../../../lib/supabase';
9
+ import { requireSuperAdmin } from '../../../middleware/requireSuperAdmin';
9
10
  import type { HonoVariables } from '../../../types';
10
11
 
11
12
  // biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
@@ -13,26 +14,27 @@ const adminDb = supabaseAdmin as SupabaseClient<any>;
13
14
 
14
15
  const adminNewsletterRoutes = new Hono<{ Variables: HonoVariables }>();
15
16
 
17
+ // Require super admin for all newsletter management endpoints (JWT-based role
18
+ // check — see requireSuperAdmin). The prior `adminDb.from('auth.users')` guard
19
+ // errored on the unexposed auth schema and fell closed for everyone.
20
+ adminNewsletterRoutes.use('*', requireSuperAdmin);
21
+
16
22
  /**
17
- * Require super admin for all newsletter management endpoints
23
+ * Escape a value for safe inclusion in a CSV cell.
24
+ *
25
+ * SECURITY: subscriber `name` is attacker-controlled via the public /subscribe
26
+ * endpoint. Two hazards are handled:
27
+ * 1. CSV structure — always quote and double any internal `"` so a value can't
28
+ * inject extra columns/rows.
29
+ * 2. CSV/formula injection — a cell beginning with `= + - @` or a control char
30
+ * (tab, CR) is executed as a formula by Excel/Sheets when the file is opened.
31
+ * Prefix such cells with a single quote to neutralize them.
18
32
  */
19
- adminNewsletterRoutes.use('*', async (c, next) => {
20
- const user = c.get('user');
21
- if (!user) return c.json({ error: 'Unauthorized' }, 401);
22
-
23
- const { data } = await adminDb
24
- .from('auth.users')
25
- .select('raw_app_meta_data')
26
- .eq('id', user.id)
27
- .single();
28
-
29
- const role = data?.raw_app_meta_data?.role;
30
- if (role !== 'super_admin') {
31
- return c.json({ error: 'Forbidden' }, 403);
32
- }
33
-
34
- await next();
35
- });
33
+ function csvCell(value: string | null | undefined): string {
34
+ const s = value == null ? '' : String(value);
35
+ const guarded = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
36
+ return `"${guarded.replace(/"/g, '""')}"`;
37
+ }
36
38
 
37
39
  /**
38
40
  * List subscribers with pagination and filtering
@@ -133,10 +135,11 @@ adminNewsletterRoutes.post('/send', async (c) => {
133
135
  }
134
136
 
135
137
  try {
136
- // Get all active subscribers
138
+ // Get all active subscribers. confirmation_token doubles as the per-subscriber
139
+ // unsubscribe secret (see routes/v1/newsletter.ts).
137
140
  const { data: subscribers, error } = await adminDb
138
141
  .from('newsletter_subscribers')
139
- .select('id, email')
142
+ .select('id, email, confirmation_token')
140
143
  .eq('status', 'active');
141
144
 
142
145
  if (error) {
@@ -153,7 +156,7 @@ adminNewsletterRoutes.post('/send', async (c) => {
153
156
 
154
157
  // Send emails in batches (non-blocking)
155
158
  for (const subscriber of subscribers) {
156
- const unsubscribeUrl = `${baseUrl}/api/v1/newsletter/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.id}`;
159
+ const unsubscribeUrl = `${baseUrl}/api/v1/newsletter/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${encodeURIComponent(subscriber.confirmation_token ?? '')}`;
157
160
  const emailHtml = `${body.html}
158
161
  <hr style="margin: 32px 0; border: none; border-top: 1px solid #e4e4e7;">
159
162
  <p style="font-size: 12px; color: #71717a; text-align: center;">
@@ -222,9 +225,9 @@ adminNewsletterRoutes.get('/export', async (c) => {
222
225
  status: string;
223
226
  subscribed_at: string | null;
224
227
  created_at: string;
225
- }) => `${s.email},${s.name || ''},${s.status},${s.subscribed_at || ''},${s.created_at}`
228
+ }) => [s.email, s.name, s.status, s.subscribed_at, s.created_at].map(csvCell).join(',')
226
229
  ),
227
- ].join('\n');
230
+ ].join('\r\n');
228
231
 
229
232
  c.header('Content-Type', 'text/csv');
230
233
  c.header('Content-Disposition', 'attachment; filename="newsletter-subscribers.csv"');
@@ -2,10 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js';
2
2
  import { Hono } from 'hono';
3
3
  import { z } from 'zod';
4
4
  import { isSuperAdmin } from '../../lib/auth';
5
+ import { getClientIp } from '../../lib/client-ip';
5
6
  import { env } from '../../lib/env';
6
7
  import { sanitizeError } from '../../lib/errors';
7
8
  import { logger } from '../../lib/logger';
8
- import { getClientIp } from '../../lib/request';
9
9
  import { createAuthClient, supabaseAdmin } from '../../lib/supabase';
10
10
  import type { HonoVariables } from '../../types';
11
11
 
@@ -106,7 +106,10 @@ authRoutes.post('/login', async (c) => {
106
106
  logger.error({ error: recordError }, 'Failed to record login attempt');
107
107
  }
108
108
 
109
- return c.json({ error: authError?.message || 'Invalid credentials' }, 401);
109
+ // SECURITY: return a single generic message regardless of the underlying
110
+ // auth error so responses can't be used to enumerate which emails exist
111
+ // or distinguish "wrong password" from "no such user".
112
+ return c.json({ error: 'Invalid credentials' }, 401);
110
113
  }
111
114
 
112
115
  // Step 3b: Clear failed attempts on success
@@ -5,10 +5,15 @@ import { planIdFromPriceId, plans } from '../../../shared/pricing';
5
5
  import { getBillingProvider, getProviderPriceIds, isBillingConfigured } from '../../billing';
6
6
  import { env } from '../../lib/env';
7
7
  import { sanitizeError } from '../../lib/errors';
8
+ import { createRateLimiter } from '../../lib/rate-limiter';
8
9
  import { supabaseAdmin } from '../../lib/supabase';
9
10
  import { requireAal2 } from '../../middleware/requireAal2';
11
+ import { requireOrgRole } from '../../middleware/requireOrgRole';
10
12
  import type { HonoVariables } from '../../types';
11
13
 
14
+ // Organization billing actions require an owner/admin of the target org.
15
+ const ORG_BILLING_ROLES = ['OWNER', 'ADMIN'];
16
+
12
17
  // Helper to access tables not yet in generated types
13
18
  // biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
14
19
  const adminDb = supabaseAdmin as SupabaseClient<any>;
@@ -236,9 +241,13 @@ billingRoutes.get('/prices', (c) => {
236
241
  // ============================================================================
237
242
 
238
243
  /**
239
- * Get current subscription for user or organization
244
+ * Get current subscription for user or organization.
245
+ *
246
+ * SECURITY: requireOrgRole enforces org membership before this handler runs, so
247
+ * a caller cannot read another organization's subscription by supplying an
248
+ * arbitrary organizationId (the previous IDOR).
240
249
  */
241
- billingRoutes.get('/subscription', async (c) => {
250
+ billingRoutes.get('/subscription', requireOrgRole(ORG_BILLING_ROLES), async (c) => {
242
251
  const user = c.get('user');
243
252
 
244
253
  if (!user) {
@@ -365,7 +374,7 @@ billingRoutes.get('/subscription', async (c) => {
365
374
  /**
366
375
  * Create a checkout session via the active billing provider
367
376
  */
368
- billingRoutes.post('/checkout', requireAal2, async (c) => {
377
+ billingRoutes.post('/checkout', requireAal2, requireOrgRole(ORG_BILLING_ROLES), async (c) => {
369
378
  const user = c.get('user');
370
379
 
371
380
  if (!user) {
@@ -376,7 +385,8 @@ billingRoutes.post('/checkout', requireAal2, async (c) => {
376
385
  return c.json({ error: 'Billing is not configured' }, 503);
377
386
  }
378
387
 
379
- // Parse and validate request body
388
+ // Parse and validate request body. Organization access is enforced by the
389
+ // requireOrgRole middleware above (which reads the same cached body).
380
390
  let body: z.infer<typeof checkoutSchema>;
381
391
  try {
382
392
  const rawBody = await c.req.json();
@@ -392,26 +402,6 @@ billingRoutes.post('/checkout', requireAal2, async (c) => {
392
402
  return c.json({ error: 'Invalid JSON body' }, 400);
393
403
  }
394
404
 
395
- // Validate organization access if billing for organization
396
- if (body.type === 'organization') {
397
- if (!body.organizationId) {
398
- return c.json({ error: 'Organization ID is required for organization billing' }, 400);
399
- }
400
-
401
- // Check user is admin/owner of organization
402
- const supabase = c.get('supabase');
403
- const { data: membership } = await supabase
404
- .from('memberships')
405
- .select('role')
406
- .eq('user_id', user.id)
407
- .eq('organization_id', body.organizationId)
408
- .single();
409
-
410
- if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) {
411
- return c.json({ error: 'You must be an admin to manage organization billing' }, 403);
412
- }
413
- }
414
-
415
405
  try {
416
406
  const provider = getBillingProvider();
417
407
 
@@ -472,7 +462,7 @@ const setupSchema = z.object({
472
462
  * method collection differently (during checkout or via their portal).
473
463
  * For non-Stripe providers, this endpoint returns 501.
474
464
  */
475
- billingRoutes.post('/setup', requireAal2, async (c) => {
465
+ billingRoutes.post('/setup', requireAal2, requireOrgRole(ORG_BILLING_ROLES), async (c) => {
476
466
  const user = c.get('user');
477
467
 
478
468
  if (!user) {
@@ -492,6 +482,7 @@ billingRoutes.post('/setup', requireAal2, async (c) => {
492
482
  );
493
483
  }
494
484
 
485
+ // Organization access is enforced by requireOrgRole above.
495
486
  let body: z.infer<typeof setupSchema>;
496
487
  try {
497
488
  const rawBody = await c.req.json();
@@ -507,24 +498,6 @@ billingRoutes.post('/setup', requireAal2, async (c) => {
507
498
  return c.json({ error: 'Invalid JSON body' }, 400);
508
499
  }
509
500
 
510
- if (body.type === 'organization') {
511
- if (!body.organizationId) {
512
- return c.json({ error: 'Organization ID is required for organization billing' }, 400);
513
- }
514
-
515
- const supabase = c.get('supabase');
516
- const { data: membership } = await supabase
517
- .from('memberships')
518
- .select('role')
519
- .eq('user_id', user.id)
520
- .eq('organization_id', body.organizationId)
521
- .single();
522
-
523
- if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) {
524
- return c.json({ error: 'You must be an admin to manage organization billing' }, 403);
525
- }
526
- }
527
-
528
501
  try {
529
502
  // Import Stripe directly for setup mode (Stripe-specific feature)
530
503
  const { getStripe } = await import('../../lib/stripe');
@@ -569,7 +542,7 @@ billingRoutes.post('/setup', requireAal2, async (c) => {
569
542
  /**
570
543
  * Create a customer portal session via the active billing provider
571
544
  */
572
- billingRoutes.post('/portal', requireAal2, async (c) => {
545
+ billingRoutes.post('/portal', requireAal2, requireOrgRole(ORG_BILLING_ROLES), async (c) => {
573
546
  const user = c.get('user');
574
547
 
575
548
  if (!user) {
@@ -580,7 +553,8 @@ billingRoutes.post('/portal', requireAal2, async (c) => {
580
553
  return c.json({ error: 'Billing is not configured' }, 503);
581
554
  }
582
555
 
583
- // Parse and validate request body
556
+ // Parse and validate request body. Organization access is enforced by the
557
+ // requireOrgRole middleware above.
584
558
  let body: z.infer<typeof portalSchema>;
585
559
  try {
586
560
  const rawBody = await c.req.json();
@@ -596,25 +570,6 @@ billingRoutes.post('/portal', requireAal2, async (c) => {
596
570
  return c.json({ error: 'Invalid JSON body' }, 400);
597
571
  }
598
572
 
599
- // Validate organization access if billing for organization
600
- if (body.type === 'organization') {
601
- if (!body.organizationId) {
602
- return c.json({ error: 'Organization ID is required for organization billing' }, 400);
603
- }
604
-
605
- const supabase = c.get('supabase');
606
- const { data: membership } = await supabase
607
- .from('memberships')
608
- .select('role')
609
- .eq('user_id', user.id)
610
- .eq('organization_id', body.organizationId)
611
- .single();
612
-
613
- if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) {
614
- return c.json({ error: 'You must be an admin to manage organization billing' }, 403);
615
- }
616
- }
617
-
618
573
  try {
619
574
  // Find existing customer
620
575
  const query = adminDb.from('customers').select('stripe_customer_id');
@@ -669,6 +624,11 @@ const TIER_PRICE_IDS: Record<string, string | undefined> = {
669
624
  fullerene: env.FULLERENE_PRICE_ID,
670
625
  };
671
626
 
627
+ // SECURITY: this route is public (no auth) and calls provider.createCustomer on
628
+ // every request, so it's an abuse/cost vector. Apply a strict per-IP limiter on
629
+ // top of the app-wide 100/min, matching the contact/newsletter public routes.
630
+ billingRoutes.use('/license-checkout', createRateLimiter({ windowMs: 15 * 60 * 1000, max: 5 }));
631
+
672
632
  /**
673
633
  * Create a one-time payment checkout session for a license tier.
674
634
  * No authentication required — collects email for the provider.
@@ -936,7 +936,10 @@ v1Routes.get('/admin/users', async (c) => {
936
936
  // ADMIN IMPERSONATION (Super Admin only)
937
937
  // ============================================================================
938
938
 
939
- v1Routes.post('/admin/impersonate/:userId', async (c) => {
939
+ // SECURITY: impersonation mints a login link for another user — the highest-
940
+ // impact admin action here — so it is gated behind aal2 (step-up MFA) in
941
+ // addition to the super_admin check, when MFA is globally enabled.
942
+ v1Routes.post('/admin/impersonate/:userId', requireAal2, async (c) => {
940
943
  const user = c.get('user');
941
944
 
942
945
  if (!user) {
@@ -949,6 +952,10 @@ v1Routes.post('/admin/impersonate/:userId', async (c) => {
949
952
 
950
953
  const targetUserId = c.req.param('userId');
951
954
 
955
+ if (!targetUserId) {
956
+ return c.json({ error: 'User ID is required' }, 400);
957
+ }
958
+
952
959
  if (targetUserId === user.id) {
953
960
  return c.json({ error: 'Cannot impersonate yourself' }, 400);
954
961
  }
@@ -112,12 +112,14 @@ newsletterRoutes.get('/confirm', async (c) => {
112
112
  }
113
113
 
114
114
  try {
115
+ // NOTE: confirmation_token is intentionally NOT cleared here — it doubles as
116
+ // the per-subscriber unsubscribe secret (see the unsubscribe route below).
117
+ // Re-confirmation is already prevented by the `status = 'pending'` filter.
115
118
  const { data, error } = await adminDb
116
119
  .from('newsletter_subscribers')
117
120
  .update({
118
121
  status: 'active',
119
122
  subscribed_at: new Date().toISOString(),
120
- confirmation_token: null,
121
123
  })
122
124
  .eq('confirmation_token', token)
123
125
  .eq('status', 'pending')
@@ -147,30 +149,34 @@ newsletterRoutes.get('/unsubscribe', async (c) => {
147
149
  const email = c.req.query('email');
148
150
  const token = c.req.query('token');
149
151
 
150
- if (!email) {
151
- return c.json({ error: 'Missing email' }, 400);
152
+ // SECURITY: require the per-subscriber token (confirmation_token) so a caller
153
+ // can't unsubscribe an arbitrary address by guessing/knowing only the email.
154
+ // The token was matched against the wrong column (`id`) before, and was
155
+ // optional — either flaw let anyone unsubscribe anyone.
156
+ if (!email || !token) {
157
+ return c.json({ error: 'Missing email or unsubscribe token' }, 400);
152
158
  }
153
159
 
154
160
  try {
155
- const query = adminDb
161
+ const { data, error } = await adminDb
156
162
  .from('newsletter_subscribers')
157
163
  .update({
158
164
  status: 'unsubscribed',
159
165
  unsubscribed_at: new Date().toISOString(),
160
166
  })
161
- .eq('email', email);
162
-
163
- // If token provided, verify it matches (for one-click unsubscribe)
164
- if (token) {
165
- query.eq('id', token);
166
- }
167
-
168
- const { error } = await query;
167
+ .eq('email', email)
168
+ .eq('confirmation_token', token)
169
+ .select('id')
170
+ .maybeSingle();
169
171
 
170
172
  if (error) {
171
173
  return c.json({ error: sanitizeError(error, 'Failed to unsubscribe') }, 500);
172
174
  }
173
175
 
176
+ if (!data) {
177
+ return c.json({ error: 'Invalid unsubscribe link' }, 400);
178
+ }
179
+
174
180
  const baseUrl = env.SITE_URL || 'http://localhost:5173';
175
181
  return c.redirect(`${baseUrl}/?newsletter=unsubscribed`);
176
182
  } catch (error) {