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.
- 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/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +30 -15
- 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/Checkout.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/server/lib/request.ts +0 -34
- 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
|
|
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
|
|
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": [
|
|
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
|
|
410
|
-
"features": [
|
|
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
|
|
416
|
-
"features": [
|
|
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": [
|
|
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": "
|
|
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": "
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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": [
|
|
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": "
|
|
410
|
-
"features": [
|
|
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
|
|
416
|
-
"features": [
|
|
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": [
|
|
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": "
|
|
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": "
|
|
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) é
|
|
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 é
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
+
}
|