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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paddle billing provider implementation.
|
|
3
|
+
*
|
|
4
|
+
* Uses Paddle API v2 via fetch (no SDK dependency). Supports both sandbox
|
|
5
|
+
* and production environments via PADDLE_ENVIRONMENT env var.
|
|
6
|
+
*
|
|
7
|
+
* Paddle API docs: https://developer.paddle.com/api-reference/overview
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHmac } from 'node:crypto';
|
|
11
|
+
import { env } from '../../lib/env';
|
|
12
|
+
import { logger } from '../../lib/logger';
|
|
13
|
+
import type { BillingProvider, CheckoutResult, PortalResult, SubscriptionInfo } from '../provider';
|
|
14
|
+
|
|
15
|
+
// Paddle API v2 base URLs
|
|
16
|
+
const PADDLE_API_URLS = {
|
|
17
|
+
sandbox: 'https://sandbox-api.paddle.com',
|
|
18
|
+
production: 'https://api.paddle.com',
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export class PaddleProvider implements BillingProvider {
|
|
22
|
+
readonly type = 'paddle' as const;
|
|
23
|
+
|
|
24
|
+
private get apiKey(): string {
|
|
25
|
+
if (!env.PADDLE_API_KEY) {
|
|
26
|
+
throw new Error('Paddle is not configured. Set PADDLE_API_KEY in your environment.');
|
|
27
|
+
}
|
|
28
|
+
return env.PADDLE_API_KEY;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private get baseUrl(): string {
|
|
32
|
+
const environment = env.PADDLE_ENVIRONMENT ?? 'sandbox';
|
|
33
|
+
return PADDLE_API_URLS[environment];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
isConfigured(): boolean {
|
|
37
|
+
return !!env.PADDLE_API_KEY;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async createCheckout(params: {
|
|
41
|
+
customerId: string;
|
|
42
|
+
priceId: string;
|
|
43
|
+
successUrl: string;
|
|
44
|
+
cancelUrl: string;
|
|
45
|
+
metadata: Record<string, string>;
|
|
46
|
+
}): Promise<CheckoutResult> {
|
|
47
|
+
// Paddle uses Transactions API to create checkout sessions
|
|
48
|
+
const response = await this.request('POST', '/transactions', {
|
|
49
|
+
customer_id: params.customerId,
|
|
50
|
+
items: [{ price_id: params.priceId, quantity: 1 }],
|
|
51
|
+
checkout: {
|
|
52
|
+
url: params.successUrl,
|
|
53
|
+
},
|
|
54
|
+
custom_data: params.metadata,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: Paddle API response shape
|
|
58
|
+
const data = response.data as any;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
url: (data.checkout?.url as string) ?? '',
|
|
62
|
+
sessionId: data.id as string,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async createPortalSession(params: {
|
|
67
|
+
customerId: string;
|
|
68
|
+
returnUrl: string;
|
|
69
|
+
}): Promise<PortalResult> {
|
|
70
|
+
// Paddle provides a hosted customer portal via the Customers API
|
|
71
|
+
const response = await this.request('POST', `/customers/${params.customerId}/portal-sessions`, {
|
|
72
|
+
return_url: params.returnUrl,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// SECURITY: The portal URL is a short-lived link from Paddle
|
|
76
|
+
// biome-ignore lint/suspicious/noExplicitAny: Paddle API response shape
|
|
77
|
+
const data = response.data as any;
|
|
78
|
+
return { url: (data.urls?.general?.overview as string) ?? '' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getSubscription(params: {
|
|
82
|
+
customerId: string;
|
|
83
|
+
subscriptionId: string;
|
|
84
|
+
}): Promise<SubscriptionInfo | null> {
|
|
85
|
+
try {
|
|
86
|
+
const response = await this.request(
|
|
87
|
+
'GET',
|
|
88
|
+
`/subscriptions/${params.subscriptionId}?include=next_transaction`
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// biome-ignore lint/suspicious/noExplicitAny: Paddle API response shape
|
|
92
|
+
const sub = response.data as any;
|
|
93
|
+
if (!sub) return null;
|
|
94
|
+
|
|
95
|
+
const item = sub.items?.[0];
|
|
96
|
+
const price = item?.price;
|
|
97
|
+
const product = price?.product;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
id: sub.id as string,
|
|
101
|
+
status: this.normalizePaddleStatus(String(sub.status ?? '')),
|
|
102
|
+
planId: this.resolvePlanId(String(price?.id ?? '')),
|
|
103
|
+
priceId: String(price?.id ?? ''),
|
|
104
|
+
currentPeriodEnd: (sub.current_billing_period?.ends_at as string) ?? null,
|
|
105
|
+
cancelAtPeriodEnd: sub.scheduled_change?.action === 'cancel',
|
|
106
|
+
price: {
|
|
107
|
+
unitAmount: Number.parseInt(String(price?.unit_price?.amount ?? '0'), 10),
|
|
108
|
+
currency: String(price?.unit_price?.currency_code ?? 'USD'),
|
|
109
|
+
interval: String(price?.billing_cycle?.interval ?? 'month'),
|
|
110
|
+
},
|
|
111
|
+
product: {
|
|
112
|
+
id: String(product?.id ?? ''),
|
|
113
|
+
name: String(product?.name ?? ''),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logger.error(
|
|
118
|
+
{ error, subscriptionId: params.subscriptionId },
|
|
119
|
+
'Failed to fetch Paddle subscription'
|
|
120
|
+
);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async handleWebhook(params: {
|
|
126
|
+
body: string;
|
|
127
|
+
signature: string;
|
|
128
|
+
}): Promise<{ type: string; data: Record<string, unknown> }> {
|
|
129
|
+
// Verify Paddle webhook signature
|
|
130
|
+
this.verifyWebhookSignature(params.body, params.signature);
|
|
131
|
+
|
|
132
|
+
const payload = JSON.parse(params.body);
|
|
133
|
+
const eventType = payload.event_type as string;
|
|
134
|
+
const normalized = this.normalizeEventType(eventType);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
type: normalized,
|
|
138
|
+
data: this.extractEventData(eventType, payload.data ?? {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async createCustomer(params: {
|
|
143
|
+
email: string;
|
|
144
|
+
name: string;
|
|
145
|
+
metadata: Record<string, string>;
|
|
146
|
+
}): Promise<string> {
|
|
147
|
+
const response = await this.request('POST', '/customers', {
|
|
148
|
+
email: params.email,
|
|
149
|
+
name: params.name,
|
|
150
|
+
custom_data: params.metadata,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return response.data.id as string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async deleteCustomer(_customerId: string): Promise<void> {
|
|
157
|
+
// Paddle does not support customer deletion via API.
|
|
158
|
+
// Customers can be archived but not removed. Log and no-op.
|
|
159
|
+
logger.info({ customerId: _customerId }, 'Paddle does not support customer deletion; skipping');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Private helpers
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** Make an authenticated request to the Paddle API v2. */
|
|
167
|
+
private async request(
|
|
168
|
+
method: string,
|
|
169
|
+
path: string,
|
|
170
|
+
body?: Record<string, unknown>
|
|
171
|
+
): Promise<{ data: Record<string, unknown> }> {
|
|
172
|
+
const url = `${this.baseUrl}${path}`;
|
|
173
|
+
|
|
174
|
+
const headers: Record<string, string> = {
|
|
175
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
176
|
+
'Content-Type': 'application/json',
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const options: RequestInit = { method, headers };
|
|
180
|
+
if (body && method !== 'GET') {
|
|
181
|
+
options.body = JSON.stringify(body);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const response = await fetch(url, options);
|
|
185
|
+
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
const errorBody = await response.text();
|
|
188
|
+
logger.error({ status: response.status, body: errorBody, path }, 'Paddle API request failed');
|
|
189
|
+
throw new Error(`Paddle API error (${response.status}): ${errorBody}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// biome-ignore lint/suspicious/noExplicitAny: Paddle API response shape varies
|
|
193
|
+
const json = (await response.json()) as any;
|
|
194
|
+
return { data: json.data ?? json };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Verify the Paddle webhook signature (Paddle Billing v2 format).
|
|
199
|
+
*
|
|
200
|
+
* Paddle v2 sends the signature in a header with format:
|
|
201
|
+
* `ts=<timestamp>;h1=<hmac_hex>`
|
|
202
|
+
*
|
|
203
|
+
* The HMAC is computed over `<timestamp>:<raw_body>` using the webhook secret.
|
|
204
|
+
*/
|
|
205
|
+
private verifyWebhookSignature(body: string, signatureHeader: string): void {
|
|
206
|
+
if (!env.PADDLE_WEBHOOK_SECRET) {
|
|
207
|
+
throw new Error('Paddle webhook secret is not configured');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Parse ts=...; h1=...
|
|
211
|
+
const parts = signatureHeader.split(';');
|
|
212
|
+
const tsEntry = parts.find((p) => p.trim().startsWith('ts='));
|
|
213
|
+
const h1Entry = parts.find((p) => p.trim().startsWith('h1='));
|
|
214
|
+
|
|
215
|
+
if (!tsEntry || !h1Entry) {
|
|
216
|
+
throw new Error('Invalid Paddle webhook signature format');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const timestamp = tsEntry.trim().slice(3);
|
|
220
|
+
const providedHash = h1Entry.trim().slice(3);
|
|
221
|
+
|
|
222
|
+
const payload = `${timestamp}:${body}`;
|
|
223
|
+
const expectedHash = createHmac('sha256', env.PADDLE_WEBHOOK_SECRET)
|
|
224
|
+
.update(payload)
|
|
225
|
+
.digest('hex');
|
|
226
|
+
|
|
227
|
+
if (expectedHash !== providedHash) {
|
|
228
|
+
throw new Error('Invalid Paddle webhook signature');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Map Paddle event types to normalized billing event names. */
|
|
233
|
+
private normalizeEventType(paddleType: string): string {
|
|
234
|
+
const map: Record<string, string> = {
|
|
235
|
+
'transaction.completed': 'checkout.completed',
|
|
236
|
+
'subscription.created': 'subscription.created',
|
|
237
|
+
'subscription.updated': 'subscription.updated',
|
|
238
|
+
'subscription.canceled': 'subscription.deleted',
|
|
239
|
+
'subscription.past_due': 'payment.failed',
|
|
240
|
+
'transaction.payment_failed': 'payment.failed',
|
|
241
|
+
};
|
|
242
|
+
return map[paddleType] ?? paddleType;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Map Paddle subscription status to a normalized string. */
|
|
246
|
+
private normalizePaddleStatus(paddleStatus: string): string {
|
|
247
|
+
const map: Record<string, string> = {
|
|
248
|
+
active: 'active',
|
|
249
|
+
paused: 'paused',
|
|
250
|
+
past_due: 'past_due',
|
|
251
|
+
canceled: 'canceled',
|
|
252
|
+
trialing: 'trialing',
|
|
253
|
+
};
|
|
254
|
+
return map[paddleStatus] ?? paddleStatus;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Map a Paddle price ID to a plan ID using configured env vars. */
|
|
258
|
+
private resolvePlanId(priceId: string): string {
|
|
259
|
+
if (priceId === env.PADDLE_PRICE_STARTER) return 'starter';
|
|
260
|
+
if (priceId === env.PADDLE_PRICE_PRO) return 'pro';
|
|
261
|
+
return 'free';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Extract relevant data from a Paddle webhook event payload. */
|
|
265
|
+
// biome-ignore lint/suspicious/noExplicitAny: Paddle webhook shapes vary by event type
|
|
266
|
+
private extractEventData(eventType: string, data: any): Record<string, unknown> {
|
|
267
|
+
switch (eventType) {
|
|
268
|
+
case 'transaction.completed':
|
|
269
|
+
return {
|
|
270
|
+
provider: 'paddle',
|
|
271
|
+
mode: 'subscription',
|
|
272
|
+
customerId: data.customer_id ?? null,
|
|
273
|
+
subscriptionId: data.subscription_id ?? null,
|
|
274
|
+
customerEmail: null, // Paddle transactions don't always include email
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
case 'subscription.created':
|
|
278
|
+
case 'subscription.updated': {
|
|
279
|
+
const item = data.items?.[0];
|
|
280
|
+
const priceId = item?.price?.id ?? '';
|
|
281
|
+
return {
|
|
282
|
+
provider: 'paddle',
|
|
283
|
+
subscriptionId: data.id,
|
|
284
|
+
customerId: data.customer_id ?? null,
|
|
285
|
+
status: this.normalizePaddleStatus(data.status ?? ''),
|
|
286
|
+
priceId,
|
|
287
|
+
currentPeriodStart: data.current_billing_period?.starts_at ?? null,
|
|
288
|
+
currentPeriodEnd: data.current_billing_period?.ends_at ?? null,
|
|
289
|
+
cancelAtPeriodEnd: data.scheduled_change?.action === 'cancel',
|
|
290
|
+
canceledAt: data.canceled_at ?? null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
case 'subscription.canceled':
|
|
295
|
+
return {
|
|
296
|
+
provider: 'paddle',
|
|
297
|
+
subscriptionId: data.id,
|
|
298
|
+
customerId: data.customer_id ?? null,
|
|
299
|
+
status: 'canceled',
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
case 'subscription.past_due':
|
|
303
|
+
case 'transaction.payment_failed':
|
|
304
|
+
return {
|
|
305
|
+
provider: 'paddle',
|
|
306
|
+
subscriptionId: data.subscription_id ?? data.id ?? null,
|
|
307
|
+
invoiceId: data.id ?? null,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
default:
|
|
311
|
+
return { provider: 'paddle', raw: data };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polar billing provider implementation.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Polar API via fetch (no SDK dependency). Polar is a modern billing
|
|
5
|
+
* platform for developers. API docs: https://docs.polar.sh/api-reference
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { env } from '../../lib/env';
|
|
10
|
+
import { logger } from '../../lib/logger';
|
|
11
|
+
import type { BillingProvider, CheckoutResult, PortalResult, SubscriptionInfo } from '../provider';
|
|
12
|
+
|
|
13
|
+
const POLAR_API_URL = 'https://api.polar.sh';
|
|
14
|
+
|
|
15
|
+
export class PolarProvider implements BillingProvider {
|
|
16
|
+
readonly type = 'polar' as const;
|
|
17
|
+
|
|
18
|
+
private get accessToken(): string {
|
|
19
|
+
if (!env.POLAR_ACCESS_TOKEN) {
|
|
20
|
+
throw new Error('Polar is not configured. Set POLAR_ACCESS_TOKEN in your environment.');
|
|
21
|
+
}
|
|
22
|
+
return env.POLAR_ACCESS_TOKEN;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
isConfigured(): boolean {
|
|
26
|
+
return !!env.POLAR_ACCESS_TOKEN;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async createCheckout(params: {
|
|
30
|
+
customerId: string;
|
|
31
|
+
priceId: string;
|
|
32
|
+
successUrl: string;
|
|
33
|
+
cancelUrl: string;
|
|
34
|
+
metadata: Record<string, string>;
|
|
35
|
+
}): Promise<CheckoutResult> {
|
|
36
|
+
// Polar uses the Checkouts API
|
|
37
|
+
const response = await this.request('POST', '/v1/checkouts', {
|
|
38
|
+
product_price_id: params.priceId,
|
|
39
|
+
customer_id: params.customerId,
|
|
40
|
+
success_url: params.successUrl,
|
|
41
|
+
metadata: params.metadata,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const data = response.data;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
url: data.url as string,
|
|
48
|
+
sessionId: data.id as string,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async createPortalSession(params: {
|
|
53
|
+
customerId: string;
|
|
54
|
+
returnUrl: string;
|
|
55
|
+
}): Promise<PortalResult> {
|
|
56
|
+
// Polar provides customer portal sessions via the Customers API
|
|
57
|
+
const response = await this.request(
|
|
58
|
+
'POST',
|
|
59
|
+
`/v1/customers/${params.customerId}/portal/sessions`,
|
|
60
|
+
{
|
|
61
|
+
return_url: params.returnUrl,
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return { url: response.data.url as string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getSubscription(params: {
|
|
69
|
+
customerId: string;
|
|
70
|
+
subscriptionId: string;
|
|
71
|
+
}): Promise<SubscriptionInfo | null> {
|
|
72
|
+
try {
|
|
73
|
+
const response = await this.request('GET', `/v1/subscriptions/${params.subscriptionId}`);
|
|
74
|
+
|
|
75
|
+
const sub = response.data;
|
|
76
|
+
if (!sub) return null;
|
|
77
|
+
|
|
78
|
+
const price = sub.price as Record<string, unknown> | undefined;
|
|
79
|
+
const product = sub.product as Record<string, unknown> | undefined;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id: sub.id as string,
|
|
83
|
+
status: this.normalizePolarStatus(sub.status as string),
|
|
84
|
+
planId: this.resolvePlanId((price?.id as string) ?? ''),
|
|
85
|
+
priceId: (price?.id as string) ?? '',
|
|
86
|
+
currentPeriodEnd: (sub.current_period_end as string) ?? null,
|
|
87
|
+
cancelAtPeriodEnd: (sub.cancel_at_period_end as boolean) ?? false,
|
|
88
|
+
price: {
|
|
89
|
+
unitAmount: (price?.price_amount as number) ?? 0,
|
|
90
|
+
currency: (price?.price_currency as string) ?? 'usd',
|
|
91
|
+
interval: (price?.recurring_interval as string) ?? 'month',
|
|
92
|
+
},
|
|
93
|
+
product: {
|
|
94
|
+
id: (product?.id as string) ?? '',
|
|
95
|
+
name: (product?.name as string) ?? '',
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
} catch (error) {
|
|
99
|
+
logger.error(
|
|
100
|
+
{ error, subscriptionId: params.subscriptionId },
|
|
101
|
+
'Failed to fetch Polar subscription'
|
|
102
|
+
);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async handleWebhook(params: {
|
|
108
|
+
body: string;
|
|
109
|
+
signature: string;
|
|
110
|
+
}): Promise<{ type: string; data: Record<string, unknown> }> {
|
|
111
|
+
// Verify Polar webhook signature (HMAC-SHA256)
|
|
112
|
+
this.verifyWebhookSignature(params.body, params.signature);
|
|
113
|
+
|
|
114
|
+
const payload = JSON.parse(params.body);
|
|
115
|
+
const eventType = payload.type as string;
|
|
116
|
+
const normalized = this.normalizeEventType(eventType);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
type: normalized,
|
|
120
|
+
data: this.extractEventData(eventType, payload.data ?? {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async createCustomer(params: {
|
|
125
|
+
email: string;
|
|
126
|
+
name: string;
|
|
127
|
+
metadata: Record<string, string>;
|
|
128
|
+
}): Promise<string> {
|
|
129
|
+
const body: Record<string, unknown> = {
|
|
130
|
+
email: params.email,
|
|
131
|
+
name: params.name,
|
|
132
|
+
metadata: params.metadata,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Polar requires an organization_id for customer creation
|
|
136
|
+
if (env.POLAR_ORGANIZATION_ID) {
|
|
137
|
+
body.organization_id = env.POLAR_ORGANIZATION_ID;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const response = await this.request('POST', '/v1/customers', body);
|
|
141
|
+
return response.data.id as string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async deleteCustomer(customerId: string): Promise<void> {
|
|
145
|
+
try {
|
|
146
|
+
await this.request('DELETE', `/v1/customers/${customerId}`);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// Polar may return 404 if customer was already removed
|
|
149
|
+
logger.warn(
|
|
150
|
+
{ error, customerId },
|
|
151
|
+
'Failed to delete Polar customer (may already be deleted)'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Private helpers
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/** Make an authenticated request to the Polar API. */
|
|
161
|
+
private async request(
|
|
162
|
+
method: string,
|
|
163
|
+
path: string,
|
|
164
|
+
body?: Record<string, unknown>
|
|
165
|
+
): Promise<{ data: Record<string, unknown> }> {
|
|
166
|
+
const url = `${POLAR_API_URL}${path}`;
|
|
167
|
+
|
|
168
|
+
const headers: Record<string, string> = {
|
|
169
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const options: RequestInit = { method, headers };
|
|
174
|
+
if (body && method !== 'GET') {
|
|
175
|
+
options.body = JSON.stringify(body);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const response = await fetch(url, options);
|
|
179
|
+
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
const errorBody = await response.text();
|
|
182
|
+
logger.error({ status: response.status, body: errorBody, path }, 'Polar API request failed');
|
|
183
|
+
throw new Error(`Polar API error (${response.status}): ${errorBody}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// DELETE responses may have no body
|
|
187
|
+
if (response.status === 204) {
|
|
188
|
+
return { data: {} };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// biome-ignore lint/suspicious/noExplicitAny: Polar API response shape varies
|
|
192
|
+
const json = (await response.json()) as any;
|
|
193
|
+
return { data: json };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Verify Polar webhook signature.
|
|
198
|
+
*
|
|
199
|
+
* Polar sends a webhook-id, webhook-timestamp, and webhook-signature header.
|
|
200
|
+
* The signature is an HMAC-SHA256 over `<webhook-id>.<timestamp>.<body>` using
|
|
201
|
+
* the webhook secret (base64-decoded).
|
|
202
|
+
*
|
|
203
|
+
* The signature header may contain multiple signatures separated by spaces,
|
|
204
|
+
* each prefixed with `v1,`. We check if any match.
|
|
205
|
+
*/
|
|
206
|
+
private verifyWebhookSignature(body: string, signatureHeader: string): void {
|
|
207
|
+
if (!env.POLAR_WEBHOOK_SECRET) {
|
|
208
|
+
throw new Error('Polar webhook secret is not configured');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The signatureHeader passed here is the combined value we construct
|
|
212
|
+
// in the billing webhook route: `${webhookId}.${timestamp}.${signature}`
|
|
213
|
+
const parts = signatureHeader.split('.');
|
|
214
|
+
if (parts.length < 3) {
|
|
215
|
+
throw new Error('Invalid Polar webhook signature format');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const webhookId = parts[0];
|
|
219
|
+
const timestamp = parts[1];
|
|
220
|
+
const signatures = parts.slice(2).join('.');
|
|
221
|
+
|
|
222
|
+
// Compute expected HMAC
|
|
223
|
+
// Polar webhook secrets are base64-encoded and prefixed with "whsec_"
|
|
224
|
+
const secretRaw = env.POLAR_WEBHOOK_SECRET.startsWith('whsec_')
|
|
225
|
+
? env.POLAR_WEBHOOK_SECRET.slice(6)
|
|
226
|
+
: env.POLAR_WEBHOOK_SECRET;
|
|
227
|
+
const secretBytes = Buffer.from(secretRaw, 'base64');
|
|
228
|
+
const payload = `${webhookId}.${timestamp}.${body}`;
|
|
229
|
+
const expectedSig = createHmac('sha256', secretBytes).update(payload).digest('base64');
|
|
230
|
+
|
|
231
|
+
// The signature header may contain multiple v1 signatures
|
|
232
|
+
const providedSigs = signatures.split(' ').map((s) => {
|
|
233
|
+
if (s.startsWith('v1,')) return s.slice(3);
|
|
234
|
+
return s;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
238
|
+
const matched = providedSigs.some((sig) => {
|
|
239
|
+
const sigBuf = Buffer.from(sig);
|
|
240
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
241
|
+
return timingSafeEqual(sigBuf, expectedBuf);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!matched) {
|
|
245
|
+
throw new Error('Invalid Polar webhook signature');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Map Polar event types to normalized billing event names. */
|
|
250
|
+
private normalizeEventType(polarType: string): string {
|
|
251
|
+
const map: Record<string, string> = {
|
|
252
|
+
'checkout.created': 'checkout.completed',
|
|
253
|
+
'subscription.created': 'subscription.created',
|
|
254
|
+
'subscription.updated': 'subscription.updated',
|
|
255
|
+
'subscription.revoked': 'subscription.deleted',
|
|
256
|
+
'subscription.canceled': 'subscription.deleted',
|
|
257
|
+
};
|
|
258
|
+
return map[polarType] ?? polarType;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Map Polar subscription status to a normalized string. */
|
|
262
|
+
private normalizePolarStatus(polarStatus: string): string {
|
|
263
|
+
const map: Record<string, string> = {
|
|
264
|
+
active: 'active',
|
|
265
|
+
canceled: 'canceled',
|
|
266
|
+
revoked: 'canceled',
|
|
267
|
+
incomplete: 'incomplete',
|
|
268
|
+
incomplete_expired: 'expired',
|
|
269
|
+
trialing: 'trialing',
|
|
270
|
+
past_due: 'past_due',
|
|
271
|
+
unpaid: 'past_due',
|
|
272
|
+
};
|
|
273
|
+
return map[polarStatus] ?? polarStatus;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Map a Polar price ID to a plan ID using configured env vars. */
|
|
277
|
+
private resolvePlanId(priceId: string): string {
|
|
278
|
+
if (priceId === env.POLAR_PRICE_STARTER) return 'starter';
|
|
279
|
+
if (priceId === env.POLAR_PRICE_PRO) return 'pro';
|
|
280
|
+
return 'free';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Extract relevant data from a Polar webhook event payload. */
|
|
284
|
+
// biome-ignore lint/suspicious/noExplicitAny: Polar webhook shapes vary by event type
|
|
285
|
+
private extractEventData(eventType: string, data: any): Record<string, unknown> {
|
|
286
|
+
switch (eventType) {
|
|
287
|
+
case 'checkout.created':
|
|
288
|
+
return {
|
|
289
|
+
provider: 'polar',
|
|
290
|
+
mode: 'subscription',
|
|
291
|
+
customerId: data.customer_id ?? null,
|
|
292
|
+
subscriptionId: data.subscription_id ?? null,
|
|
293
|
+
customerEmail: data.customer_email ?? null,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
case 'subscription.created':
|
|
297
|
+
case 'subscription.updated': {
|
|
298
|
+
const price = data.price;
|
|
299
|
+
return {
|
|
300
|
+
provider: 'polar',
|
|
301
|
+
subscriptionId: data.id,
|
|
302
|
+
customerId: data.customer_id ?? null,
|
|
303
|
+
status: this.normalizePolarStatus(data.status ?? ''),
|
|
304
|
+
priceId: price?.id ?? '',
|
|
305
|
+
currentPeriodStart: data.current_period_start ?? null,
|
|
306
|
+
currentPeriodEnd: data.current_period_end ?? null,
|
|
307
|
+
cancelAtPeriodEnd: data.cancel_at_period_end ?? false,
|
|
308
|
+
canceledAt: data.ended_at ?? null,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
case 'subscription.revoked':
|
|
313
|
+
case 'subscription.canceled':
|
|
314
|
+
return {
|
|
315
|
+
provider: 'polar',
|
|
316
|
+
subscriptionId: data.id,
|
|
317
|
+
customerId: data.customer_id ?? null,
|
|
318
|
+
status: 'canceled',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
default:
|
|
322
|
+
return { provider: 'polar', raw: data };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|