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,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic billing webhook handler.
|
|
3
|
+
*
|
|
4
|
+
* Accepts webhooks at /api/webhooks/billing from any configured billing provider
|
|
5
|
+
* (Stripe, Paddle, Polar). The active provider verifies the signature and
|
|
6
|
+
* normalizes the event, and this handler updates subscriptions in the database.
|
|
7
|
+
*
|
|
8
|
+
* The Stripe-specific webhook at /api/webhooks/stripe is kept for backward
|
|
9
|
+
* compatibility with existing deployments.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
13
|
+
import { Hono } from 'hono';
|
|
14
|
+
import { getBillingProvider, isBillingConfigured } from '../../billing';
|
|
15
|
+
import { paymentFailedEmail, subscriptionConfirmEmail } from '../../emails/templates';
|
|
16
|
+
import { sendEmail } from '../../lib/email';
|
|
17
|
+
import { logger } from '../../lib/logger';
|
|
18
|
+
import { supabaseAdmin } from '../../lib/supabase';
|
|
19
|
+
import type { HonoVariables } from '../../types';
|
|
20
|
+
|
|
21
|
+
// Helper to access tables not yet in generated types
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
|
|
23
|
+
const adminDb = supabaseAdmin as SupabaseClient<any>;
|
|
24
|
+
|
|
25
|
+
const billingWebhookRoutes = new Hono<{ Variables: HonoVariables }>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Handle billing webhook events from the active provider.
|
|
29
|
+
*
|
|
30
|
+
* IMPORTANT: This endpoint must receive the raw body for signature verification.
|
|
31
|
+
* The body parser middleware should be skipped for this route.
|
|
32
|
+
*/
|
|
33
|
+
billingWebhookRoutes.post('/', async (c) => {
|
|
34
|
+
if (!isBillingConfigured()) {
|
|
35
|
+
return c.json({ error: 'Billing is not configured' }, 503);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const provider = getBillingProvider();
|
|
39
|
+
|
|
40
|
+
// Each provider uses different signature headers:
|
|
41
|
+
// - Stripe: stripe-signature
|
|
42
|
+
// - Paddle: paddle-signature
|
|
43
|
+
// - Polar: webhook-id + webhook-timestamp + webhook-signature (combined below)
|
|
44
|
+
let signature = '';
|
|
45
|
+
switch (provider.type) {
|
|
46
|
+
case 'stripe':
|
|
47
|
+
signature = c.req.header('stripe-signature') ?? '';
|
|
48
|
+
break;
|
|
49
|
+
case 'paddle':
|
|
50
|
+
signature = c.req.header('paddle-signature') ?? '';
|
|
51
|
+
break;
|
|
52
|
+
case 'polar': {
|
|
53
|
+
// Polar uses three headers; combine them into a single string for the provider
|
|
54
|
+
const webhookId = c.req.header('webhook-id') ?? '';
|
|
55
|
+
const webhookTimestamp = c.req.header('webhook-timestamp') ?? '';
|
|
56
|
+
const webhookSignature = c.req.header('webhook-signature') ?? '';
|
|
57
|
+
signature = `${webhookId}.${webhookTimestamp}.${webhookSignature}`;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!signature || signature === '..') {
|
|
63
|
+
logger.warn({ provider: provider.type }, 'Billing webhook received without signature');
|
|
64
|
+
return c.json({ error: 'Missing webhook signature' }, 400);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let event: { type: string; data: Record<string, unknown> };
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const rawBody = await c.req.text();
|
|
71
|
+
event = await provider.handleWebhook({ body: rawBody, signature });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
74
|
+
logger.error(
|
|
75
|
+
{ error: message, provider: provider.type },
|
|
76
|
+
'Billing webhook signature verification failed'
|
|
77
|
+
);
|
|
78
|
+
return c.json({ error: 'Invalid signature' }, 400);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
logger.info({ type: event.type, provider: provider.type }, 'Processing billing webhook event');
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
switch (event.type) {
|
|
85
|
+
case 'checkout.completed':
|
|
86
|
+
await handleCheckoutCompleted(event.data);
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case 'subscription.created':
|
|
90
|
+
case 'subscription.updated':
|
|
91
|
+
await handleSubscriptionUpsert(event.data);
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case 'subscription.deleted':
|
|
95
|
+
await handleSubscriptionDeleted(event.data);
|
|
96
|
+
break;
|
|
97
|
+
|
|
98
|
+
case 'payment.failed':
|
|
99
|
+
await handlePaymentFailed(event.data);
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
default:
|
|
103
|
+
logger.debug({ type: event.type }, 'Unhandled billing webhook event type');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return c.json({ received: true });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
109
|
+
logger.error(
|
|
110
|
+
{ error: message, eventType: event.type, provider: provider.type },
|
|
111
|
+
'Failed to process billing webhook event'
|
|
112
|
+
);
|
|
113
|
+
return c.json({ error: 'Webhook handler failed' }, 500);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// EVENT HANDLERS (provider-agnostic)
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Handle a completed checkout from any provider.
|
|
123
|
+
* The data contains normalized fields: customerId, subscriptionId, customerEmail.
|
|
124
|
+
*/
|
|
125
|
+
async function handleCheckoutCompleted(data: Record<string, unknown>): Promise<void> {
|
|
126
|
+
const customerId = data.customerId as string | null;
|
|
127
|
+
const subscriptionId = data.subscriptionId as string | null;
|
|
128
|
+
const customerEmail = data.customerEmail as string | null;
|
|
129
|
+
|
|
130
|
+
if (!customerId || !subscriptionId) {
|
|
131
|
+
logger.debug('Checkout completed event missing customerId or subscriptionId');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
logger.info({ customerId, subscriptionId }, 'Checkout completed');
|
|
136
|
+
|
|
137
|
+
// Find customer in our database
|
|
138
|
+
const { data: customer, error: customerError } = await adminDb
|
|
139
|
+
.from('customers')
|
|
140
|
+
.select('id')
|
|
141
|
+
.eq('stripe_customer_id', customerId)
|
|
142
|
+
.single();
|
|
143
|
+
|
|
144
|
+
if (customerError || !customer) {
|
|
145
|
+
logger.error({ customerId, error: customerError }, 'Customer not found for checkout');
|
|
146
|
+
throw new Error(`Customer not found: ${customerId}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// If the provider sent subscription data inline, upsert it
|
|
150
|
+
if (data.status && data.priceId) {
|
|
151
|
+
await upsertSubscriptionFromData(customer.id, data);
|
|
152
|
+
} else {
|
|
153
|
+
// For providers like Stripe that require a separate fetch, get the full subscription
|
|
154
|
+
try {
|
|
155
|
+
const provider = getBillingProvider();
|
|
156
|
+
const subInfo = await provider.getSubscription({ customerId, subscriptionId });
|
|
157
|
+
if (subInfo) {
|
|
158
|
+
await upsertSubscriptionFromInfo(customer.id, subscriptionId, subInfo);
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.warn({ error, subscriptionId }, 'Could not fetch subscription details after checkout');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Send confirmation email
|
|
166
|
+
if (customerEmail) {
|
|
167
|
+
try {
|
|
168
|
+
const provider = getBillingProvider();
|
|
169
|
+
const subInfo = await provider.getSubscription({ customerId, subscriptionId });
|
|
170
|
+
if (subInfo) {
|
|
171
|
+
const template = subscriptionConfirmEmail({
|
|
172
|
+
planName: subInfo.product.name,
|
|
173
|
+
amount: `$${(subInfo.price.unitAmount / 100).toFixed(2)}`,
|
|
174
|
+
interval: subInfo.price.interval,
|
|
175
|
+
});
|
|
176
|
+
sendEmail({ to: customerEmail, ...template }).catch((err) => {
|
|
177
|
+
logger.error({ error: err }, 'Failed to send subscription confirmation email');
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} catch (emailErr) {
|
|
181
|
+
logger.error({ error: emailErr }, 'Failed to prepare subscription confirmation email');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Handle subscription create/update from any provider.
|
|
188
|
+
*/
|
|
189
|
+
async function handleSubscriptionUpsert(data: Record<string, unknown>): Promise<void> {
|
|
190
|
+
const customerId = data.customerId as string | null;
|
|
191
|
+
const subscriptionId = data.subscriptionId as string | null;
|
|
192
|
+
|
|
193
|
+
if (!customerId || !subscriptionId) {
|
|
194
|
+
logger.warn('Subscription event missing customerId or subscriptionId');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logger.info({ subscriptionId, status: data.status }, 'Subscription upserted');
|
|
199
|
+
|
|
200
|
+
// Find customer in our database
|
|
201
|
+
const { data: customer, error: customerError } = await adminDb
|
|
202
|
+
.from('customers')
|
|
203
|
+
.select('id')
|
|
204
|
+
.eq('stripe_customer_id', customerId)
|
|
205
|
+
.single();
|
|
206
|
+
|
|
207
|
+
if (customerError || !customer) {
|
|
208
|
+
logger.error({ customerId, error: customerError }, 'Customer not found for subscription');
|
|
209
|
+
throw new Error(`Customer not found: ${customerId}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await upsertSubscriptionFromData(customer.id, data);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Handle subscription deletion/cancellation.
|
|
217
|
+
*/
|
|
218
|
+
async function handleSubscriptionDeleted(data: Record<string, unknown>): Promise<void> {
|
|
219
|
+
const subscriptionId = data.subscriptionId as string | null;
|
|
220
|
+
|
|
221
|
+
if (!subscriptionId) {
|
|
222
|
+
logger.warn('Subscription delete event missing subscriptionId');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
logger.info({ subscriptionId }, 'Subscription deleted');
|
|
227
|
+
|
|
228
|
+
const { error } = await adminDb
|
|
229
|
+
.from('subscriptions')
|
|
230
|
+
.update({
|
|
231
|
+
status: 'canceled',
|
|
232
|
+
canceled_at: new Date().toISOString(),
|
|
233
|
+
})
|
|
234
|
+
.eq('stripe_subscription_id', subscriptionId);
|
|
235
|
+
|
|
236
|
+
if (error) {
|
|
237
|
+
logger.error({ error, subscriptionId }, 'Failed to update canceled subscription');
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Handle a failed payment.
|
|
244
|
+
*/
|
|
245
|
+
async function handlePaymentFailed(data: Record<string, unknown>): Promise<void> {
|
|
246
|
+
const subscriptionId = data.subscriptionId as string | null;
|
|
247
|
+
|
|
248
|
+
if (!subscriptionId) {
|
|
249
|
+
logger.debug('Payment failed event without subscription');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
logger.warn({ subscriptionId }, 'Payment failed for subscription');
|
|
254
|
+
|
|
255
|
+
const { error } = await adminDb
|
|
256
|
+
.from('subscriptions')
|
|
257
|
+
.update({ status: 'past_due' })
|
|
258
|
+
.eq('stripe_subscription_id', subscriptionId);
|
|
259
|
+
|
|
260
|
+
if (error) {
|
|
261
|
+
logger.error({ error, subscriptionId }, 'Failed to update subscription status to past_due');
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Send payment failed email
|
|
266
|
+
try {
|
|
267
|
+
const { data: sub } = await adminDb
|
|
268
|
+
.from('subscriptions')
|
|
269
|
+
.select('customer_id')
|
|
270
|
+
.eq('stripe_subscription_id', subscriptionId)
|
|
271
|
+
.single();
|
|
272
|
+
|
|
273
|
+
if (sub) {
|
|
274
|
+
const { data: customer } = await adminDb
|
|
275
|
+
.from('customers')
|
|
276
|
+
.select('email')
|
|
277
|
+
.eq('id', sub.customer_id)
|
|
278
|
+
.single();
|
|
279
|
+
|
|
280
|
+
if (customer?.email) {
|
|
281
|
+
const baseUrl = process.env.SITE_URL || 'http://localhost:5173';
|
|
282
|
+
const template = paymentFailedEmail({
|
|
283
|
+
planName: 'your',
|
|
284
|
+
billingUrl: `${baseUrl}/settings/billing`,
|
|
285
|
+
});
|
|
286
|
+
sendEmail({ to: customer.email, ...template }).catch((err) => {
|
|
287
|
+
logger.error({ error: err }, 'Failed to send payment failed email');
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (emailErr) {
|
|
292
|
+
logger.error({ error: emailErr }, 'Failed to prepare payment failed email');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ============================================================================
|
|
297
|
+
// HELPERS
|
|
298
|
+
// ============================================================================
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Upsert a subscription record using normalized webhook event data.
|
|
302
|
+
* Used when the webhook payload contains all needed subscription fields.
|
|
303
|
+
*/
|
|
304
|
+
async function upsertSubscriptionFromData(
|
|
305
|
+
dbCustomerId: string,
|
|
306
|
+
data: Record<string, unknown>
|
|
307
|
+
): Promise<void> {
|
|
308
|
+
const subscriptionId = data.subscriptionId as string;
|
|
309
|
+
const priceId = data.priceId as string;
|
|
310
|
+
|
|
311
|
+
if (!priceId) {
|
|
312
|
+
throw new Error('Subscription event has no priceId');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const subscriptionData = {
|
|
316
|
+
customer_id: dbCustomerId,
|
|
317
|
+
stripe_subscription_id: subscriptionId,
|
|
318
|
+
stripe_price_id: priceId,
|
|
319
|
+
status: (data.status as string) ?? 'active',
|
|
320
|
+
current_period_start: (data.currentPeriodStart as string) ?? null,
|
|
321
|
+
current_period_end: (data.currentPeriodEnd as string) ?? null,
|
|
322
|
+
cancel_at_period_end: (data.cancelAtPeriodEnd as boolean) ?? false,
|
|
323
|
+
canceled_at: (data.canceledAt as string) ?? null,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const { error } = await adminDb.from('subscriptions').upsert(subscriptionData, {
|
|
327
|
+
onConflict: 'stripe_subscription_id',
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (error) {
|
|
331
|
+
logger.error({ error, subscriptionId }, 'Failed to upsert subscription');
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
logger.info({ subscriptionId, status: data.status }, 'Subscription record upserted');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Upsert a subscription record using SubscriptionInfo from the provider API.
|
|
340
|
+
* Used when we need to fetch subscription details separately (e.g., after checkout).
|
|
341
|
+
*/
|
|
342
|
+
async function upsertSubscriptionFromInfo(
|
|
343
|
+
dbCustomerId: string,
|
|
344
|
+
subscriptionId: string,
|
|
345
|
+
info: {
|
|
346
|
+
status: string;
|
|
347
|
+
priceId: string;
|
|
348
|
+
currentPeriodEnd: string | null;
|
|
349
|
+
cancelAtPeriodEnd: boolean;
|
|
350
|
+
}
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
const subscriptionData = {
|
|
353
|
+
customer_id: dbCustomerId,
|
|
354
|
+
stripe_subscription_id: subscriptionId,
|
|
355
|
+
stripe_price_id: info.priceId,
|
|
356
|
+
status: info.status,
|
|
357
|
+
current_period_end: info.currentPeriodEnd,
|
|
358
|
+
cancel_at_period_end: info.cancelAtPeriodEnd,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const { error } = await adminDb.from('subscriptions').upsert(subscriptionData, {
|
|
362
|
+
onConflict: 'stripe_subscription_id',
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (error) {
|
|
366
|
+
logger.error({ error, subscriptionId }, 'Failed to upsert subscription from provider info');
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
logger.info(
|
|
371
|
+
{ subscriptionId, status: info.status },
|
|
372
|
+
'Subscription record upserted from provider'
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export { billingWebhookRoutes };
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import type Stripe from 'stripe';
|
|
4
|
+
import { paymentFailedEmail, subscriptionConfirmEmail } from '../../emails/templates';
|
|
5
|
+
import { sendEmail } from '../../lib/email';
|
|
6
|
+
import { logger } from '../../lib/logger';
|
|
7
|
+
import { getStripe, verifyWebhookSignature } from '../../lib/stripe';
|
|
8
|
+
import { supabaseAdmin } from '../../lib/supabase';
|
|
9
|
+
import type { HonoVariables } from '../../types';
|
|
10
|
+
|
|
11
|
+
// Helper to access tables not yet in generated types
|
|
12
|
+
// biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
|
|
13
|
+
const adminDb = supabaseAdmin as SupabaseClient<any>;
|
|
14
|
+
|
|
15
|
+
const stripeWebhookRoutes = new Hono<{ Variables: HonoVariables }>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle Stripe webhook events
|
|
19
|
+
*
|
|
20
|
+
* IMPORTANT: This endpoint must receive the raw body for signature verification.
|
|
21
|
+
* The body parser middleware should be skipped for this route.
|
|
22
|
+
*/
|
|
23
|
+
stripeWebhookRoutes.post('/', async (c) => {
|
|
24
|
+
const signature = c.req.header('stripe-signature');
|
|
25
|
+
|
|
26
|
+
if (!signature) {
|
|
27
|
+
logger.warn('Stripe webhook received without signature');
|
|
28
|
+
return c.json({ error: 'Missing stripe-signature header' }, 400);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let event: Stripe.Event;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Get raw body for signature verification
|
|
35
|
+
const rawBody = await c.req.text();
|
|
36
|
+
event = verifyWebhookSignature(rawBody, signature);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
39
|
+
logger.error({ error: message }, 'Stripe webhook signature verification failed');
|
|
40
|
+
return c.json({ error: 'Invalid signature' }, 400);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
logger.info({ type: event.type, id: event.id }, 'Processing Stripe webhook event');
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
switch (event.type) {
|
|
47
|
+
case 'checkout.session.completed':
|
|
48
|
+
await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
|
|
49
|
+
break;
|
|
50
|
+
|
|
51
|
+
case 'customer.subscription.created':
|
|
52
|
+
case 'customer.subscription.updated':
|
|
53
|
+
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case 'customer.subscription.deleted':
|
|
57
|
+
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case 'invoice.payment_failed':
|
|
61
|
+
await handlePaymentFailed(event.data.object as Stripe.Invoice);
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
logger.debug({ type: event.type }, 'Unhandled webhook event type');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return c.json({ received: true });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
71
|
+
logger.error({ error: message, eventType: event.type }, 'Failed to process webhook event');
|
|
72
|
+
return c.json({ error: 'Webhook handler failed' }, 500);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// EVENT HANDLERS
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
|
|
81
|
+
if (session.mode !== 'subscription') {
|
|
82
|
+
logger.debug('Ignoring non-subscription checkout session');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const subscriptionId = session.subscription as string;
|
|
87
|
+
const customerId = session.customer as string;
|
|
88
|
+
|
|
89
|
+
logger.info({ subscriptionId, customerId }, 'Checkout session completed');
|
|
90
|
+
|
|
91
|
+
// Fetch full subscription details
|
|
92
|
+
const stripe = getStripe();
|
|
93
|
+
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
94
|
+
|
|
95
|
+
// Find customer in our database
|
|
96
|
+
const { data: customer, error: customerError } = await adminDb
|
|
97
|
+
.from('customers')
|
|
98
|
+
.select('id')
|
|
99
|
+
.eq('stripe_customer_id', customerId)
|
|
100
|
+
.single();
|
|
101
|
+
|
|
102
|
+
if (customerError || !customer) {
|
|
103
|
+
logger.error({ customerId, error: customerError }, 'Customer not found for subscription');
|
|
104
|
+
throw new Error(`Customer not found: ${customerId}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Create or update subscription record
|
|
108
|
+
await upsertSubscription(customer.id, subscription);
|
|
109
|
+
|
|
110
|
+
// Send subscription confirmation email
|
|
111
|
+
const stripeObj = getStripe();
|
|
112
|
+
try {
|
|
113
|
+
const price = await stripeObj.prices.retrieve(subscription.items.data[0]?.price.id, {
|
|
114
|
+
expand: ['product'],
|
|
115
|
+
});
|
|
116
|
+
const product = price.product as Stripe.Product;
|
|
117
|
+
const customerEmail = (session as Stripe.Checkout.Session).customer_details?.email;
|
|
118
|
+
|
|
119
|
+
if (customerEmail) {
|
|
120
|
+
const template = subscriptionConfirmEmail({
|
|
121
|
+
planName: product.name,
|
|
122
|
+
amount: `$${((price.unit_amount ?? 0) / 100).toFixed(2)}`,
|
|
123
|
+
interval: price.recurring?.interval ?? 'month',
|
|
124
|
+
});
|
|
125
|
+
sendEmail({ to: customerEmail, ...template }).catch((err) => {
|
|
126
|
+
logger.error({ error: err }, 'Failed to send subscription confirmation email');
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} catch (emailErr) {
|
|
130
|
+
logger.error({ error: emailErr }, 'Failed to prepare subscription confirmation email');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
|
135
|
+
const customerId = subscription.customer as string;
|
|
136
|
+
|
|
137
|
+
logger.info(
|
|
138
|
+
{ subscriptionId: subscription.id, status: subscription.status },
|
|
139
|
+
'Subscription updated'
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Find customer in our database
|
|
143
|
+
const { data: customer, error: customerError } = await adminDb
|
|
144
|
+
.from('customers')
|
|
145
|
+
.select('id')
|
|
146
|
+
.eq('stripe_customer_id', customerId)
|
|
147
|
+
.single();
|
|
148
|
+
|
|
149
|
+
if (customerError || !customer) {
|
|
150
|
+
logger.error(
|
|
151
|
+
{ customerId, error: customerError },
|
|
152
|
+
'Customer not found for subscription update'
|
|
153
|
+
);
|
|
154
|
+
throw new Error(`Customer not found: ${customerId}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await upsertSubscription(customer.id, subscription);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
|
161
|
+
logger.info({ subscriptionId: subscription.id }, 'Subscription deleted');
|
|
162
|
+
|
|
163
|
+
const { error } = await adminDb
|
|
164
|
+
.from('subscriptions')
|
|
165
|
+
.update({
|
|
166
|
+
status: 'canceled',
|
|
167
|
+
canceled_at: new Date().toISOString(),
|
|
168
|
+
})
|
|
169
|
+
.eq('stripe_subscription_id', subscription.id);
|
|
170
|
+
|
|
171
|
+
if (error) {
|
|
172
|
+
logger.error(
|
|
173
|
+
{ error, subscriptionId: subscription.id },
|
|
174
|
+
'Failed to update canceled subscription'
|
|
175
|
+
);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function handlePaymentFailed(invoice: Stripe.Invoice) {
|
|
181
|
+
// In Stripe 20.x, subscription is accessed via parent property
|
|
182
|
+
const invoiceWithSub = invoice as unknown as {
|
|
183
|
+
subscription?: string | { id: string } | null;
|
|
184
|
+
};
|
|
185
|
+
const subscriptionId =
|
|
186
|
+
typeof invoiceWithSub.subscription === 'string'
|
|
187
|
+
? invoiceWithSub.subscription
|
|
188
|
+
: invoiceWithSub.subscription?.id;
|
|
189
|
+
|
|
190
|
+
if (!subscriptionId) {
|
|
191
|
+
logger.debug('Ignoring invoice without subscription');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
logger.warn({ subscriptionId, invoiceId: invoice.id }, 'Payment failed for subscription');
|
|
196
|
+
|
|
197
|
+
const { error } = await adminDb
|
|
198
|
+
.from('subscriptions')
|
|
199
|
+
.update({ status: 'past_due' })
|
|
200
|
+
.eq('stripe_subscription_id', subscriptionId);
|
|
201
|
+
|
|
202
|
+
if (error) {
|
|
203
|
+
logger.error({ error, subscriptionId }, 'Failed to update subscription status to past_due');
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Send payment failed email
|
|
208
|
+
try {
|
|
209
|
+
const stripe = getStripe();
|
|
210
|
+
const sub = await stripe.subscriptions.retrieve(subscriptionId, {
|
|
211
|
+
expand: ['items.data.price.product'],
|
|
212
|
+
});
|
|
213
|
+
const customerObj = await stripe.customers.retrieve(sub.customer as string);
|
|
214
|
+
if (customerObj && !customerObj.deleted && customerObj.email) {
|
|
215
|
+
const product = sub.items.data[0]?.price.product as Stripe.Product;
|
|
216
|
+
const baseUrl = process.env.SITE_URL || 'http://localhost:5173';
|
|
217
|
+
const template = paymentFailedEmail({
|
|
218
|
+
planName: product?.name ?? 'your',
|
|
219
|
+
billingUrl: `${baseUrl}/settings/billing`,
|
|
220
|
+
});
|
|
221
|
+
sendEmail({ to: customerObj.email, ...template }).catch((err) => {
|
|
222
|
+
logger.error({ error: err }, 'Failed to send payment failed email');
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} catch (emailErr) {
|
|
226
|
+
logger.error({ error: emailErr }, 'Failed to prepare payment failed email');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// HELPERS
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
async function upsertSubscription(customerId: string, subscription: Stripe.Subscription) {
|
|
235
|
+
const priceId = subscription.items.data[0]?.price.id;
|
|
236
|
+
|
|
237
|
+
if (!priceId) {
|
|
238
|
+
throw new Error('Subscription has no price');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const currentPeriodStart = (subscription as unknown as { current_period_start?: number })
|
|
242
|
+
.current_period_start;
|
|
243
|
+
const currentPeriodEnd = (subscription as unknown as { current_period_end?: number })
|
|
244
|
+
.current_period_end;
|
|
245
|
+
|
|
246
|
+
const subscriptionData = {
|
|
247
|
+
customer_id: customerId,
|
|
248
|
+
stripe_subscription_id: subscription.id,
|
|
249
|
+
stripe_price_id: priceId,
|
|
250
|
+
status: subscription.status,
|
|
251
|
+
current_period_start: currentPeriodStart
|
|
252
|
+
? new Date(currentPeriodStart * 1000).toISOString()
|
|
253
|
+
: null,
|
|
254
|
+
current_period_end: currentPeriodEnd ? new Date(currentPeriodEnd * 1000).toISOString() : null,
|
|
255
|
+
cancel_at_period_end: subscription.cancel_at_period_end,
|
|
256
|
+
canceled_at: subscription.canceled_at
|
|
257
|
+
? new Date(subscription.canceled_at * 1000).toISOString()
|
|
258
|
+
: null,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const { error } = await adminDb.from('subscriptions').upsert(subscriptionData, {
|
|
262
|
+
onConflict: 'stripe_subscription_id',
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (error) {
|
|
266
|
+
logger.error({ error, subscriptionId: subscription.id }, 'Failed to upsert subscription');
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
logger.info(
|
|
271
|
+
{ subscriptionId: subscription.id, status: subscription.status },
|
|
272
|
+
'Subscription record upserted'
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export { stripeWebhookRoutes };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { User } from '@supabase/supabase-js';
|
|
2
|
+
import type { getSupabaseClient } from './lib/supabase';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared Hono context variables for authenticated routes.
|
|
6
|
+
* Used to type the user and supabase client available via c.get().
|
|
7
|
+
*/
|
|
8
|
+
export type HonoVariables = {
|
|
9
|
+
user: User | null;
|
|
10
|
+
supabase: ReturnType<typeof getSupabaseClient>;
|
|
11
|
+
};
|