vibecarbon 0.8.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.
- package/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/scripts/dev-init.js +6 -2
- package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
- package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
- package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
- package/carbon/src/client/components/AppSidebar.tsx +35 -28
- package/carbon/src/client/components/CTAFooter.tsx +1 -1
- package/carbon/src/client/components/ComparisonSection.tsx +1 -3
- package/carbon/src/client/components/ContentPanel.tsx +1 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
- package/carbon/src/client/components/FAQSection.tsx +1 -1
- package/carbon/src/client/components/Hero.tsx +13 -1
- package/carbon/src/client/components/Logo.tsx +66 -49
- package/carbon/src/client/components/Nav.tsx +1 -1
- package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
- package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
- package/carbon/src/client/components/PageHeader.tsx +1 -1
- package/carbon/src/client/components/PricingSection.tsx +2 -6
- package/carbon/src/client/components/TechStackSection.tsx +1 -3
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
- package/carbon/src/client/components/ui/button.tsx +1 -1
- package/carbon/src/client/components/ui/card.tsx +1 -1
- package/carbon/src/client/components/ui/dialog.tsx +1 -1
- package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
- package/carbon/src/client/components/ui/popover.tsx +1 -1
- package/carbon/src/client/components/ui/select.tsx +1 -1
- package/carbon/src/client/components/ui/sidebar.tsx +1 -1
- package/carbon/src/client/index.css +186 -104
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/lib/theme.ts +14 -14
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +33 -18
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Blog.tsx +1 -1
- package/carbon/src/client/pages/Changelog.tsx +1 -1
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/client/pages/Dashboard.tsx +33 -40
- package/carbon/src/client/pages/Docs.tsx +1 -1
- package/carbon/src/client/pages/Home.tsx +1 -1
- package/carbon/src/client/pages/Legal.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +78 -0
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +63 -20
- package/src/lib/deploy/image.js +19 -23
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +65 -14
- package/src/lib/deploy/orchestrator.js +176 -6
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +4 -2
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
- package/carbon/src/server/lib/request.ts +0 -34
- 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 {
|
|
5
|
-
import {
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
adminContactRoutes.use('*',
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
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
|
-
}) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
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) {
|