vibecarbon 0.1.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 +663 -0
- package/README.md +188 -0
- package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
- package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
- package/carbon/.claude/settings.json +29 -0
- package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
- package/carbon/.dockerignore +57 -0
- package/carbon/.env.example +219 -0
- package/carbon/.github/copilot-instructions.md +3 -0
- package/carbon/.github/workflows/deploy.yml +346 -0
- package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
- package/carbon/.windsurfrules +74 -0
- package/carbon/AGENTS.md +422 -0
- package/carbon/CLAUDE.md +3 -0
- package/carbon/DEVELOPMENT.md +187 -0
- package/carbon/Dockerfile +98 -0
- package/carbon/LICENSE +21 -0
- package/carbon/PRODUCTION.md +364 -0
- package/carbon/README.md +193 -0
- package/carbon/TESTING.md +138 -0
- package/carbon/backup/Dockerfile +75 -0
- package/carbon/backup/backup.sh +95 -0
- package/carbon/backup/compose-backup.sh +140 -0
- package/carbon/biome.json +83 -0
- package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
- package/carbon/cloud-init/k3s/master-init.sh +215 -0
- package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
- package/carbon/cloud-init/k3s/worker-init.sh +147 -0
- package/carbon/components.json +24 -0
- package/carbon/content/blog/authentication-guide.mdx +34 -0
- package/carbon/content/blog/getting-started.mdx +41 -0
- package/carbon/content/changelog/v0-1-0.mdx +40 -0
- package/carbon/content/docs/analytics.mdx +90 -0
- package/carbon/content/docs/authentication.mdx +231 -0
- package/carbon/content/docs/background-jobs.mdx +116 -0
- package/carbon/content/docs/cli.mdx +630 -0
- package/carbon/content/docs/database.mdx +236 -0
- package/carbon/content/docs/deployment.mdx +227 -0
- package/carbon/content/docs/development.mdx +238 -0
- package/carbon/content/docs/environments.mdx +84 -0
- package/carbon/content/docs/getting-started.mdx +112 -0
- package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
- package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
- package/carbon/content/docs/optional-services.mdx +160 -0
- package/carbon/db/Dockerfile +23 -0
- package/carbon/docker-compose.dev-init.yml +5 -0
- package/carbon/docker-compose.metabase.override.yml +14 -0
- package/carbon/docker-compose.metabase.prod.yml +28 -0
- package/carbon/docker-compose.metabase.yml +84 -0
- package/carbon/docker-compose.n8n.override.yml +14 -0
- package/carbon/docker-compose.n8n.prod.yml +31 -0
- package/carbon/docker-compose.n8n.yml +97 -0
- package/carbon/docker-compose.observability.override.yml +18 -0
- package/carbon/docker-compose.observability.prod.yml +28 -0
- package/carbon/docker-compose.observability.yml +125 -0
- package/carbon/docker-compose.override.yml +28 -0
- package/carbon/docker-compose.prod.yml +294 -0
- package/carbon/docker-compose.yml +508 -0
- package/carbon/docker-entrypoint.sh +12 -0
- package/carbon/ha/activate-standby.sh +52 -0
- package/carbon/ha/primary-init.sql +36 -0
- package/carbon/ha/standby-init.sh +74 -0
- package/carbon/k8s/LICENSE +99 -0
- package/carbon/k8s/README.md +272 -0
- package/carbon/k8s/base/app/deployment.yaml +130 -0
- package/carbon/k8s/base/app/hpa.yaml +44 -0
- package/carbon/k8s/base/app/kustomization.yaml +10 -0
- package/carbon/k8s/base/app/network-policy.yaml +124 -0
- package/carbon/k8s/base/app/pdb.yaml +14 -0
- package/carbon/k8s/base/app/rbac.yaml +36 -0
- package/carbon/k8s/base/app/service.yaml +16 -0
- package/carbon/k8s/base/backup/cronjob.yaml +120 -0
- package/carbon/k8s/base/backup/kustomization.yaml +7 -0
- package/carbon/k8s/base/backup/network-policy.yaml +31 -0
- package/carbon/k8s/base/backup/rbac.yaml +7 -0
- package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
- package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
- package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
- package/carbon/k8s/base/config/configmap.yaml +48 -0
- package/carbon/k8s/base/config/kustomization.yaml +8 -0
- package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
- package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
- package/carbon/k8s/base/kustomization.yaml +43 -0
- package/carbon/k8s/base/namespace.yaml +7 -0
- package/carbon/k8s/base/network-policies.yaml +95 -0
- package/carbon/k8s/base/registry/kustomization.yaml +5 -0
- package/carbon/k8s/base/registry/local-registry.yaml +193 -0
- package/carbon/k8s/base/traefik/certificate.yaml +21 -0
- package/carbon/k8s/base/traefik/configmap.yaml +13 -0
- package/carbon/k8s/base/traefik/deployment.yaml +165 -0
- package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
- package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
- package/carbon/k8s/base/traefik/middleware.yaml +109 -0
- package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
- package/carbon/k8s/base/traefik/service.yaml +38 -0
- package/carbon/k8s/flux/README.md +49 -0
- package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
- package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
- package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
- package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
- package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
- package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
- package/carbon/k8s/infra/kustomization.yaml +21 -0
- package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
- package/carbon/k8s/overlays/local/configmap.yaml +23 -0
- package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
- package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
- package/carbon/k8s/overlays/local/namespace.yaml +8 -0
- package/carbon/k8s/overlays/local/secrets.yaml +32 -0
- package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
- package/carbon/k8s/test-local.sh +318 -0
- package/carbon/k8s/values/supabase.values.yaml +133 -0
- package/carbon/package.json +154 -0
- package/carbon/runtime.Dockerfile +17 -0
- package/carbon/scripts/_dev-jwt.mjs +45 -0
- package/carbon/scripts/check-rls.ts +53 -0
- package/carbon/scripts/dev-init.js +191 -0
- package/carbon/scripts/dev.js +191 -0
- package/carbon/scripts/docker-down.js +63 -0
- package/carbon/scripts/docker-logs.js +59 -0
- package/carbon/scripts/docker-up.js +222 -0
- package/carbon/scripts/generate-dev-configs.sh +131 -0
- package/carbon/scripts/generate-rss.ts +116 -0
- package/carbon/scripts/generate-sitemap.ts +102 -0
- package/carbon/scripts/k8s-apply.js +71 -0
- package/carbon/scripts/k8s-delete.js +75 -0
- package/carbon/scripts/lib/manifest.js +176 -0
- package/carbon/scripts/secret-scan.mjs +278 -0
- package/carbon/scripts/validate-dev-configs.sh +101 -0
- package/carbon/src/client/App.tsx +202 -0
- package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
- package/carbon/src/client/assets/hyperformant-light.svg +29 -0
- package/carbon/src/client/assets/logos/aider.svg +1 -0
- package/carbon/src/client/assets/logos/antigravity.svg +1 -0
- package/carbon/src/client/assets/logos/bolt.svg +1 -0
- package/carbon/src/client/assets/logos/claude-code.svg +1 -0
- package/carbon/src/client/assets/logos/copilot.svg +1 -0
- package/carbon/src/client/assets/logos/cursor.svg +1 -0
- package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
- package/carbon/src/client/assets/logos/lovable.svg +1 -0
- package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
- package/carbon/src/client/assets/logos/v0.svg +1 -0
- package/carbon/src/client/assets/logos/windsurf.svg +1 -0
- package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
- package/carbon/src/client/components/AppSidebar.tsx +760 -0
- package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
- package/carbon/src/client/components/CTAFooter.tsx +59 -0
- package/carbon/src/client/components/ComparisonSection.tsx +132 -0
- package/carbon/src/client/components/ContentPanel.tsx +66 -0
- package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
- package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
- package/carbon/src/client/components/FAQSection.tsx +76 -0
- package/carbon/src/client/components/FileUpload.tsx +210 -0
- package/carbon/src/client/components/HeaderActions.tsx +17 -0
- package/carbon/src/client/components/Hero.tsx +608 -0
- package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
- package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
- package/carbon/src/client/components/Logo.tsx +87 -0
- package/carbon/src/client/components/LogoStrip.tsx +76 -0
- package/carbon/src/client/components/MetricsStrip.tsx +48 -0
- package/carbon/src/client/components/Nav.tsx +195 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
- package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
- package/carbon/src/client/components/PageHeader.tsx +24 -0
- package/carbon/src/client/components/PlanGate.tsx +36 -0
- package/carbon/src/client/components/PricingSection.tsx +371 -0
- package/carbon/src/client/components/SEO.tsx +34 -0
- package/carbon/src/client/components/SmoothScroll.tsx +45 -0
- package/carbon/src/client/components/TechStackSection.tsx +165 -0
- package/carbon/src/client/components/WorkflowSection.tsx +604 -0
- package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
- package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
- package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
- package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
- package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
- package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
- package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
- package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
- package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
- package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
- package/carbon/src/client/components/effects/index.ts +2 -0
- package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
- package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
- package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
- package/carbon/src/client/components/scroll/index.ts +2 -0
- package/carbon/src/client/components/ui/accordion.tsx +71 -0
- package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
- package/carbon/src/client/components/ui/alert.tsx +73 -0
- package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
- package/carbon/src/client/components/ui/avatar.tsx +91 -0
- package/carbon/src/client/components/ui/badge.tsx +50 -0
- package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
- package/carbon/src/client/components/ui/button-group.tsx +78 -0
- package/carbon/src/client/components/ui/button.tsx +120 -0
- package/carbon/src/client/components/ui/calendar.tsx +182 -0
- package/carbon/src/client/components/ui/card.tsx +85 -0
- package/carbon/src/client/components/ui/carousel.tsx +227 -0
- package/carbon/src/client/components/ui/chart.tsx +357 -0
- package/carbon/src/client/components/ui/checkbox.tsx +27 -0
- package/carbon/src/client/components/ui/collapsible.tsx +15 -0
- package/carbon/src/client/components/ui/command.tsx +178 -0
- package/carbon/src/client/components/ui/context-menu.tsx +233 -0
- package/carbon/src/client/components/ui/dialog.tsx +132 -0
- package/carbon/src/client/components/ui/drawer.tsx +118 -0
- package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
- package/carbon/src/client/components/ui/empty.tsx +94 -0
- package/carbon/src/client/components/ui/field.tsx +226 -0
- package/carbon/src/client/components/ui/hover-card.tsx +44 -0
- package/carbon/src/client/components/ui/input-group.tsx +146 -0
- package/carbon/src/client/components/ui/input-otp.tsx +83 -0
- package/carbon/src/client/components/ui/input.tsx +20 -0
- package/carbon/src/client/components/ui/item.tsx +188 -0
- package/carbon/src/client/components/ui/kbd.tsx +26 -0
- package/carbon/src/client/components/ui/label.tsx +21 -0
- package/carbon/src/client/components/ui/menubar.tsx +250 -0
- package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
- package/carbon/src/client/components/ui/pagination.tsx +102 -0
- package/carbon/src/client/components/ui/popover.tsx +75 -0
- package/carbon/src/client/components/ui/progress.tsx +66 -0
- package/carbon/src/client/components/ui/radio-group.tsx +36 -0
- package/carbon/src/client/components/ui/resizable.tsx +46 -0
- package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
- package/carbon/src/client/components/ui/select.tsx +186 -0
- package/carbon/src/client/components/ui/separator.tsx +21 -0
- package/carbon/src/client/components/ui/sheet.tsx +124 -0
- package/carbon/src/client/components/ui/sidebar.tsx +702 -0
- package/carbon/src/client/components/ui/skeleton.tsx +13 -0
- package/carbon/src/client/components/ui/slider.tsx +57 -0
- package/carbon/src/client/components/ui/sonner.tsx +45 -0
- package/carbon/src/client/components/ui/spinner.tsx +15 -0
- package/carbon/src/client/components/ui/switch.tsx +30 -0
- package/carbon/src/client/components/ui/table.tsx +89 -0
- package/carbon/src/client/components/ui/tabs.tsx +73 -0
- package/carbon/src/client/components/ui/textarea.tsx +18 -0
- package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
- package/carbon/src/client/components/ui/toggle.tsx +44 -0
- package/carbon/src/client/components/ui/tooltip.tsx +56 -0
- package/carbon/src/client/hooks/api/index.ts +14 -0
- package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
- package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
- package/carbon/src/client/hooks/use-mobile.ts +21 -0
- package/carbon/src/client/hooks/useMousePosition.ts +91 -0
- package/carbon/src/client/hooks/useNotifications.tsx +124 -0
- package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
- package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
- package/carbon/src/client/hooks/usePackageManager.ts +69 -0
- package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
- package/carbon/src/client/hooks/useRunningServices.ts +114 -0
- package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
- package/carbon/src/client/index.css +467 -0
- package/carbon/src/client/index.html +56 -0
- package/carbon/src/client/lib/admin-services.ts +151 -0
- package/carbon/src/client/lib/api.ts +32 -0
- package/carbon/src/client/lib/blog.ts +35 -0
- package/carbon/src/client/lib/changelog.ts +33 -0
- package/carbon/src/client/lib/docs-search.ts +101 -0
- package/carbon/src/client/lib/docs.ts +37 -0
- package/carbon/src/client/lib/i18n.ts +32 -0
- package/carbon/src/client/lib/supabase.ts +72 -0
- package/carbon/src/client/lib/tailwind-colors.ts +357 -0
- package/carbon/src/client/lib/theme.ts +117 -0
- package/carbon/src/client/lib/utils.ts +22 -0
- package/carbon/src/client/locales/de.json +529 -0
- package/carbon/src/client/locales/en.json +461 -0
- package/carbon/src/client/locales/es.json +529 -0
- package/carbon/src/client/locales/fr.json +529 -0
- package/carbon/src/client/locales/pt.json +529 -0
- package/carbon/src/client/main.tsx +56 -0
- package/carbon/src/client/mdx.d.ts +13 -0
- package/carbon/src/client/pages/ApiDocs.tsx +76 -0
- package/carbon/src/client/pages/AuthCallback.tsx +34 -0
- package/carbon/src/client/pages/Blog.tsx +167 -0
- package/carbon/src/client/pages/Changelog.tsx +171 -0
- package/carbon/src/client/pages/Charts.tsx +388 -0
- package/carbon/src/client/pages/Checkout.tsx +227 -0
- package/carbon/src/client/pages/Contact.tsx +174 -0
- package/carbon/src/client/pages/Dashboard.tsx +368 -0
- package/carbon/src/client/pages/Docs.tsx +372 -0
- package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
- package/carbon/src/client/pages/Home.tsx +187 -0
- package/carbon/src/client/pages/Legal.tsx +100 -0
- package/carbon/src/client/pages/Login.tsx +408 -0
- package/carbon/src/client/pages/MFAVerify.tsx +156 -0
- package/carbon/src/client/pages/NotFound.tsx +21 -0
- package/carbon/src/client/pages/Onboarding.tsx +246 -0
- package/carbon/src/client/pages/ResetPassword.tsx +200 -0
- package/carbon/src/client/pages/UIComponents.tsx +390 -0
- package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
- package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
- package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
- package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
- package/carbon/src/client/pages/admin/Logs.tsx +18 -0
- package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
- package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
- package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
- package/carbon/src/client/pages/admin/Settings.tsx +314 -0
- package/carbon/src/client/pages/admin/Theme.tsx +465 -0
- package/carbon/src/client/pages/admin/Users.tsx +365 -0
- package/carbon/src/client/pages/api-docs.css +156 -0
- package/carbon/src/client/pages/organizations/Details.tsx +200 -0
- package/carbon/src/client/pages/organizations/Members.tsx +402 -0
- package/carbon/src/client/pages/settings/Billing.tsx +473 -0
- package/carbon/src/client/pages/settings/Profile.tsx +160 -0
- package/carbon/src/client/pages/settings/Security.tsx +341 -0
- package/carbon/src/client/public/favicon.svg +19 -0
- package/carbon/src/client/public/robots.txt +8 -0
- package/carbon/src/server/billing/index.ts +104 -0
- package/carbon/src/server/billing/provider.ts +81 -0
- package/carbon/src/server/billing/providers/paddle.ts +314 -0
- package/carbon/src/server/billing/providers/polar.ts +325 -0
- package/carbon/src/server/billing/providers/stripe.ts +233 -0
- package/carbon/src/server/emails/templates.ts +116 -0
- package/carbon/src/server/index.ts +554 -0
- package/carbon/src/server/lib/auth.ts +6 -0
- package/carbon/src/server/lib/email.ts +64 -0
- package/carbon/src/server/lib/env.ts +112 -0
- package/carbon/src/server/lib/errors.ts +21 -0
- package/carbon/src/server/lib/logger.ts +17 -0
- package/carbon/src/server/lib/rate-limiter.ts +288 -0
- package/carbon/src/server/lib/request.ts +34 -0
- package/carbon/src/server/lib/stripe.ts +42 -0
- package/carbon/src/server/lib/supabase.ts +65 -0
- package/carbon/src/server/middleware/requirePlan.ts +80 -0
- package/carbon/src/server/routes/_internal/services-status.ts +958 -0
- package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
- package/carbon/src/server/routes/health.ts +48 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
- package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
- package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
- package/carbon/src/server/routes/v1/auth.ts +390 -0
- package/carbon/src/server/routes/v1/billing.ts +718 -0
- package/carbon/src/server/routes/v1/contact.ts +93 -0
- package/carbon/src/server/routes/v1/index.ts +1333 -0
- package/carbon/src/server/routes/v1/newsletter.ts +181 -0
- package/carbon/src/server/routes/v1/performance.ts +157 -0
- package/carbon/src/server/routes/v1/stats.ts +170 -0
- package/carbon/src/server/routes/v1/theme.ts +106 -0
- package/carbon/src/server/routes/webhooks/billing.ts +376 -0
- package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
- package/carbon/src/server/types.ts +11 -0
- package/carbon/src/shared/pricing.ts +155 -0
- package/carbon/src/shared/types.ts +338 -0
- package/carbon/supabase/migrations/00001_init.sql +717 -0
- package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
- package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
- package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
- package/carbon/supabase/seed.sql +16 -0
- package/carbon/tests/_helpers/app.ts +45 -0
- package/carbon/tests/_helpers/env.ts +37 -0
- package/carbon/tests/_helpers/factories.ts +69 -0
- package/carbon/tests/_helpers/jwt.ts +23 -0
- package/carbon/tests/_helpers/setup-integration.ts +20 -0
- package/carbon/tests/_helpers/setup-rtl.ts +12 -0
- package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
- package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
- package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
- package/carbon/tests/integration/server/routes/health.test.ts +61 -0
- package/carbon/tests/structural/i18n-parity.test.ts +42 -0
- package/carbon/tests/unit/client/utils.test.ts +49 -0
- package/carbon/tests/unit/shared/pricing.test.ts +93 -0
- package/carbon/tsconfig.json +27 -0
- package/carbon/tsconfig.server.json +27 -0
- package/carbon/tsconfig.test.json +9 -0
- package/carbon/vite.config.ts +110 -0
- package/carbon/vitest.config.ts +74 -0
- package/carbon/volumes/db/jwt.sql +57 -0
- package/carbon/volumes/db/metabase-init.sh +29 -0
- package/carbon/volumes/db/n8n-init.sh +25 -0
- package/carbon/volumes/db/realtime.sql +33 -0
- package/carbon/volumes/db/roles.sql +93 -0
- package/carbon/volumes/db/set-passwords.sh +12 -0
- package/carbon/volumes/db/super-admin.dev.sql +113 -0
- package/carbon/volumes/db/super-admin.generated.sql +113 -0
- package/carbon/volumes/db/super-admin.sql +114 -0
- package/carbon/volumes/grafana/dashboards/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards/overview.json +523 -0
- package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
- package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
- package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
- package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
- package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
- package/carbon/volumes/kong/kong.yml +208 -0
- package/carbon/volumes/loki/loki-config.yml +58 -0
- package/carbon/volumes/n8n/hooks.js +131 -0
- package/carbon/volumes/n8n/scripts/setup.sh +66 -0
- package/carbon/volumes/prometheus/prometheus.yml +43 -0
- package/carbon/volumes/promtail/promtail-config.yml +64 -0
- package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
- package/carbon/volumes/traefik/middlewares.yml +95 -0
- package/carbon/volumes/traefik/vite-dev.yml +20 -0
- package/package.json +95 -0
- package/src/access.js +354 -0
- package/src/activate.js +187 -0
- package/src/add.js +718 -0
- package/src/backup.js +786 -0
- package/src/cli.js +350 -0
- package/src/configure.js +967 -0
- package/src/console.js +155 -0
- package/src/create.js +1499 -0
- package/src/deploy.js +311 -0
- package/src/destroy.js +2033 -0
- package/src/diagnose.js +735 -0
- package/src/down.js +80 -0
- package/src/failover.js +1032 -0
- package/src/lib/backup-s3.js +179 -0
- package/src/lib/build.js +33 -0
- package/src/lib/checksum.js +28 -0
- package/src/lib/ci-setup.js +666 -0
- package/src/lib/cli/help.js +129 -0
- package/src/lib/cli/parse-flags.js +160 -0
- package/src/lib/cli/select-action.js +65 -0
- package/src/lib/cli/select-environment.js +108 -0
- package/src/lib/cli/tty-guard.js +75 -0
- package/src/lib/cloudflare.js +447 -0
- package/src/lib/colors.js +52 -0
- package/src/lib/command.js +361 -0
- package/src/lib/config.js +359 -0
- package/src/lib/cost.js +103 -0
- package/src/lib/deploy/bundle.js +231 -0
- package/src/lib/deploy/compose/acme-verify.js +121 -0
- package/src/lib/deploy/compose/build-args.js +78 -0
- package/src/lib/deploy/compose/ha.js +1874 -0
- package/src/lib/deploy/compose/index.js +1294 -0
- package/src/lib/deploy/compose/reconcile.js +74 -0
- package/src/lib/deploy/github.js +382 -0
- package/src/lib/deploy/image.js +191 -0
- package/src/lib/deploy/index.js +119 -0
- package/src/lib/deploy/k8s/LICENSE +99 -0
- package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
- package/src/lib/deploy/k8s/ha/index.js +1000 -0
- package/src/lib/deploy/k8s/index.js +62 -0
- package/src/lib/deploy/k8s/k3s.js +2515 -0
- package/src/lib/deploy/orchestrator.js +1401 -0
- package/src/lib/deploy/prompts.js +545 -0
- package/src/lib/deploy/remote-build.js +130 -0
- package/src/lib/deploy/state.js +120 -0
- package/src/lib/deploy/utils.js +328 -0
- package/src/lib/deploy-logger.js +93 -0
- package/src/lib/dns-propagation.js +48 -0
- package/src/lib/environment.js +58 -0
- package/src/lib/fetch-retry.js +103 -0
- package/src/lib/github-environments.js +335 -0
- package/src/lib/hetzner-dns.js +377 -0
- package/src/lib/hetzner-guided-setup.js +275 -0
- package/src/lib/host-keys.js +37 -0
- package/src/lib/iac/cloud-init.js +52 -0
- package/src/lib/iac/index.js +325 -0
- package/src/lib/iac/programs/hetzner-compose.js +130 -0
- package/src/lib/iac/programs/hetzner-k8s.js +320 -0
- package/src/lib/kubectl.js +81 -0
- package/src/lib/licensing/index.js +259 -0
- package/src/lib/licensing/tiers.js +181 -0
- package/src/lib/licensing/validator.js +171 -0
- package/src/lib/merge-package-json.js +90 -0
- package/src/lib/operator-ip.js +381 -0
- package/src/lib/package-manager.js +111 -0
- package/src/lib/perf.js +71 -0
- package/src/lib/project-guard.js +39 -0
- package/src/lib/project.js +334 -0
- package/src/lib/providers/base.js +276 -0
- package/src/lib/providers/hetzner-s3.js +656 -0
- package/src/lib/providers/hetzner.js +755 -0
- package/src/lib/providers/index.js +164 -0
- package/src/lib/s3.js +510 -0
- package/src/lib/secret-scan.js +583 -0
- package/src/lib/secrets.js +63 -0
- package/src/lib/server-types.js +195 -0
- package/src/lib/shell.js +91 -0
- package/src/lib/ssh.js +241 -0
- package/src/lib/tracker.js +242 -0
- package/src/lib/upgrade-policy.js +170 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/version.js +5 -0
- package/src/remove.js +292 -0
- package/src/reset.js +97 -0
- package/src/restore.js +871 -0
- package/src/scale.js +1734 -0
- package/src/shell.js +222 -0
- package/src/status.js +981 -0
- package/src/up.js +264 -0
- package/src/upgrade.js +721 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const envSchema = z.object({
|
|
4
|
+
// Supabase (required)
|
|
5
|
+
SUPABASE_URL: z.string().url(),
|
|
6
|
+
SUPABASE_ANON_KEY: z.string().min(1),
|
|
7
|
+
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
|
|
8
|
+
|
|
9
|
+
// Public site URL — used in CSP connectSrc so the client can reach Supabase
|
|
10
|
+
// via the public domain (Traefik → Kong). Defaults to empty string in dev
|
|
11
|
+
// (CSP is disabled in development).
|
|
12
|
+
SITE_URL: z.string().url().optional(),
|
|
13
|
+
|
|
14
|
+
// Server
|
|
15
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
16
|
+
PORT: z.coerce.number().default(3000),
|
|
17
|
+
|
|
18
|
+
// Port configuration (for running multiple projects)
|
|
19
|
+
DEV_PORT_OFFSET: z.coerce.number().default(0),
|
|
20
|
+
DEV_VITE_PORT: z.coerce.number().optional(),
|
|
21
|
+
DEV_API_PORT: z.coerce.number().optional(),
|
|
22
|
+
DEV_DB_PORT: z.coerce.number().optional(),
|
|
23
|
+
DEV_KONG_PORT: z.coerce.number().optional(),
|
|
24
|
+
DEV_TRAEFIK_PORT: z.coerce.number().optional(),
|
|
25
|
+
DEV_GRAFANA_PORT: z.coerce.number().optional(),
|
|
26
|
+
DEV_PROMETHEUS_PORT: z.coerce.number().optional(),
|
|
27
|
+
DEV_LOKI_PORT: z.coerce.number().optional(),
|
|
28
|
+
|
|
29
|
+
// 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),
|
|
36
|
+
|
|
37
|
+
// SMTP (shared with Supabase Auth)
|
|
38
|
+
SMTP_HOST: z.string().optional(),
|
|
39
|
+
SMTP_PORT: z.coerce.number().optional(),
|
|
40
|
+
SMTP_USER: z.string().optional(),
|
|
41
|
+
SMTP_PASS: z.string().optional(),
|
|
42
|
+
SMTP_ADMIN_EMAIL: z.string().email().optional(),
|
|
43
|
+
SMTP_SENDER_NAME: z.string().optional(),
|
|
44
|
+
|
|
45
|
+
// Billing provider selection (defaults to stripe for backward compatibility)
|
|
46
|
+
BILLING_PROVIDER: z.enum(['stripe', 'paddle', 'polar']).default('stripe'),
|
|
47
|
+
|
|
48
|
+
// Stripe (optional)
|
|
49
|
+
STRIPE_SECRET_KEY: z.string().optional(),
|
|
50
|
+
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
|
51
|
+
STRIPE_PRICE_STARTER: z.string().optional(),
|
|
52
|
+
STRIPE_PRICE_PRO: z.string().optional(),
|
|
53
|
+
|
|
54
|
+
// Paddle (optional - alternative billing provider)
|
|
55
|
+
PADDLE_API_KEY: z.string().optional(),
|
|
56
|
+
PADDLE_WEBHOOK_SECRET: z.string().optional(),
|
|
57
|
+
PADDLE_ENVIRONMENT: z.enum(['sandbox', 'production']).default('sandbox'),
|
|
58
|
+
PADDLE_PRICE_STARTER: z.string().optional(),
|
|
59
|
+
PADDLE_PRICE_PRO: z.string().optional(),
|
|
60
|
+
|
|
61
|
+
// Polar (optional - alternative billing provider)
|
|
62
|
+
POLAR_ACCESS_TOKEN: z.string().optional(),
|
|
63
|
+
POLAR_WEBHOOK_SECRET: z.string().optional(),
|
|
64
|
+
POLAR_ORGANIZATION_ID: z.string().optional(),
|
|
65
|
+
POLAR_PRICE_STARTER: z.string().optional(),
|
|
66
|
+
POLAR_PRICE_PRO: z.string().optional(),
|
|
67
|
+
|
|
68
|
+
// License tier price IDs (for one-time license purchases)
|
|
69
|
+
DIAMOND_PRICE_ID: z.string().optional(),
|
|
70
|
+
FULLERENE_PRICE_ID: z.string().optional(),
|
|
71
|
+
|
|
72
|
+
// Redis (optional - for distributed rate limiting)
|
|
73
|
+
// Format: redis://[:password@]host:port or redis://[:password@]host:port/db
|
|
74
|
+
REDIS_URL: z.string().url().optional(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Apply port offset to localhost URLs in development
|
|
78
|
+
// SUPABASE_URL and SITE_URL are hardcoded to default ports in .env but
|
|
79
|
+
// DEV_PORT_OFFSET shifts Docker ports (Kong on 8000+offset, Vite on 5173+offset).
|
|
80
|
+
const portOffset = Number.parseInt(process.env.DEV_PORT_OFFSET || '0', 10);
|
|
81
|
+
if (process.env.NODE_ENV !== 'production' && portOffset !== 0) {
|
|
82
|
+
for (const key of ['SUPABASE_URL', 'SITE_URL'] as const) {
|
|
83
|
+
const raw = process.env[key];
|
|
84
|
+
if (!raw) continue;
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(raw);
|
|
87
|
+
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
|
|
88
|
+
url.port = String(Number.parseInt(url.port || '80', 10) + portOffset);
|
|
89
|
+
process.env[key] = url.toString().replace(/\/$/, '');
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Leave invalid URLs for Zod to catch
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Validate environment variables
|
|
98
|
+
const parsed = envSchema.safeParse(process.env);
|
|
99
|
+
|
|
100
|
+
if (!parsed.success) {
|
|
101
|
+
// In production, don't expose which specific environment variables failed validation
|
|
102
|
+
// This prevents information disclosure about the application's configuration
|
|
103
|
+
if (process.env.NODE_ENV === 'production') {
|
|
104
|
+
console.error('Server configuration error. Please check environment variables.');
|
|
105
|
+
} else {
|
|
106
|
+
console.error('Invalid environment variables:');
|
|
107
|
+
console.error(parsed.error.flatten().fieldErrors);
|
|
108
|
+
}
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const env = parsed.data;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { env } from './env';
|
|
2
|
+
import { logger } from './logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sanitize error messages for API responses.
|
|
6
|
+
* In production, logs the full error and returns a generic message.
|
|
7
|
+
* In development, returns the actual error message for debugging.
|
|
8
|
+
*/
|
|
9
|
+
export function sanitizeError(error: unknown, fallback = 'An unexpected error occurred'): string {
|
|
10
|
+
if (env.NODE_ENV === 'production') {
|
|
11
|
+
// Log the full error for debugging
|
|
12
|
+
logger.error({ error }, 'API error');
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
// In development, return the actual error message
|
|
16
|
+
if (error instanceof Error) return error.message;
|
|
17
|
+
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
18
|
+
return String((error as { message: unknown }).message);
|
|
19
|
+
}
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
|
|
3
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
4
|
+
|
|
5
|
+
export const logger = pino({
|
|
6
|
+
level: isProduction ? 'info' : 'debug',
|
|
7
|
+
...(isProduction
|
|
8
|
+
? {}
|
|
9
|
+
: {
|
|
10
|
+
transport: {
|
|
11
|
+
target: 'pino-pretty',
|
|
12
|
+
options: {
|
|
13
|
+
colorize: true,
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono';
|
|
2
|
+
import Redis from 'ioredis';
|
|
3
|
+
import { env } from './env';
|
|
4
|
+
import { logger } from './logger';
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// RATE LIMIT STORE INTERFACE
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
interface RateLimitResult {
|
|
11
|
+
count: number;
|
|
12
|
+
resetTime: number;
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RateLimitStore {
|
|
17
|
+
increment(key: string, windowMs: number, max: number): Promise<RateLimitResult>;
|
|
18
|
+
isHealthy(): boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// IN-MEMORY STORE (fallback when Redis unavailable)
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
class InMemoryStore implements RateLimitStore {
|
|
26
|
+
private store: Map<string, { count: number; resetTime: number }> = new Map();
|
|
27
|
+
|
|
28
|
+
constructor() {
|
|
29
|
+
// Clean up old entries every minute
|
|
30
|
+
setInterval(() => {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [key, value] of this.store.entries()) {
|
|
33
|
+
if (value.resetTime < now) {
|
|
34
|
+
this.store.delete(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}, 60000);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async increment(key: string, windowMs: number, max: number): Promise<RateLimitResult> {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const entry = this.store.get(key);
|
|
43
|
+
|
|
44
|
+
if (!entry || entry.resetTime < now) {
|
|
45
|
+
const resetTime = now + windowMs;
|
|
46
|
+
this.store.set(key, { count: 1, resetTime });
|
|
47
|
+
return { count: 1, resetTime, allowed: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
entry.count++;
|
|
51
|
+
return {
|
|
52
|
+
count: entry.count,
|
|
53
|
+
resetTime: entry.resetTime,
|
|
54
|
+
allowed: entry.count <= max,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
isHealthy(): boolean {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// REDIS STORE (for distributed rate limiting)
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
class RedisStore implements RateLimitStore {
|
|
68
|
+
private client: Redis;
|
|
69
|
+
private healthy = false;
|
|
70
|
+
|
|
71
|
+
constructor(redisUrl: string) {
|
|
72
|
+
this.client = new Redis(redisUrl, {
|
|
73
|
+
maxRetriesPerRequest: 1,
|
|
74
|
+
retryStrategy: (times) => {
|
|
75
|
+
if (times > 3) {
|
|
76
|
+
logger.warn('Redis connection failed, rate limiting will use in-memory fallback');
|
|
77
|
+
return null; // Stop retrying
|
|
78
|
+
}
|
|
79
|
+
return Math.min(times * 100, 1000);
|
|
80
|
+
},
|
|
81
|
+
lazyConnect: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.client.on('connect', () => {
|
|
85
|
+
this.healthy = true;
|
|
86
|
+
logger.info('Redis connected for rate limiting');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
this.client.on('error', (err) => {
|
|
90
|
+
this.healthy = false;
|
|
91
|
+
logger.warn({ err: err.message }, 'Redis error');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.client.on('close', () => {
|
|
95
|
+
this.healthy = false;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Attempt connection
|
|
99
|
+
this.client.connect().catch(() => {
|
|
100
|
+
// Connection failed, will use fallback
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async increment(key: string, windowMs: number, max: number): Promise<RateLimitResult> {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const windowKey = `ratelimit:${key}:${Math.floor(now / windowMs)}`;
|
|
107
|
+
const resetTime = Math.ceil(now / windowMs) * windowMs + windowMs;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Use Redis MULTI for atomic increment and TTL set
|
|
111
|
+
const results = await this.client
|
|
112
|
+
.multi()
|
|
113
|
+
.incr(windowKey)
|
|
114
|
+
.pexpire(windowKey, windowMs + 1000) // Extra 1s buffer
|
|
115
|
+
.exec();
|
|
116
|
+
|
|
117
|
+
if (!results || results[0][0]) {
|
|
118
|
+
throw new Error('Redis transaction failed');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const count = results[0][1] as number;
|
|
122
|
+
return {
|
|
123
|
+
count,
|
|
124
|
+
resetTime,
|
|
125
|
+
allowed: count <= max,
|
|
126
|
+
};
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// Redis operation failed, mark as unhealthy
|
|
129
|
+
this.healthy = false;
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
isHealthy(): boolean {
|
|
135
|
+
return this.healthy;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// =============================================================================
|
|
140
|
+
// HYBRID STORE (Redis with in-memory fallback)
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
class HybridStore implements RateLimitStore {
|
|
144
|
+
private redisStore: RedisStore | null = null;
|
|
145
|
+
private memoryStore: InMemoryStore;
|
|
146
|
+
private warnedAboutFallback = false;
|
|
147
|
+
|
|
148
|
+
constructor(redisUrl?: string) {
|
|
149
|
+
this.memoryStore = new InMemoryStore();
|
|
150
|
+
|
|
151
|
+
if (redisUrl) {
|
|
152
|
+
this.redisStore = new RedisStore(redisUrl);
|
|
153
|
+
} else if (env.NODE_ENV === 'production') {
|
|
154
|
+
logger.warn(
|
|
155
|
+
'REDIS_URL not configured. Rate limiting uses in-memory store which does not scale across multiple instances. ' +
|
|
156
|
+
'For production with multiple replicas, configure Redis: vibecarbon add redis'
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async increment(key: string, windowMs: number, max: number): Promise<RateLimitResult> {
|
|
162
|
+
// Try Redis first if available
|
|
163
|
+
if (this.redisStore?.isHealthy()) {
|
|
164
|
+
try {
|
|
165
|
+
return await this.redisStore.increment(key, windowMs, max);
|
|
166
|
+
} catch {
|
|
167
|
+
// Fall through to memory store
|
|
168
|
+
if (!this.warnedAboutFallback) {
|
|
169
|
+
this.warnedAboutFallback = true;
|
|
170
|
+
logger.warn('Redis unavailable, falling back to in-memory rate limiting');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Use in-memory fallback
|
|
176
|
+
return this.memoryStore.increment(key, windowMs, max);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
isHealthy(): boolean {
|
|
180
|
+
return this.redisStore?.isHealthy() || this.memoryStore.isHealthy();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// =============================================================================
|
|
185
|
+
// SINGLETON STORE INSTANCE
|
|
186
|
+
// =============================================================================
|
|
187
|
+
|
|
188
|
+
let store: HybridStore | null = null;
|
|
189
|
+
|
|
190
|
+
function getStore(): HybridStore {
|
|
191
|
+
if (!store) {
|
|
192
|
+
store = new HybridStore(env.REDIS_URL);
|
|
193
|
+
}
|
|
194
|
+
return store;
|
|
195
|
+
}
|
|
196
|
+
|
|
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
|
+
// =============================================================================
|
|
260
|
+
// RATE LIMITER MIDDLEWARE
|
|
261
|
+
// =============================================================================
|
|
262
|
+
|
|
263
|
+
export function createRateLimiter(options: { windowMs: number; max: number }) {
|
|
264
|
+
return async (c: Context, next: Next) => {
|
|
265
|
+
const ip = getClientIP(c);
|
|
266
|
+
const key = `${ip}:${c.req.path}`;
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
|
|
269
|
+
const result = await getStore().increment(key, options.windowMs, options.max);
|
|
270
|
+
const remaining = Math.max(0, options.max - result.count);
|
|
271
|
+
const reset = Math.ceil(result.resetTime / 1000);
|
|
272
|
+
|
|
273
|
+
c.res.headers.set('X-RateLimit-Limit', String(options.max));
|
|
274
|
+
c.res.headers.set('X-RateLimit-Remaining', String(remaining));
|
|
275
|
+
c.res.headers.set('X-RateLimit-Reset', String(reset));
|
|
276
|
+
|
|
277
|
+
if (!result.allowed) {
|
|
278
|
+
// Log rate limit violations for security monitoring
|
|
279
|
+
logger.warn({ ip, path: c.req.path, count: result.count }, 'Rate limit exceeded');
|
|
280
|
+
return c.json(
|
|
281
|
+
{ error: 'Too many requests', retryAfter: Math.ceil((result.resetTime - now) / 1000) },
|
|
282
|
+
429
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await next();
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { logger } from './logger';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract client IP address from request headers.
|
|
5
|
+
* Checks common proxy headers (x-forwarded-for, x-real-ip, cf-connecting-ip).
|
|
6
|
+
*/
|
|
7
|
+
export function getClientIp(c: { req: { header: (name: string) => string | undefined } }): string {
|
|
8
|
+
// Cloudflare
|
|
9
|
+
const cfIp = c.req.header('cf-connecting-ip');
|
|
10
|
+
if (cfIp && isValidIp(cfIp)) return cfIp;
|
|
11
|
+
|
|
12
|
+
// Standard proxy headers
|
|
13
|
+
const forwarded = c.req.header('x-forwarded-for');
|
|
14
|
+
if (forwarded) {
|
|
15
|
+
const first = forwarded.split(',')[0].trim();
|
|
16
|
+
if (isValidIp(first)) return first;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const realIp = c.req.header('x-real-ip');
|
|
20
|
+
if (realIp && isValidIp(realIp)) return realIp;
|
|
21
|
+
|
|
22
|
+
logger.warn(
|
|
23
|
+
'Could not extract client IP from request headers (x-forwarded-for, x-real-ip, cf-connecting-ip). ' +
|
|
24
|
+
'Using fallback. Check proxy configuration if this happens frequently.'
|
|
25
|
+
);
|
|
26
|
+
return '127.0.0.1';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ipv4Regex = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
|
30
|
+
const ipv6Regex = /^[a-fA-F0-9:]+$/;
|
|
31
|
+
|
|
32
|
+
function isValidIp(ip: string): boolean {
|
|
33
|
+
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { env } from './env';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stripe client instance.
|
|
6
|
+
* Only initialized if STRIPE_SECRET_KEY is configured.
|
|
7
|
+
*/
|
|
8
|
+
export const stripe = env.STRIPE_SECRET_KEY
|
|
9
|
+
? new Stripe(env.STRIPE_SECRET_KEY, {
|
|
10
|
+
typescript: true,
|
|
11
|
+
})
|
|
12
|
+
: null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the Stripe client, throwing an error if not configured.
|
|
16
|
+
* Use this in routes that require Stripe to be configured.
|
|
17
|
+
*/
|
|
18
|
+
export function getStripe(): Stripe {
|
|
19
|
+
if (!stripe) {
|
|
20
|
+
throw new Error('Stripe is not configured. Set STRIPE_SECRET_KEY in your environment.');
|
|
21
|
+
}
|
|
22
|
+
return stripe;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if Stripe is configured.
|
|
27
|
+
*/
|
|
28
|
+
export function isStripeConfigured(): boolean {
|
|
29
|
+
return stripe !== null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Verify Stripe webhook signature.
|
|
34
|
+
* Returns the event if valid, throws an error if invalid.
|
|
35
|
+
*/
|
|
36
|
+
export function verifyWebhookSignature(payload: string | Buffer, signature: string): Stripe.Event {
|
|
37
|
+
if (!env.STRIPE_WEBHOOK_SECRET) {
|
|
38
|
+
throw new Error('Stripe webhook secret is not configured');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return getStripe().webhooks.constructEvent(payload, signature, env.STRIPE_WEBHOOK_SECRET);
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { Database } from '../../shared/types';
|
|
3
|
+
import { env } from './env';
|
|
4
|
+
|
|
5
|
+
// Server-side Supabase client with service role key
|
|
6
|
+
// This bypasses RLS - use only for admin operations
|
|
7
|
+
export const supabaseAdmin: SupabaseClient<Database> = createClient<Database>(
|
|
8
|
+
env.SUPABASE_URL,
|
|
9
|
+
env.SUPABASE_SERVICE_ROLE_KEY,
|
|
10
|
+
{
|
|
11
|
+
auth: {
|
|
12
|
+
autoRefreshToken: false,
|
|
13
|
+
persistSession: false,
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
);
|
|
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.
|
|
21
|
+
export function createSupabaseClient(accessToken: string): SupabaseClient<Database> {
|
|
22
|
+
return createClient<Database>(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
|
23
|
+
global: {
|
|
24
|
+
headers: {
|
|
25
|
+
Authorization: `Bearer ${accessToken}`,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
auth: {
|
|
29
|
+
autoRefreshToken: false,
|
|
30
|
+
persistSession: false,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create a fresh client for auth operations (signInWithPassword, etc.)
|
|
36
|
+
// IMPORTANT: Never use supabaseAdmin for signInWithPassword — it stores the
|
|
37
|
+
// user's session on the singleton, contaminating all subsequent admin queries
|
|
38
|
+
// with the user's "authenticated" role instead of the "service_role" key.
|
|
39
|
+
export function createAuthClient(): SupabaseClient<Database> {
|
|
40
|
+
return createClient<Database>(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
|
41
|
+
auth: {
|
|
42
|
+
autoRefreshToken: false,
|
|
43
|
+
persistSession: false,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper to get Supabase client from request headers
|
|
49
|
+
export function getSupabaseClient(authHeader: string | undefined): SupabaseClient<Database> {
|
|
50
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
51
|
+
// No valid auth header — return an anon client so RLS is enforced.
|
|
52
|
+
// Routes must still reject unauthenticated requests via c.get('user'),
|
|
53
|
+
// but this ensures any accidental query falls under anon RLS policies
|
|
54
|
+
// rather than bypassing them entirely with the service role key.
|
|
55
|
+
return createClient<Database>(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
|
56
|
+
auth: {
|
|
57
|
+
autoRefreshToken: false,
|
|
58
|
+
persistSession: false,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const token = authHeader.replace('Bearer ', '');
|
|
64
|
+
return createSupabaseClient(token);
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { Context, Next } from 'hono';
|
|
3
|
+
import type { PlanId } from '../../shared/pricing';
|
|
4
|
+
import { planIdFromStripePriceId } from '../../shared/pricing';
|
|
5
|
+
import { env } from '../lib/env';
|
|
6
|
+
import { isStripeConfigured } from '../lib/stripe';
|
|
7
|
+
import { supabaseAdmin } from '../lib/supabase';
|
|
8
|
+
import type { HonoVariables } from '../types';
|
|
9
|
+
|
|
10
|
+
// biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
|
|
11
|
+
const adminDb = supabaseAdmin as SupabaseClient<any>;
|
|
12
|
+
|
|
13
|
+
const planHierarchy: Record<PlanId, number> = {
|
|
14
|
+
free: 0,
|
|
15
|
+
starter: 1,
|
|
16
|
+
pro: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hono middleware that gates API routes behind a minimum plan.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* myRoutes.use('/premium-endpoint', requirePlan('pro'));
|
|
24
|
+
* myRoutes.get('/starter-feature', requirePlan('starter'), handler);
|
|
25
|
+
*/
|
|
26
|
+
export function requirePlan(minimumPlan: PlanId) {
|
|
27
|
+
return async (c: Context<{ Variables: HonoVariables }>, next: Next) => {
|
|
28
|
+
// If billing isn't configured, allow all requests (dev mode / self-hosted)
|
|
29
|
+
if (!isStripeConfigured()) {
|
|
30
|
+
await next();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const user = c.get('user');
|
|
35
|
+
if (!user) {
|
|
36
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Free plan requires no subscription check
|
|
40
|
+
if (minimumPlan === 'free') {
|
|
41
|
+
await next();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find customer for this user
|
|
46
|
+
const { data: customer } = await adminDb
|
|
47
|
+
.from('customers')
|
|
48
|
+
.select('id')
|
|
49
|
+
.eq('user_id', user.id)
|
|
50
|
+
.is('organization_id', null)
|
|
51
|
+
.maybeSingle();
|
|
52
|
+
|
|
53
|
+
if (!customer) {
|
|
54
|
+
return c.json({ error: 'Upgrade required', requiredPlan: minimumPlan }, 403);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Find active subscription
|
|
58
|
+
const { data: subscription } = await adminDb
|
|
59
|
+
.from('subscriptions')
|
|
60
|
+
.select('stripe_price_id, status')
|
|
61
|
+
.eq('customer_id', customer.id)
|
|
62
|
+
.in('status', ['active', 'trialing'])
|
|
63
|
+
.maybeSingle();
|
|
64
|
+
|
|
65
|
+
if (!subscription) {
|
|
66
|
+
return c.json({ error: 'Upgrade required', requiredPlan: minimumPlan }, 403);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const currentPlan = planIdFromStripePriceId(subscription.stripe_price_id, {
|
|
70
|
+
starter: env.STRIPE_PRICE_STARTER,
|
|
71
|
+
pro: env.STRIPE_PRICE_PRO,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (planHierarchy[currentPlan] < planHierarchy[minimumPlan]) {
|
|
75
|
+
return c.json({ error: 'Upgrade required', requiredPlan: minimumPlan, currentPlan }, 403);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await next();
|
|
79
|
+
};
|
|
80
|
+
}
|