vibecarbon 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +78 -0
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +63 -20
  60. package/src/lib/deploy/image.js +19 -23
  61. package/src/lib/deploy/k8s/ha/index.js +397 -150
  62. package/src/lib/deploy/k8s/k3s.js +65 -14
  63. package/src/lib/deploy/orchestrator.js +176 -6
  64. package/src/lib/deploy/remote-build.js +8 -1
  65. package/src/lib/deploy/replication.js +540 -0
  66. package/src/lib/deploy/state.js +62 -5
  67. package/src/lib/deploy/utils.js +30 -2
  68. package/src/lib/deploy/wireguard.js +146 -0
  69. package/src/lib/host-keys.js +120 -5
  70. package/src/lib/iac/index.js +57 -23
  71. package/src/lib/licensing/gate.js +59 -0
  72. package/src/lib/licensing/index.js +1 -1
  73. package/src/lib/licensing/tiers.js +6 -5
  74. package/src/lib/prod-confirm.js +65 -0
  75. package/src/lib/providers/hetzner-s3.js +85 -0
  76. package/src/lib/ssh.js +35 -35
  77. package/src/restore.js +102 -14
  78. package/src/scale.js +4 -2
  79. package/src/status.js +122 -0
  80. package/src/upgrade.js +0 -4
  81. package/carbon/ha/activate-standby.sh +0 -52
  82. package/carbon/ha/standby-init.sh +0 -74
  83. package/carbon/src/server/lib/request.ts +0 -34
  84. package/src/lib/pod-backups.js +0 -53
@@ -293,7 +293,7 @@
293
293
  },
294
294
  "deploy": {
295
295
  "label": "Déployer",
296
- "description": "Déploie en production avec mise à l'échelle automatique, haute disponibilité, monitoring, CI/CD et plus encore."
296
+ "description": "Déploie en production avec mise à l'échelle, haute disponibilité, monitoring, CI/CD et plus encore."
297
297
  }
298
298
  },
299
299
  "techStack": {
@@ -352,7 +352,7 @@
352
352
  "traffic": {
353
353
  "risk": "Ne supporte pas le trafic",
354
354
  "protection": "Mise à l'échelle automatique",
355
- "how": "Kubernetes autoscaling sur les pods et les nœuds VPS"
355
+ "how": "Autoscaling des pods Kubernetes plus mise à l'échelle des workers en une commande"
356
356
  },
357
357
  "redundancy": {
358
358
  "risk": "Zéro redondance",
@@ -401,25 +401,40 @@
401
401
  "compose": {
402
402
  "name": "Docker Compose",
403
403
  "tagline": "Un VPS, toute votre stack incluse.",
404
- "features": ["1 serveur", "Région unique"],
404
+ "features": [
405
+ "1 serveur",
406
+ "Région unique"
407
+ ],
405
408
  "bestFor": "MVP et outils internes"
406
409
  },
407
410
  "composeHa": {
408
411
  "name": "Compose HA",
409
- "tagline": "Deux serveurs, bascule automatique.",
410
- "features": ["2 serveurs", "Réplication en flux", "Bascule automatique"],
412
+ "tagline": "Deux régions, bascule en une commande.",
413
+ "features": [
414
+ "2 serveurs",
415
+ "Réplication en flux",
416
+ "Bascule en une commande"
417
+ ],
411
418
  "bestFor": "SaaS en production sans Kubernetes"
412
419
  },
413
420
  "k8s": {
414
421
  "name": "Kubernetes",
415
- "tagline": "Cluster k3s à mise à l'échelle automatique.",
416
- "features": ["Région unique", "Mise à l'échelle horizontale", "Mises à jour progressives"],
422
+ "tagline": "Cluster k3s de production.",
423
+ "features": [
424
+ "Région unique",
425
+ "Mise à l'échelle horizontale",
426
+ "Mises à jour progressives"
427
+ ],
417
428
  "bestFor": "Applications à fort trafic"
418
429
  },
419
430
  "k8sHa": {
420
431
  "name": "Kubernetes HA",
421
432
  "tagline": "Clusters multi-région, disponibilité maximale.",
422
- "features": ["2 régions", "Bascule de région", "Mise à l'échelle auto"],
433
+ "features": [
434
+ "2 régions",
435
+ "Bascule de région",
436
+ "Autoscaling des pods"
437
+ ],
423
438
  "bestFor": "Critique et mondial"
424
439
  }
425
440
  },
@@ -459,7 +474,7 @@
459
474
  "backups": "Sauvegardes S3 automatisées",
460
475
  "cicd": "CI/CD via GitHub Actions",
461
476
  "unlimited": "Serveurs et projets illimités",
462
- "commercial": "Droits d'usage commercial"
477
+ "commercial": "Exception FSL (intégration, marque blanche, revente)"
463
478
  }
464
479
  },
465
480
  "fullerene": {
@@ -467,7 +482,7 @@
467
482
  "cta": "Passer à Fullerene",
468
483
  "features": {
469
484
  "diamond": "Tout ce qu'inclut Diamond",
470
- "commercial": "Droits d'usage commercial (exception AGPL)",
485
+ "commercial": "Exception FSL : intégrer, proposer en marque blanche ou revendre des services propulsés par Vibecarbon",
471
486
  "clients": "Déployez pour des clients et des organisations",
472
487
  "corporate": "Équipes d'agence et d'entreprise",
473
488
  "support": "Support e-mail prioritaire",
@@ -496,7 +511,7 @@
496
511
  },
497
512
  "q5": {
498
513
  "question": "Comment fonctionne la tarification ?",
499
- "answer": "Graphite est gratuit pour toujours — crée des projets, développe localement et utilise tous les modules. Diamond (149 $ à vie) offre une automatisation complète pour les indie hackers et startups — tous les modes de déploiement, sauvegardes, CI/CD et mise à l'échelle. Fullerene (499 $ à vie) est la licence commerciale pour les agences et l'entreprise — déploie pour des clients et organisations sans restrictions AGPL. Paiement unique, sans abonnement."
514
+ "answer": "Graphite est gratuit pour toujours — crée des projets, développe localement et utilise tous les modules. Diamond (149 $ à vie) offre une automatisation complète pour les indie hackers et startups — tous les modes de déploiement, sauvegardes, CI/CD et mise à l'échelle. Fullerene (499 $ à vie) s'adresse aux agences et à l'entreprise — déploie pour des clients, avec en plus l'exception FSL pour intégrer, proposer en marque blanche ou revendre des services propulsés par Vibecarbon. Paiement unique, sans abonnement."
500
515
  },
501
516
  "q6": {
502
517
  "question": "Combien de projets puis-je créer avec une seule licence ?",
@@ -508,7 +523,7 @@
508
523
  },
509
524
  "q8": {
510
525
  "question": "Que se passe-t-il si Vibecarbon ferme ses portes ?",
511
- "answer": "Rien ne change pour votre application. Le code généré est sous licence MIT et n'a aucune dépendance d'exécution envers Vibecarbon — c'est simplement une base de code qui vous appartient entièrement. La CLI est open source sous AGPL, vous pouvez donc l'exécuter à partir des sources indéfiniment. Votre infrastructure tourne sur vos propres serveurs."
526
+ "answer": "Rien ne change pour votre application. Le code généré est sous licence MIT et n'a aucune dépendance d'exécution envers Vibecarbon — c'est simplement une base de code qui vous appartient entièrement. La CLI est Fair Source (FSL) vous pouvez l'exécuter à partir des sources indéfiniment pour tout usage non concurrent, et chaque version devient open source MIT deux ans après sa publication. Votre infrastructure tourne sur vos propres serveurs."
512
527
  },
513
528
  "q9": {
514
529
  "question": "Faut-il avoir une expérience DevOps pour déployer ?",
@@ -293,7 +293,7 @@
293
293
  },
294
294
  "deploy": {
295
295
  "label": "Publicar",
296
- "description": "Publica em produção com auto-scaling, alta disponibilidade, monitorização, CI/CD e muito mais."
296
+ "description": "Publica em produção com escalonamento, alta disponibilidade, monitorização, CI/CD e muito mais."
297
297
  }
298
298
  },
299
299
  "techStack": {
@@ -352,7 +352,7 @@
352
352
  "traffic": {
353
353
  "risk": "Incapaz de escalar",
354
354
  "protection": "Auto-scaling",
355
- "how": "Auto-scaling Kubernetes em pods e nós VPS"
355
+ "how": "Auto-scaling de pods Kubernetes mais escalonamento de workers com um comando"
356
356
  },
357
357
  "redundancy": {
358
358
  "risk": "Sem redundância",
@@ -401,25 +401,40 @@
401
401
  "compose": {
402
402
  "name": "Docker Compose",
403
403
  "tagline": "Um VPS, com toda a sua stack incluída.",
404
- "features": ["1 servidor", "Região única"],
404
+ "features": [
405
+ "1 servidor",
406
+ "Região única"
407
+ ],
405
408
  "bestFor": "MVPs e ferramentas internas"
406
409
  },
407
410
  "composeHa": {
408
411
  "name": "Compose HA",
409
- "tagline": "Dois servidores, failover automático.",
410
- "features": ["2 servidores", "Replicação em streaming", "Failover automático"],
412
+ "tagline": "Duas regiões, failover com um comando.",
413
+ "features": [
414
+ "2 servidores",
415
+ "Replicação em streaming",
416
+ "Failover com um comando"
417
+ ],
411
418
  "bestFor": "SaaS em produção sem Kubernetes"
412
419
  },
413
420
  "k8s": {
414
421
  "name": "Kubernetes",
415
- "tagline": "Cluster k3s com escalonamento automático.",
416
- "features": ["Região única", "Escalonamento horizontal", "Atualizações graduais"],
422
+ "tagline": "Cluster k3s pronto para produção.",
423
+ "features": [
424
+ "Região única",
425
+ "Escalonamento horizontal",
426
+ "Atualizações graduais"
427
+ ],
417
428
  "bestFor": "Apps de alto tráfego"
418
429
  },
419
430
  "k8sHa": {
420
431
  "name": "Kubernetes HA",
421
432
  "tagline": "Clusters multirregião, disponibilidade máxima.",
422
- "features": ["2 regiões", "Failover de região", "Escalonamento automático"],
433
+ "features": [
434
+ "2 regiões",
435
+ "Failover de região",
436
+ "Auto-scaling de pods"
437
+ ],
423
438
  "bestFor": "Crítico e global"
424
439
  }
425
440
  },
@@ -459,7 +474,7 @@
459
474
  "backups": "Backups S3 automáticos",
460
475
  "cicd": "CI/CD via GitHub Actions",
461
476
  "unlimited": "Servidores e projetos ilimitados",
462
- "commercial": "Direitos de uso comercial"
477
+ "commercial": "Exceção FSL (integração, white-label, revenda)"
463
478
  }
464
479
  },
465
480
  "fullerene": {
@@ -467,7 +482,7 @@
467
482
  "cta": "Ir para Fullerene",
468
483
  "features": {
469
484
  "diamond": "Tudo do Diamond",
470
- "commercial": "Direitos de uso comercial (exceção AGPL)",
485
+ "commercial": "Exceção FSL: integrar, oferecer white-label ou revender serviços baseados no Vibecarbon",
471
486
  "clients": "Implante para clientes e organizações",
472
487
  "corporate": "Equipas de agência e empresas",
473
488
  "support": "Suporte prioritário por e-mail",
@@ -496,7 +511,7 @@
496
511
  },
497
512
  "q5": {
498
513
  "question": "Como funciona o modelo de preços?",
499
- "answer": "O Graphite é gratuito para sempre — cria projetos, desenvolve localmente e usa todos os add-ons. O Diamond (149 $ vitalício) é automatização completa para indie hackers e startups — todos os modos de deployment, backups, CI/CD e escalamento. O Fullerene (499 $ vitalício) é a licença comercial para agências e empresas — faz deployment para clientes e organizações sem restrições AGPL. Pagamento único, sem subscrições."
514
+ "answer": "O Graphite é gratuito para sempre — cria projetos, desenvolve localmente e usa todos os add-ons. O Diamond (149 $ vitalício) é automatização completa para indie hackers e startups — todos os modos de deployment, backups, CI/CD e escalamento. O Fullerene (499 $ vitalício) é para agências e empresas — faz deployment para clientes, mais a exceção FSL para integrar, oferecer white-label ou revender serviços baseados no Vibecarbon. Pagamento único, sem subscrições."
500
515
  },
501
516
  "q6": {
502
517
  "question": "Quantos projetos posso criar com uma licença?",
@@ -508,7 +523,7 @@
508
523
  },
509
524
  "q8": {
510
525
  "question": "O que acontece se o Vibecarbon encerrar?",
511
- "answer": "Nada muda para a sua aplicação. O código gerado tem licença MIT sem qualquer dependência em tempo de execução do Vibecarbon — é apenas uma base de código que lhe pertence por completo. A CLI é open source sob AGPL, por isso pode executá-la a partir do código-fonte indefinidamente. A sua infraestrutura corre nos seus próprios servidores."
526
+ "answer": "Nada muda para a sua aplicação. O código gerado tem licença MIT sem qualquer dependência em tempo de execução do Vibecarbon — é apenas uma base de código que lhe pertence por completo. A CLI é Fair Source (FSL) pode executá-la a partir do código-fonte indefinidamente para qualquer uso não concorrente, e cada versão torna-se open source MIT dois anos após a publicação. A sua infraestrutura corre nos seus próprios servidores."
512
527
  },
513
528
  "q9": {
514
529
  "question": "Preciso de experiência em DevOps para implantar?",
@@ -52,7 +52,7 @@ const tierData: Record<string, TierInfo> = {
52
52
  gradientClass: 'from-secondary-accent to-fuchsia-400',
53
53
  features: [
54
54
  'Everything in Diamond',
55
- 'Commercial use rights (AGPL exception)',
55
+ 'FSL exception: embed, white-label, or resell Vibecarbon-powered services',
56
56
  'Deploy for clients & organizations',
57
57
  'Agency & enterprise teams',
58
58
  'Priority email support',
@@ -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
+ }