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.
Files changed (121) 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/scripts/dev-init.js +6 -2
  12. package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
  13. package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
  14. package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
  15. package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
  16. package/carbon/src/client/components/AppSidebar.tsx +35 -28
  17. package/carbon/src/client/components/CTAFooter.tsx +1 -1
  18. package/carbon/src/client/components/ComparisonSection.tsx +1 -3
  19. package/carbon/src/client/components/ContentPanel.tsx +1 -1
  20. package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
  21. package/carbon/src/client/components/FAQSection.tsx +1 -1
  22. package/carbon/src/client/components/Hero.tsx +13 -1
  23. package/carbon/src/client/components/Logo.tsx +66 -49
  24. package/carbon/src/client/components/Nav.tsx +1 -1
  25. package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
  26. package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
  27. package/carbon/src/client/components/PageHeader.tsx +1 -1
  28. package/carbon/src/client/components/PricingSection.tsx +2 -6
  29. package/carbon/src/client/components/TechStackSection.tsx +1 -3
  30. package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
  31. package/carbon/src/client/components/ui/button.tsx +1 -1
  32. package/carbon/src/client/components/ui/card.tsx +1 -1
  33. package/carbon/src/client/components/ui/dialog.tsx +1 -1
  34. package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
  35. package/carbon/src/client/components/ui/popover.tsx +1 -1
  36. package/carbon/src/client/components/ui/select.tsx +1 -1
  37. package/carbon/src/client/components/ui/sidebar.tsx +1 -1
  38. package/carbon/src/client/index.css +186 -104
  39. package/carbon/src/client/lib/supabase.ts +41 -0
  40. package/carbon/src/client/lib/theme.ts +14 -14
  41. package/carbon/src/client/locales/de.json +27 -12
  42. package/carbon/src/client/locales/en.json +33 -18
  43. package/carbon/src/client/locales/es.json +27 -12
  44. package/carbon/src/client/locales/fr.json +27 -12
  45. package/carbon/src/client/locales/pt.json +27 -12
  46. package/carbon/src/client/pages/Blog.tsx +1 -1
  47. package/carbon/src/client/pages/Changelog.tsx +1 -1
  48. package/carbon/src/client/pages/Checkout.tsx +1 -1
  49. package/carbon/src/client/pages/Dashboard.tsx +33 -40
  50. package/carbon/src/client/pages/Docs.tsx +1 -1
  51. package/carbon/src/client/pages/Home.tsx +1 -1
  52. package/carbon/src/client/pages/Legal.tsx +1 -1
  53. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  54. package/carbon/src/server/billing/providers/polar.ts +14 -0
  55. package/carbon/src/server/index.ts +12 -9
  56. package/carbon/src/server/lib/client-ip.ts +68 -0
  57. package/carbon/src/server/lib/env.ts +20 -6
  58. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  59. package/carbon/src/server/lib/supabase.ts +10 -4
  60. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  61. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  62. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  63. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  64. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  65. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  66. package/carbon/src/server/routes/v1/auth.ts +5 -2
  67. package/carbon/src/server/routes/v1/billing.ts +24 -64
  68. package/carbon/src/server/routes/v1/index.ts +8 -1
  69. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  70. package/carbon/src/server/routes/v1/theme.ts +36 -15
  71. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  72. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  73. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  74. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  75. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  76. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  77. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  78. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  79. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  80. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  81. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  82. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  83. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  84. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  85. package/carbon/volumes/db/wal-archive.sh +36 -0
  86. package/package.json +2 -2
  87. package/src/backup.js +0 -2
  88. package/src/cli.js +16 -0
  89. package/src/deploy.js +16 -5
  90. package/src/destroy.js +78 -0
  91. package/src/failover.js +118 -237
  92. package/src/lib/command.js +19 -1
  93. package/src/lib/deploy/compose/ha.js +180 -102
  94. package/src/lib/deploy/compose/index.js +63 -20
  95. package/src/lib/deploy/image.js +19 -23
  96. package/src/lib/deploy/k8s/ha/index.js +397 -150
  97. package/src/lib/deploy/k8s/k3s.js +65 -14
  98. package/src/lib/deploy/orchestrator.js +176 -6
  99. package/src/lib/deploy/remote-build.js +8 -1
  100. package/src/lib/deploy/replication.js +540 -0
  101. package/src/lib/deploy/state.js +62 -5
  102. package/src/lib/deploy/utils.js +30 -2
  103. package/src/lib/deploy/wireguard.js +146 -0
  104. package/src/lib/host-keys.js +120 -5
  105. package/src/lib/iac/index.js +57 -23
  106. package/src/lib/licensing/gate.js +59 -0
  107. package/src/lib/licensing/index.js +1 -1
  108. package/src/lib/licensing/tiers.js +6 -5
  109. package/src/lib/prod-confirm.js +65 -0
  110. package/src/lib/providers/hetzner-s3.js +85 -0
  111. package/src/lib/ssh.js +35 -35
  112. package/src/restore.js +102 -14
  113. package/src/scale.js +4 -2
  114. package/src/status.js +122 -0
  115. package/src/upgrade.js +0 -4
  116. package/carbon/ha/activate-standby.sh +0 -52
  117. package/carbon/ha/standby-init.sh +0 -74
  118. package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
  119. package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
  120. package/carbon/src/server/lib/request.ts +0 -34
  121. 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: 'rgba(255,255,255,0.5)' } }}
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="sparkFill" x1="0" y1="0" x2="0" y2="1">
60
- <stop offset="0%" stopColor="rgba(255,255,255,0.3)" />
61
- <stop offset="100%" stopColor="rgba(255,255,255,0.05)" />
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="rgba(255,255,255,0.6)"
69
+ stroke="var(--color-count)"
68
70
  strokeWidth={2}
69
- fill="url(#sparkFill)"
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-semibold uppercase tracking-wide text-white drop-shadow-sm">
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-white/20 backdrop-blur-sm">
113
- <Icon className="size-4 drop-shadow-sm" />
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-4xl font-extrabold tabular-nums drop-shadow-sm">{value}</p>
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 className="inline-flex items-center gap-1 text-xs font-medium text-white/90 drop-shadow-sm">
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 bg-gradient-to-r from-primary/5 to-transparent" />
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-xl">
227
+ <CardTitle className="font-display text-3xl tracking-tight">
229
228
  {greeting}, {firstName}!
230
229
  </CardTitle>
231
- <CardDescription className="mt-0.5">{user?.email}</CardDescription>
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">{formattedDate}</p>
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>
@@ -287,7 +287,7 @@ export function DocsPage() {
287
287
  )}
288
288
  </header>
289
289
 
290
- <article className="prose prose-invert max-w-none">
290
+ <article className="prose dark:prose-invert max-w-none">
291
291
  <page.Component components={mdxComponents} />
292
292
  </article>
293
293
 
@@ -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
- if (expectedHash !== providedHash) {
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 middleware
128
- app.use('/api/*', async (c, next) => {
129
- const contentLength = Number.parseInt(c.req.header('content-length') || '0', 10);
130
- const maxSize = 10 * 1024 * 1024; // 10MB
131
- if (contentLength > maxSize) {
132
- return c.json({ error: 'Request body too large' }, 413);
133
- }
134
- await next();
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: z.coerce.boolean().default(false),
31
- MICROSOFT_ENABLED: z.coerce.boolean().default(false),
32
- GITHUB_ENABLED: z.coerce.boolean().default(false),
33
- APPLE_ENABLED: z.coerce.boolean().default(false),
34
- DISCORD_ENABLED: z.coerce.boolean().default(false),
35
- MAGIC_LINK_ENABLED: z.coerce.boolean().default(true),
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
- const ip = getClientIP(c);
266
- const key = `${ip}:${c.req.path}`;
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({ ip, path: c.req.path, count: result.count }, 'Rate limit exceeded');
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
- // Uses SERVICE_ROLE_KEY to pass Kong's ACL gate for PostgREST, but RLS is still
20
- // enforced because PostgREST reads the user's role from the Authorization JWT.
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.SUPABASE_SERVICE_ROLE_KEY, {
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
+ }