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
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
UserPlus,
|
|
12
12
|
Users,
|
|
13
13
|
} from 'lucide-react';
|
|
14
|
+
import { useId } from 'react';
|
|
14
15
|
import { useTranslation } from 'react-i18next';
|
|
15
16
|
import { Area, AreaChart } from 'recharts';
|
|
16
17
|
import { useAuth } from '../components/auth/AuthProvider';
|
|
@@ -21,7 +22,7 @@ import { ChartContainer } from '../components/ui/chart';
|
|
|
21
22
|
import { Skeleton } from '../components/ui/skeleton';
|
|
22
23
|
import { adminServices, getServiceUrl } from '../lib/admin-services';
|
|
23
24
|
import { getAuthHeaders } from '../lib/api';
|
|
24
|
-
import { getUserInitials } from '../lib/utils';
|
|
25
|
+
import { cn, getUserInitials } from '../lib/utils';
|
|
25
26
|
|
|
26
27
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'http://localhost:8000';
|
|
27
28
|
|
|
@@ -49,24 +50,25 @@ function getGreetingKey() {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function MiniSparkline({ data }: { data: Array<{ week: string; count: number }> }) {
|
|
53
|
+
const gradientId = useId();
|
|
52
54
|
return (
|
|
53
55
|
<ChartContainer
|
|
54
|
-
config={{ count: { label: 'Signups', color: '
|
|
55
|
-
className="h-16 w-full aspect-auto"
|
|
56
|
+
config={{ count: { label: 'Signups', color: 'var(--chart-1)' } }}
|
|
57
|
+
className="h-16 w-full aspect-auto [&_.recharts-area-curve]:[filter:drop-shadow(0_0_5px_var(--glow-primary))]"
|
|
56
58
|
>
|
|
57
59
|
<AreaChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
|
|
58
60
|
<defs>
|
|
59
|
-
<linearGradient id=
|
|
60
|
-
<stop offset="0%" stopColor="
|
|
61
|
-
<stop offset="100%" stopColor="
|
|
61
|
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
62
|
+
<stop offset="0%" stopColor="var(--color-count)" stopOpacity={0.25} />
|
|
63
|
+
<stop offset="100%" stopColor="var(--color-count)" stopOpacity={0.03} />
|
|
62
64
|
</linearGradient>
|
|
63
65
|
</defs>
|
|
64
66
|
<Area
|
|
65
67
|
dataKey="count"
|
|
66
68
|
type="monotone"
|
|
67
|
-
stroke="
|
|
69
|
+
stroke="var(--color-count)"
|
|
68
70
|
strokeWidth={2}
|
|
69
|
-
fill=
|
|
71
|
+
fill={`url(#${gradientId})`}
|
|
70
72
|
/>
|
|
71
73
|
</AreaChart>
|
|
72
74
|
</ChartContainer>
|
|
@@ -79,8 +81,6 @@ interface KpiCardProps {
|
|
|
79
81
|
change: string;
|
|
80
82
|
positive: boolean;
|
|
81
83
|
icon: React.ComponentType<{ className?: string }>;
|
|
82
|
-
accentColor: string;
|
|
83
|
-
accentVar: string;
|
|
84
84
|
sparklineData?: Array<{ week: string; count: number }>;
|
|
85
85
|
loading: boolean;
|
|
86
86
|
}
|
|
@@ -91,26 +91,18 @@ function KpiCard({
|
|
|
91
91
|
change,
|
|
92
92
|
positive,
|
|
93
93
|
icon: Icon,
|
|
94
|
-
accentColor: _accentColor,
|
|
95
|
-
accentVar,
|
|
96
94
|
sparklineData,
|
|
97
95
|
loading,
|
|
98
96
|
}: KpiCardProps) {
|
|
99
97
|
return (
|
|
100
|
-
<Card
|
|
101
|
-
className="relative overflow-hidden ring-0 text-white backdrop-blur-md"
|
|
102
|
-
style={{
|
|
103
|
-
border: '2px solid transparent',
|
|
104
|
-
background: `linear-gradient(315deg, color-mix(in oklab, ${accentVar} 70%, transparent), color-mix(in oklab, ${accentVar} 40%, transparent)) padding-box, linear-gradient(oklch(0.18 0.02 260),oklch(0.18 0.02 260)) padding-box, linear-gradient(315deg, white, ${accentVar}) border-box`,
|
|
105
|
-
}}
|
|
106
|
-
>
|
|
98
|
+
<Card className="glass relative overflow-hidden ring-0">
|
|
107
99
|
<CardHeader className="relative z-10 pb-1">
|
|
108
100
|
<div className="flex items-center justify-between">
|
|
109
|
-
<CardDescription className="text-sm font-
|
|
101
|
+
<CardDescription className="text-sm font-medium uppercase tracking-wide">
|
|
110
102
|
{label}
|
|
111
103
|
</CardDescription>
|
|
112
|
-
<div className="flex size-8 items-center justify-center rounded-md bg-
|
|
113
|
-
<Icon className="size-4
|
|
104
|
+
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
105
|
+
<Icon className="size-4" />
|
|
114
106
|
</div>
|
|
115
107
|
</div>
|
|
116
108
|
</CardHeader>
|
|
@@ -118,13 +110,20 @@ function KpiCard({
|
|
|
118
110
|
{loading ? (
|
|
119
111
|
<Skeleton className="h-9 w-24 rounded-md" />
|
|
120
112
|
) : (
|
|
121
|
-
<p className="text-
|
|
113
|
+
<p className="font-mono text-5xl font-bold tabular-nums tracking-tight text-card-foreground">
|
|
114
|
+
{value}
|
|
115
|
+
</p>
|
|
122
116
|
)}
|
|
123
117
|
<div className="mt-1.5">
|
|
124
118
|
{loading ? (
|
|
125
119
|
<Skeleton className="h-5 w-32 rounded-md" />
|
|
126
120
|
) : (
|
|
127
|
-
<span
|
|
121
|
+
<span
|
|
122
|
+
className={cn(
|
|
123
|
+
'inline-flex items-center gap-1 text-xs font-medium',
|
|
124
|
+
positive ? 'text-success' : 'text-destructive'
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
128
127
|
{positive ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
|
|
129
128
|
{change}
|
|
130
129
|
</span>
|
|
@@ -214,25 +213,27 @@ export default function Dashboard() {
|
|
|
214
213
|
<>
|
|
215
214
|
<PageHeader title={t('dashboard.title')} />
|
|
216
215
|
|
|
217
|
-
<ContentPanel variant="full">
|
|
218
|
-
{/* Welcome banner */}
|
|
219
|
-
<Card className="relative overflow-hidden">
|
|
220
|
-
<div className="pointer-events-none absolute inset-0
|
|
216
|
+
<ContentPanel variant="full" className="space-y-12">
|
|
217
|
+
{/* Welcome banner — the view's hero panel */}
|
|
218
|
+
<Card className="glass relative overflow-hidden ring-0">
|
|
219
|
+
<div className="pointer-events-none absolute inset-0 [background:radial-gradient(90%_140%_at_0%_0%,var(--glow-primary),transparent_55%)]" />
|
|
221
220
|
<CardHeader className="relative">
|
|
222
221
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
223
222
|
<div className="flex items-center gap-4">
|
|
224
|
-
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-semibold text-primary">
|
|
223
|
+
<div className="glow-teal-subtle flex size-14 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-semibold text-primary ring-1 ring-primary/30">
|
|
225
224
|
{initials}
|
|
226
225
|
</div>
|
|
227
226
|
<div>
|
|
228
|
-
<CardTitle className="text-
|
|
227
|
+
<CardTitle className="font-display text-3xl tracking-tight">
|
|
229
228
|
{greeting}, {firstName}!
|
|
230
229
|
</CardTitle>
|
|
231
|
-
<CardDescription className="mt-
|
|
230
|
+
<CardDescription className="mt-1">{user?.email}</CardDescription>
|
|
232
231
|
</div>
|
|
233
232
|
</div>
|
|
234
233
|
<div className="flex items-center gap-3 sm:flex-col sm:items-end">
|
|
235
|
-
<p className="text-xs text-muted-foreground">
|
|
234
|
+
<p className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground">
|
|
235
|
+
{formattedDate}
|
|
236
|
+
</p>
|
|
236
237
|
</div>
|
|
237
238
|
</div>
|
|
238
239
|
</CardHeader>
|
|
@@ -250,8 +251,6 @@ export default function Dashboard() {
|
|
|
250
251
|
}
|
|
251
252
|
positive={stats ? stats.users.newThisMonth >= stats.users.newLastMonth : true}
|
|
252
253
|
icon={Users}
|
|
253
|
-
accentColor="bg-primary/10 text-primary"
|
|
254
|
-
accentVar="var(--primary)"
|
|
255
254
|
sparklineData={stats?.users.weeklySignups}
|
|
256
255
|
loading={!stats}
|
|
257
256
|
/>
|
|
@@ -267,8 +266,6 @@ export default function Dashboard() {
|
|
|
267
266
|
}
|
|
268
267
|
positive={stats ? stats.users.newThisMonth >= stats.users.newLastMonth : true}
|
|
269
268
|
icon={UserPlus}
|
|
270
|
-
accentColor="bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
|
271
|
-
accentVar="#3b82f6"
|
|
272
269
|
sparklineData={stats?.users.weeklySignups}
|
|
273
270
|
loading={!stats}
|
|
274
271
|
/>
|
|
@@ -284,8 +281,6 @@ export default function Dashboard() {
|
|
|
284
281
|
}
|
|
285
282
|
positive={stats ? stats.users.activeThisMonth >= stats.users.activeLastMonth : true}
|
|
286
283
|
icon={Activity}
|
|
287
|
-
accentColor="bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
|
288
|
-
accentVar="var(--warning)"
|
|
289
284
|
loading={!stats}
|
|
290
285
|
/>
|
|
291
286
|
<KpiCard
|
|
@@ -296,8 +291,6 @@ export default function Dashboard() {
|
|
|
296
291
|
}
|
|
297
292
|
positive={true}
|
|
298
293
|
icon={Globe}
|
|
299
|
-
accentColor="bg-destructive/10 text-destructive"
|
|
300
|
-
accentVar="var(--destructive)"
|
|
301
294
|
loading={!stats}
|
|
302
295
|
/>
|
|
303
296
|
</div>
|
|
@@ -63,7 +63,7 @@ function VibecarbonCTAFooter() {
|
|
|
63
63
|
}
|
|
64
64
|
footerLinks={
|
|
65
65
|
<>
|
|
66
|
-
<div className="flex items-center gap-6">
|
|
66
|
+
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
|
67
67
|
<a
|
|
68
68
|
href="https://github.com/hyperformant/vibecarbon/"
|
|
69
69
|
target="_blank"
|
|
@@ -65,7 +65,7 @@ export default function LegalRoute() {
|
|
|
65
65
|
<p className="text-lg text-muted-foreground">{page.description}</p>
|
|
66
66
|
</header>
|
|
67
67
|
|
|
68
|
-
<article className="prose prose-invert max-w-none">
|
|
68
|
+
<article className="prose dark:prose-invert max-w-none">
|
|
69
69
|
<page.Component />
|
|
70
70
|
</article>
|
|
71
71
|
</div>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Paddle API docs: https://developer.paddle.com/api-reference/overview
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { createHmac } from 'node:crypto';
|
|
10
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
11
11
|
import { env } from '../../lib/env';
|
|
12
12
|
import { logger } from '../../lib/logger';
|
|
13
13
|
import type { BillingProvider, CheckoutResult, PortalResult, SubscriptionInfo } from '../provider';
|
|
@@ -224,9 +224,28 @@ export class PaddleProvider implements BillingProvider {
|
|
|
224
224
|
.update(payload)
|
|
225
225
|
.digest('hex');
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
// SECURITY: constant-time comparison to avoid leaking the expected HMAC via
|
|
228
|
+
// response-timing. `!==` short-circuits on the first differing byte, which
|
|
229
|
+
// is a classic signature-forgery oracle. Encode as bytes and compare with
|
|
230
|
+
// timingSafeEqual (guarding the length first, since it throws on mismatch).
|
|
231
|
+
const expectedBuf = Buffer.from(expectedHash, 'hex');
|
|
232
|
+
const providedBuf = Buffer.from(providedHash, 'hex');
|
|
233
|
+
if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) {
|
|
228
234
|
throw new Error('Invalid Paddle webhook signature');
|
|
229
235
|
}
|
|
236
|
+
|
|
237
|
+
// SECURITY: reject stale events to blunt replay attacks. A captured, validly
|
|
238
|
+
// signed payload can otherwise be resent forever. Mirror Stripe/Polar's
|
|
239
|
+
// 5-minute tolerance. Paddle's `ts` is a Unix timestamp in seconds.
|
|
240
|
+
const timestampSec = Number.parseInt(timestamp, 10);
|
|
241
|
+
if (!Number.isFinite(timestampSec)) {
|
|
242
|
+
throw new Error('Invalid Paddle webhook timestamp');
|
|
243
|
+
}
|
|
244
|
+
const ageSec = Math.abs(Date.now() / 1000 - timestampSec);
|
|
245
|
+
const TOLERANCE_SEC = 5 * 60;
|
|
246
|
+
if (ageSec > TOLERANCE_SEC) {
|
|
247
|
+
throw new Error('Paddle webhook timestamp outside tolerance');
|
|
248
|
+
}
|
|
230
249
|
}
|
|
231
250
|
|
|
232
251
|
/** Map Paddle event types to normalized billing event names. */
|
|
@@ -244,6 +244,20 @@ export class PolarProvider implements BillingProvider {
|
|
|
244
244
|
if (!matched) {
|
|
245
245
|
throw new Error('Invalid Polar webhook signature');
|
|
246
246
|
}
|
|
247
|
+
|
|
248
|
+
// SECURITY: reject stale events to blunt replay attacks. A captured, validly
|
|
249
|
+
// signed payload can otherwise be resent forever to re-assert subscription
|
|
250
|
+
// state. Standard Webhooks timestamps are Unix seconds; use the same
|
|
251
|
+
// 5-minute tolerance as the Paddle/Stripe verifiers.
|
|
252
|
+
const timestampSec = Number.parseInt(timestamp, 10);
|
|
253
|
+
if (!Number.isFinite(timestampSec)) {
|
|
254
|
+
throw new Error('Invalid Polar webhook timestamp');
|
|
255
|
+
}
|
|
256
|
+
const ageSec = Math.abs(Date.now() / 1000 - timestampSec);
|
|
257
|
+
const TOLERANCE_SEC = 5 * 60;
|
|
258
|
+
if (ageSec > TOLERANCE_SEC) {
|
|
259
|
+
throw new Error('Polar webhook timestamp outside tolerance');
|
|
260
|
+
}
|
|
247
261
|
}
|
|
248
262
|
|
|
249
263
|
/** Map Polar event types to normalized billing event names. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { serve } from '@hono/node-server';
|
|
2
2
|
import { serveStatic } from '@hono/node-server/serve-static';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
4
5
|
import { cors } from 'hono/cors';
|
|
5
6
|
import { logger as honoLogger } from 'hono/logger';
|
|
6
7
|
import { secureHeaders } from 'hono/secure-headers';
|
|
@@ -124,15 +125,17 @@ app.use(
|
|
|
124
125
|
})
|
|
125
126
|
);
|
|
126
127
|
|
|
127
|
-
// Request body size limit
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
})
|
|
128
|
+
// Request body size limit. bodyLimit enforces the cap while READING the stream,
|
|
129
|
+
// not just by trusting the Content-Length header (which a client can under-state
|
|
130
|
+
// or omit with chunked transfer-encoding to smuggle an oversized body past a
|
|
131
|
+
// header-only check).
|
|
132
|
+
app.use(
|
|
133
|
+
'/api/*',
|
|
134
|
+
bodyLimit({
|
|
135
|
+
maxSize: 10 * 1024 * 1024, // 10MB
|
|
136
|
+
onError: (c) => c.json({ error: 'Request body too large' }, 413),
|
|
137
|
+
})
|
|
138
|
+
);
|
|
136
139
|
|
|
137
140
|
// Supabase session middleware - inject user and client into context
|
|
138
141
|
// Skip for health endpoint (no auth needed, avoids unnecessary getUser() call)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { env } from './env';
|
|
2
|
+
import { logger } from './logger';
|
|
3
|
+
|
|
4
|
+
// Minimal shape so both Hono's full `Context` and lightweight test doubles work.
|
|
5
|
+
type HasHeader = { req: { header: (name: string) => string | undefined } };
|
|
6
|
+
|
|
7
|
+
const ipv4Regex = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
|
8
|
+
const ipv6Regex = /^[a-fA-F0-9:]+$/;
|
|
9
|
+
|
|
10
|
+
export function isValidIp(ip: string): boolean {
|
|
11
|
+
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Log the "no trusted IP" warning at most once per process to avoid log spam.
|
|
15
|
+
let warnedAboutMissingIP = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the client IP from a trusted reverse proxy.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY: `X-Forwarded-For` is an ordered list where each proxy APPENDS the
|
|
21
|
+
* address of the host it received the connection from. With N trusted proxy
|
|
22
|
+
* hops in front of this server (`TRUSTED_PROXY_HOPS`, default 1 = Traefik), the
|
|
23
|
+
* real client IP is the Nth entry counted from the RIGHT. Every entry to the
|
|
24
|
+
* left of that is attacker-supplied and MUST be ignored — trusting the leftmost
|
|
25
|
+
* value (as the previous implementations did) lets a client spoof an arbitrary
|
|
26
|
+
* IP and mint fresh rate-limit / login-lockout buckets at will.
|
|
27
|
+
*
|
|
28
|
+
* We deliberately do NOT fall back to `x-real-ip` / `cf-connecting-ip`: Traefik
|
|
29
|
+
* does not set those, so an attacker could forge them. When a trusted IP can't
|
|
30
|
+
* be derived we return a shared sentinel ('unknown') so those requests share a
|
|
31
|
+
* single, more-restrictive bucket (fail closed).
|
|
32
|
+
*/
|
|
33
|
+
export function getClientIp(c: HasHeader, hops: number = env.TRUSTED_PROXY_HOPS): string {
|
|
34
|
+
const xff = c.req.header('x-forwarded-for');
|
|
35
|
+
|
|
36
|
+
if (xff && hops > 0) {
|
|
37
|
+
const ips = xff
|
|
38
|
+
.split(',')
|
|
39
|
+
.map((s) => s.trim())
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
|
|
42
|
+
// Need at least `hops` entries for the depth-from-right index to land on a
|
|
43
|
+
// proxy-written value rather than an attacker-supplied one.
|
|
44
|
+
if (ips.length >= hops) {
|
|
45
|
+
const candidate = ips[ips.length - hops];
|
|
46
|
+
if (isValidIp(candidate)) {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// In development there is typically no reverse proxy in front of the server.
|
|
53
|
+
if (env.NODE_ENV === 'development') {
|
|
54
|
+
return 'localhost';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!warnedAboutMissingIP) {
|
|
58
|
+
warnedAboutMissingIP = true;
|
|
59
|
+
logger.warn(
|
|
60
|
+
'Could not determine a trusted client IP from X-Forwarded-For. Ensure the reverse ' +
|
|
61
|
+
'proxy (Traefik) sets X-Forwarded-For and that TRUSTED_PROXY_HOPS matches the number ' +
|
|
62
|
+
'of trusted proxies in front of this server. Requests without a trusted IP share a ' +
|
|
63
|
+
'single rate-limit bucket.'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return 'unknown';
|
|
68
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Parse a boolean env var strictly: only the literal string "true" is true.
|
|
5
|
+
* `z.coerce.boolean()` is NOT usable here — it treats any non-empty string
|
|
6
|
+
* (including "false") as `true`. Empty strings are stripped to `undefined`
|
|
7
|
+
* before validation (see cleanedEnv below), so they fall back to `def`.
|
|
8
|
+
*/
|
|
9
|
+
const envBool = (def: boolean) =>
|
|
10
|
+
z.preprocess((v) => (v === undefined ? def : v === 'true'), z.boolean());
|
|
11
|
+
|
|
3
12
|
const envSchema = z.object({
|
|
4
13
|
// Supabase (required)
|
|
5
14
|
SUPABASE_URL: z.string().url(),
|
|
@@ -27,12 +36,12 @@ const envSchema = z.object({
|
|
|
27
36
|
DEV_LOKI_PORT: z.coerce.number().optional(),
|
|
28
37
|
|
|
29
38
|
// OAuth (optional - configure in Supabase dashboard)
|
|
30
|
-
GOOGLE_ENABLED:
|
|
31
|
-
MICROSOFT_ENABLED:
|
|
32
|
-
GITHUB_ENABLED:
|
|
33
|
-
APPLE_ENABLED:
|
|
34
|
-
DISCORD_ENABLED:
|
|
35
|
-
MAGIC_LINK_ENABLED:
|
|
39
|
+
GOOGLE_ENABLED: envBool(false),
|
|
40
|
+
MICROSOFT_ENABLED: envBool(false),
|
|
41
|
+
GITHUB_ENABLED: envBool(false),
|
|
42
|
+
APPLE_ENABLED: envBool(false),
|
|
43
|
+
DISCORD_ENABLED: envBool(false),
|
|
44
|
+
MAGIC_LINK_ENABLED: envBool(true),
|
|
36
45
|
|
|
37
46
|
// SMTP (shared with Supabase Auth)
|
|
38
47
|
SMTP_HOST: z.string().optional(),
|
|
@@ -72,6 +81,11 @@ const envSchema = z.object({
|
|
|
72
81
|
// Redis (optional - for distributed rate limiting)
|
|
73
82
|
// Format: redis://[:password@]host:port or redis://[:password@]host:port/db
|
|
74
83
|
REDIS_URL: z.string().url().optional(),
|
|
84
|
+
|
|
85
|
+
// Number of trusted reverse-proxy hops in front of this server. Used to pick
|
|
86
|
+
// the real client IP out of X-Forwarded-For (see lib/client-ip.ts). The
|
|
87
|
+
// default single hop matches the standard Traefik deployment.
|
|
88
|
+
TRUSTED_PROXY_HOPS: z.coerce.number().int().min(0).default(1),
|
|
75
89
|
});
|
|
76
90
|
|
|
77
91
|
// Apply port offset to localhost URLs in development
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context, Next } from 'hono';
|
|
2
2
|
import Redis from 'ioredis';
|
|
3
|
+
import { getClientIp } from './client-ip';
|
|
3
4
|
import { env } from './env';
|
|
4
5
|
import { logger } from './logger';
|
|
5
6
|
|
|
@@ -194,76 +195,21 @@ function getStore(): HybridStore {
|
|
|
194
195
|
return store;
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
// =============================================================================
|
|
198
|
-
// IP EXTRACTION
|
|
199
|
-
// =============================================================================
|
|
200
|
-
|
|
201
|
-
// Track if we've warned about missing IP headers (log once per process)
|
|
202
|
-
let warnedAboutMissingIP = false;
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Extract client IP from request headers
|
|
206
|
-
*
|
|
207
|
-
* Security note: Only trust x-forwarded-for when behind a trusted reverse proxy (Traefik).
|
|
208
|
-
* The leftmost IP in x-forwarded-for is the original client IP.
|
|
209
|
-
*
|
|
210
|
-
* Fallback behavior:
|
|
211
|
-
* - In development: Falls back to 'localhost' (expected when not behind proxy)
|
|
212
|
-
* - In production: Falls back to 'unknown' but logs a warning (indicates misconfiguration)
|
|
213
|
-
*
|
|
214
|
-
* When IP is 'unknown', all such requests share a rate limit bucket - this is intentional
|
|
215
|
-
* as it provides more restrictive rate limiting for requests that bypass proper headers.
|
|
216
|
-
*/
|
|
217
|
-
function getClientIP(c: Context): string {
|
|
218
|
-
// x-forwarded-for format: "client, proxy1, proxy2"
|
|
219
|
-
const xff = c.req.header('x-forwarded-for');
|
|
220
|
-
if (xff) {
|
|
221
|
-
// Take leftmost (original client) IP, trim whitespace
|
|
222
|
-
const clientIP = xff.split(',')[0].trim();
|
|
223
|
-
// Basic validation - must look like an IP (IPv4 or IPv6)
|
|
224
|
-
if (/^[\d.:a-fA-F]+$/.test(clientIP)) {
|
|
225
|
-
return clientIP;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Fallback to x-real-ip (set by some proxies like nginx)
|
|
230
|
-
const realIP = c.req.header('x-real-ip');
|
|
231
|
-
if (realIP && /^[\d.:a-fA-F]+$/.test(realIP)) {
|
|
232
|
-
return realIP;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Fallback to cf-connecting-ip (Cloudflare)
|
|
236
|
-
const cfIP = c.req.header('cf-connecting-ip');
|
|
237
|
-
if (cfIP && /^[\d.:a-fA-F]+$/.test(cfIP)) {
|
|
238
|
-
return cfIP;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// In development, localhost is expected
|
|
242
|
-
if (env.NODE_ENV === 'development') {
|
|
243
|
-
return 'localhost';
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// In production, warn about missing IP headers (indicates proxy misconfiguration)
|
|
247
|
-
if (!warnedAboutMissingIP) {
|
|
248
|
-
warnedAboutMissingIP = true;
|
|
249
|
-
logger.warn(
|
|
250
|
-
'Could not determine client IP from request headers. ' +
|
|
251
|
-
'Ensure reverse proxy (Traefik) is configured to set X-Forwarded-For or X-Real-IP headers. ' +
|
|
252
|
-
'Requests without identifiable IPs will share a rate limit bucket.'
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return 'unknown';
|
|
257
|
-
}
|
|
258
|
-
|
|
259
198
|
// =============================================================================
|
|
260
199
|
// RATE LIMITER MIDDLEWARE
|
|
261
200
|
// =============================================================================
|
|
262
201
|
|
|
263
202
|
export function createRateLimiter(options: { windowMs: number; max: number }) {
|
|
264
203
|
return async (c: Context, next: Next) => {
|
|
265
|
-
|
|
266
|
-
|
|
204
|
+
// SECURITY: prefer a per-user bucket for authenticated requests so a single
|
|
205
|
+
// account behind a shared/NAT'd IP can't exhaust everyone else's budget (and
|
|
206
|
+
// can't dodge its own limit by rotating IPs). Falls back to the trusted
|
|
207
|
+
// client IP — resolved via getClientIp, which ignores attacker-supplied
|
|
208
|
+
// X-Forwarded-For entries. Note: the app-wide limiters in index.ts run
|
|
209
|
+
// before the auth middleware sets `user`, so those key by IP.
|
|
210
|
+
const user = c.get('user');
|
|
211
|
+
const identifier = user?.id ? `user:${user.id}` : `ip:${getClientIp(c)}`;
|
|
212
|
+
const key = `${identifier}:${c.req.path}`;
|
|
267
213
|
const now = Date.now();
|
|
268
214
|
|
|
269
215
|
const result = await getStore().increment(key, options.windowMs, options.max);
|
|
@@ -276,7 +222,7 @@ export function createRateLimiter(options: { windowMs: number; max: number }) {
|
|
|
276
222
|
|
|
277
223
|
if (!result.allowed) {
|
|
278
224
|
// Log rate limit violations for security monitoring
|
|
279
|
-
logger.warn({
|
|
225
|
+
logger.warn({ identifier, path: c.req.path, count: result.count }, 'Rate limit exceeded');
|
|
280
226
|
return c.json(
|
|
281
227
|
{ error: 'Too many requests', retryAfter: Math.ceil((result.resetTime - now) / 1000) },
|
|
282
228
|
429
|
|
@@ -15,11 +15,17 @@ export const supabaseAdmin: SupabaseClient<Database> = createClient<Database>(
|
|
|
15
15
|
}
|
|
16
16
|
);
|
|
17
17
|
|
|
18
|
-
// Create a client for a specific user's context (respects RLS)
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// Create a client for a specific user's context (respects RLS).
|
|
19
|
+
//
|
|
20
|
+
// SECURITY: uses the ANON key (not the service-role key) as the apikey, with the
|
|
21
|
+
// user's JWT layered on via the Authorization header. PostgREST derives the
|
|
22
|
+
// Postgres role from the JWT, so RLS is enforced for the authenticated user.
|
|
23
|
+
// The critical property is fail-safe degradation: if the Authorization header is
|
|
24
|
+
// ever dropped (bug, proxy stripping, refactor), the client falls back to the
|
|
25
|
+
// anon role under RLS rather than to service_role BYPASSRLS. The dedicated
|
|
26
|
+
// service-role clients (supabaseAdmin / createAuthClient) remain for admin ops.
|
|
21
27
|
export function createSupabaseClient(accessToken: string): SupabaseClient<Database> {
|
|
22
|
-
return createClient<Database>(env.SUPABASE_URL, env.
|
|
28
|
+
return createClient<Database>(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
|
23
29
|
global: {
|
|
24
30
|
headers: {
|
|
25
31
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono';
|
|
2
|
+
import type { HonoVariables } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hono middleware factory: gate an organization-scoped billing route behind
|
|
6
|
+
* org membership.
|
|
7
|
+
*
|
|
8
|
+
* The route indicates its target via `type` ('user' | 'organization') and,
|
|
9
|
+
* when organization-scoped, `organizationId`. Reads are GET requests that carry
|
|
10
|
+
* these in the query string; writes are POSTs that carry them in the JSON body
|
|
11
|
+
* (Hono caches the parsed body, so the handler can re-parse it safely).
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: the membership lookup uses the RLS-enforced per-user Supabase client
|
|
14
|
+
* (`c.get('supabase')`), never the service-role `adminDb`. A caller can only see
|
|
15
|
+
* memberships they belong to, so this can neither be bypassed nor used to probe
|
|
16
|
+
* arbitrary organizations. When `type !== 'organization'` the guard is inert.
|
|
17
|
+
*
|
|
18
|
+
* Applying this as route middleware (rather than an inline check per handler)
|
|
19
|
+
* ensures no org-scoped billing route can silently skip the membership check.
|
|
20
|
+
*/
|
|
21
|
+
export function requireOrgRole(roles: string[]) {
|
|
22
|
+
return async (
|
|
23
|
+
c: Context<{ Variables: HonoVariables }>,
|
|
24
|
+
next: Next
|
|
25
|
+
): Promise<Response | undefined> => {
|
|
26
|
+
const user = c.get('user');
|
|
27
|
+
if (!user) {
|
|
28
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let type: string | undefined;
|
|
32
|
+
let organizationId: string | undefined;
|
|
33
|
+
|
|
34
|
+
if (c.req.method === 'GET') {
|
|
35
|
+
type = c.req.query('type') ?? 'user';
|
|
36
|
+
organizationId = c.req.query('organizationId');
|
|
37
|
+
} else {
|
|
38
|
+
try {
|
|
39
|
+
const body = (await c.req.json()) as {
|
|
40
|
+
type?: string;
|
|
41
|
+
organizationId?: string;
|
|
42
|
+
};
|
|
43
|
+
type = body?.type;
|
|
44
|
+
organizationId = body?.organizationId;
|
|
45
|
+
} catch {
|
|
46
|
+
// Malformed JSON — let the handler's own validation return the 400 so
|
|
47
|
+
// error handling stays in one place. No data is accessed here.
|
|
48
|
+
await next();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (type !== 'organization') {
|
|
54
|
+
await next();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!organizationId) {
|
|
59
|
+
return c.json({ error: 'Organization ID is required for organization billing' }, 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const supabase = c.get('supabase');
|
|
63
|
+
const { data: membership } = await supabase
|
|
64
|
+
.from('memberships')
|
|
65
|
+
.select('role')
|
|
66
|
+
.eq('user_id', user.id)
|
|
67
|
+
.eq('organization_id', organizationId)
|
|
68
|
+
.single();
|
|
69
|
+
|
|
70
|
+
if (!membership || !roles.includes(membership.role)) {
|
|
71
|
+
return c.json({ error: 'You must be an admin to manage organization billing' }, 403);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await next();
|
|
75
|
+
};
|
|
76
|
+
}
|