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.
Files changed (495) hide show
  1. package/LICENSE +663 -0
  2. package/README.md +188 -0
  3. package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
  4. package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
  5. package/carbon/.claude/settings.json +29 -0
  6. package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
  7. package/carbon/.dockerignore +57 -0
  8. package/carbon/.env.example +219 -0
  9. package/carbon/.github/copilot-instructions.md +3 -0
  10. package/carbon/.github/workflows/deploy.yml +346 -0
  11. package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
  12. package/carbon/.windsurfrules +74 -0
  13. package/carbon/AGENTS.md +422 -0
  14. package/carbon/CLAUDE.md +3 -0
  15. package/carbon/DEVELOPMENT.md +187 -0
  16. package/carbon/Dockerfile +98 -0
  17. package/carbon/LICENSE +21 -0
  18. package/carbon/PRODUCTION.md +364 -0
  19. package/carbon/README.md +193 -0
  20. package/carbon/TESTING.md +138 -0
  21. package/carbon/backup/Dockerfile +75 -0
  22. package/carbon/backup/backup.sh +95 -0
  23. package/carbon/backup/compose-backup.sh +140 -0
  24. package/carbon/biome.json +83 -0
  25. package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
  26. package/carbon/cloud-init/k3s/master-init.sh +215 -0
  27. package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
  28. package/carbon/cloud-init/k3s/worker-init.sh +147 -0
  29. package/carbon/components.json +24 -0
  30. package/carbon/content/blog/authentication-guide.mdx +34 -0
  31. package/carbon/content/blog/getting-started.mdx +41 -0
  32. package/carbon/content/changelog/v0-1-0.mdx +40 -0
  33. package/carbon/content/docs/analytics.mdx +90 -0
  34. package/carbon/content/docs/authentication.mdx +231 -0
  35. package/carbon/content/docs/background-jobs.mdx +116 -0
  36. package/carbon/content/docs/cli.mdx +630 -0
  37. package/carbon/content/docs/database.mdx +236 -0
  38. package/carbon/content/docs/deployment.mdx +227 -0
  39. package/carbon/content/docs/development.mdx +238 -0
  40. package/carbon/content/docs/environments.mdx +84 -0
  41. package/carbon/content/docs/getting-started.mdx +112 -0
  42. package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
  43. package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
  44. package/carbon/content/docs/optional-services.mdx +160 -0
  45. package/carbon/db/Dockerfile +23 -0
  46. package/carbon/docker-compose.dev-init.yml +5 -0
  47. package/carbon/docker-compose.metabase.override.yml +14 -0
  48. package/carbon/docker-compose.metabase.prod.yml +28 -0
  49. package/carbon/docker-compose.metabase.yml +84 -0
  50. package/carbon/docker-compose.n8n.override.yml +14 -0
  51. package/carbon/docker-compose.n8n.prod.yml +31 -0
  52. package/carbon/docker-compose.n8n.yml +97 -0
  53. package/carbon/docker-compose.observability.override.yml +18 -0
  54. package/carbon/docker-compose.observability.prod.yml +28 -0
  55. package/carbon/docker-compose.observability.yml +125 -0
  56. package/carbon/docker-compose.override.yml +28 -0
  57. package/carbon/docker-compose.prod.yml +294 -0
  58. package/carbon/docker-compose.yml +508 -0
  59. package/carbon/docker-entrypoint.sh +12 -0
  60. package/carbon/ha/activate-standby.sh +52 -0
  61. package/carbon/ha/primary-init.sql +36 -0
  62. package/carbon/ha/standby-init.sh +74 -0
  63. package/carbon/k8s/LICENSE +99 -0
  64. package/carbon/k8s/README.md +272 -0
  65. package/carbon/k8s/base/app/deployment.yaml +130 -0
  66. package/carbon/k8s/base/app/hpa.yaml +44 -0
  67. package/carbon/k8s/base/app/kustomization.yaml +10 -0
  68. package/carbon/k8s/base/app/network-policy.yaml +124 -0
  69. package/carbon/k8s/base/app/pdb.yaml +14 -0
  70. package/carbon/k8s/base/app/rbac.yaml +36 -0
  71. package/carbon/k8s/base/app/service.yaml +16 -0
  72. package/carbon/k8s/base/backup/cronjob.yaml +120 -0
  73. package/carbon/k8s/base/backup/kustomization.yaml +7 -0
  74. package/carbon/k8s/base/backup/network-policy.yaml +31 -0
  75. package/carbon/k8s/base/backup/rbac.yaml +7 -0
  76. package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
  77. package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
  78. package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
  79. package/carbon/k8s/base/config/configmap.yaml +48 -0
  80. package/carbon/k8s/base/config/kustomization.yaml +8 -0
  81. package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
  82. package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
  83. package/carbon/k8s/base/kustomization.yaml +43 -0
  84. package/carbon/k8s/base/namespace.yaml +7 -0
  85. package/carbon/k8s/base/network-policies.yaml +95 -0
  86. package/carbon/k8s/base/registry/kustomization.yaml +5 -0
  87. package/carbon/k8s/base/registry/local-registry.yaml +193 -0
  88. package/carbon/k8s/base/traefik/certificate.yaml +21 -0
  89. package/carbon/k8s/base/traefik/configmap.yaml +13 -0
  90. package/carbon/k8s/base/traefik/deployment.yaml +165 -0
  91. package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
  92. package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
  93. package/carbon/k8s/base/traefik/middleware.yaml +109 -0
  94. package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
  95. package/carbon/k8s/base/traefik/service.yaml +38 -0
  96. package/carbon/k8s/flux/README.md +49 -0
  97. package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
  98. package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
  99. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
  100. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
  101. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
  102. package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
  103. package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
  104. package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
  105. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
  106. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
  107. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
  108. package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
  109. package/carbon/k8s/infra/kustomization.yaml +21 -0
  110. package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
  111. package/carbon/k8s/overlays/local/configmap.yaml +23 -0
  112. package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
  113. package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
  114. package/carbon/k8s/overlays/local/namespace.yaml +8 -0
  115. package/carbon/k8s/overlays/local/secrets.yaml +32 -0
  116. package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
  117. package/carbon/k8s/test-local.sh +318 -0
  118. package/carbon/k8s/values/supabase.values.yaml +133 -0
  119. package/carbon/package.json +154 -0
  120. package/carbon/runtime.Dockerfile +17 -0
  121. package/carbon/scripts/_dev-jwt.mjs +45 -0
  122. package/carbon/scripts/check-rls.ts +53 -0
  123. package/carbon/scripts/dev-init.js +191 -0
  124. package/carbon/scripts/dev.js +191 -0
  125. package/carbon/scripts/docker-down.js +63 -0
  126. package/carbon/scripts/docker-logs.js +59 -0
  127. package/carbon/scripts/docker-up.js +222 -0
  128. package/carbon/scripts/generate-dev-configs.sh +131 -0
  129. package/carbon/scripts/generate-rss.ts +116 -0
  130. package/carbon/scripts/generate-sitemap.ts +102 -0
  131. package/carbon/scripts/k8s-apply.js +71 -0
  132. package/carbon/scripts/k8s-delete.js +75 -0
  133. package/carbon/scripts/lib/manifest.js +176 -0
  134. package/carbon/scripts/secret-scan.mjs +278 -0
  135. package/carbon/scripts/validate-dev-configs.sh +101 -0
  136. package/carbon/src/client/App.tsx +202 -0
  137. package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
  138. package/carbon/src/client/assets/hyperformant-light.svg +29 -0
  139. package/carbon/src/client/assets/logos/aider.svg +1 -0
  140. package/carbon/src/client/assets/logos/antigravity.svg +1 -0
  141. package/carbon/src/client/assets/logos/bolt.svg +1 -0
  142. package/carbon/src/client/assets/logos/claude-code.svg +1 -0
  143. package/carbon/src/client/assets/logos/copilot.svg +1 -0
  144. package/carbon/src/client/assets/logos/cursor.svg +1 -0
  145. package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
  146. package/carbon/src/client/assets/logos/lovable.svg +1 -0
  147. package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
  148. package/carbon/src/client/assets/logos/v0.svg +1 -0
  149. package/carbon/src/client/assets/logos/windsurf.svg +1 -0
  150. package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
  151. package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
  152. package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
  153. package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
  154. package/carbon/src/client/components/AppSidebar.tsx +760 -0
  155. package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
  156. package/carbon/src/client/components/CTAFooter.tsx +59 -0
  157. package/carbon/src/client/components/ComparisonSection.tsx +132 -0
  158. package/carbon/src/client/components/ContentPanel.tsx +66 -0
  159. package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
  160. package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
  161. package/carbon/src/client/components/FAQSection.tsx +76 -0
  162. package/carbon/src/client/components/FileUpload.tsx +210 -0
  163. package/carbon/src/client/components/HeaderActions.tsx +17 -0
  164. package/carbon/src/client/components/Hero.tsx +608 -0
  165. package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
  166. package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
  167. package/carbon/src/client/components/Logo.tsx +87 -0
  168. package/carbon/src/client/components/LogoStrip.tsx +76 -0
  169. package/carbon/src/client/components/MetricsStrip.tsx +48 -0
  170. package/carbon/src/client/components/Nav.tsx +195 -0
  171. package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
  172. package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
  173. package/carbon/src/client/components/PageHeader.tsx +24 -0
  174. package/carbon/src/client/components/PlanGate.tsx +36 -0
  175. package/carbon/src/client/components/PricingSection.tsx +371 -0
  176. package/carbon/src/client/components/SEO.tsx +34 -0
  177. package/carbon/src/client/components/SmoothScroll.tsx +45 -0
  178. package/carbon/src/client/components/TechStackSection.tsx +165 -0
  179. package/carbon/src/client/components/WorkflowSection.tsx +604 -0
  180. package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
  181. package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
  182. package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
  183. package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
  184. package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
  185. package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
  186. package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
  187. package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
  188. package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
  189. package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
  190. package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
  191. package/carbon/src/client/components/effects/index.ts +2 -0
  192. package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
  193. package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
  194. package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
  195. package/carbon/src/client/components/scroll/index.ts +2 -0
  196. package/carbon/src/client/components/ui/accordion.tsx +71 -0
  197. package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
  198. package/carbon/src/client/components/ui/alert.tsx +73 -0
  199. package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
  200. package/carbon/src/client/components/ui/avatar.tsx +91 -0
  201. package/carbon/src/client/components/ui/badge.tsx +50 -0
  202. package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
  203. package/carbon/src/client/components/ui/button-group.tsx +78 -0
  204. package/carbon/src/client/components/ui/button.tsx +120 -0
  205. package/carbon/src/client/components/ui/calendar.tsx +182 -0
  206. package/carbon/src/client/components/ui/card.tsx +85 -0
  207. package/carbon/src/client/components/ui/carousel.tsx +227 -0
  208. package/carbon/src/client/components/ui/chart.tsx +357 -0
  209. package/carbon/src/client/components/ui/checkbox.tsx +27 -0
  210. package/carbon/src/client/components/ui/collapsible.tsx +15 -0
  211. package/carbon/src/client/components/ui/command.tsx +178 -0
  212. package/carbon/src/client/components/ui/context-menu.tsx +233 -0
  213. package/carbon/src/client/components/ui/dialog.tsx +132 -0
  214. package/carbon/src/client/components/ui/drawer.tsx +118 -0
  215. package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
  216. package/carbon/src/client/components/ui/empty.tsx +94 -0
  217. package/carbon/src/client/components/ui/field.tsx +226 -0
  218. package/carbon/src/client/components/ui/hover-card.tsx +44 -0
  219. package/carbon/src/client/components/ui/input-group.tsx +146 -0
  220. package/carbon/src/client/components/ui/input-otp.tsx +83 -0
  221. package/carbon/src/client/components/ui/input.tsx +20 -0
  222. package/carbon/src/client/components/ui/item.tsx +188 -0
  223. package/carbon/src/client/components/ui/kbd.tsx +26 -0
  224. package/carbon/src/client/components/ui/label.tsx +21 -0
  225. package/carbon/src/client/components/ui/menubar.tsx +250 -0
  226. package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
  227. package/carbon/src/client/components/ui/pagination.tsx +102 -0
  228. package/carbon/src/client/components/ui/popover.tsx +75 -0
  229. package/carbon/src/client/components/ui/progress.tsx +66 -0
  230. package/carbon/src/client/components/ui/radio-group.tsx +36 -0
  231. package/carbon/src/client/components/ui/resizable.tsx +46 -0
  232. package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
  233. package/carbon/src/client/components/ui/select.tsx +186 -0
  234. package/carbon/src/client/components/ui/separator.tsx +21 -0
  235. package/carbon/src/client/components/ui/sheet.tsx +124 -0
  236. package/carbon/src/client/components/ui/sidebar.tsx +702 -0
  237. package/carbon/src/client/components/ui/skeleton.tsx +13 -0
  238. package/carbon/src/client/components/ui/slider.tsx +57 -0
  239. package/carbon/src/client/components/ui/sonner.tsx +45 -0
  240. package/carbon/src/client/components/ui/spinner.tsx +15 -0
  241. package/carbon/src/client/components/ui/switch.tsx +30 -0
  242. package/carbon/src/client/components/ui/table.tsx +89 -0
  243. package/carbon/src/client/components/ui/tabs.tsx +73 -0
  244. package/carbon/src/client/components/ui/textarea.tsx +18 -0
  245. package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
  246. package/carbon/src/client/components/ui/toggle.tsx +44 -0
  247. package/carbon/src/client/components/ui/tooltip.tsx +56 -0
  248. package/carbon/src/client/hooks/api/index.ts +14 -0
  249. package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
  250. package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
  251. package/carbon/src/client/hooks/use-mobile.ts +21 -0
  252. package/carbon/src/client/hooks/useMousePosition.ts +91 -0
  253. package/carbon/src/client/hooks/useNotifications.tsx +124 -0
  254. package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
  255. package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
  256. package/carbon/src/client/hooks/usePackageManager.ts +69 -0
  257. package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
  258. package/carbon/src/client/hooks/useRunningServices.ts +114 -0
  259. package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
  260. package/carbon/src/client/index.css +467 -0
  261. package/carbon/src/client/index.html +56 -0
  262. package/carbon/src/client/lib/admin-services.ts +151 -0
  263. package/carbon/src/client/lib/api.ts +32 -0
  264. package/carbon/src/client/lib/blog.ts +35 -0
  265. package/carbon/src/client/lib/changelog.ts +33 -0
  266. package/carbon/src/client/lib/docs-search.ts +101 -0
  267. package/carbon/src/client/lib/docs.ts +37 -0
  268. package/carbon/src/client/lib/i18n.ts +32 -0
  269. package/carbon/src/client/lib/supabase.ts +72 -0
  270. package/carbon/src/client/lib/tailwind-colors.ts +357 -0
  271. package/carbon/src/client/lib/theme.ts +117 -0
  272. package/carbon/src/client/lib/utils.ts +22 -0
  273. package/carbon/src/client/locales/de.json +529 -0
  274. package/carbon/src/client/locales/en.json +461 -0
  275. package/carbon/src/client/locales/es.json +529 -0
  276. package/carbon/src/client/locales/fr.json +529 -0
  277. package/carbon/src/client/locales/pt.json +529 -0
  278. package/carbon/src/client/main.tsx +56 -0
  279. package/carbon/src/client/mdx.d.ts +13 -0
  280. package/carbon/src/client/pages/ApiDocs.tsx +76 -0
  281. package/carbon/src/client/pages/AuthCallback.tsx +34 -0
  282. package/carbon/src/client/pages/Blog.tsx +167 -0
  283. package/carbon/src/client/pages/Changelog.tsx +171 -0
  284. package/carbon/src/client/pages/Charts.tsx +388 -0
  285. package/carbon/src/client/pages/Checkout.tsx +227 -0
  286. package/carbon/src/client/pages/Contact.tsx +174 -0
  287. package/carbon/src/client/pages/Dashboard.tsx +368 -0
  288. package/carbon/src/client/pages/Docs.tsx +372 -0
  289. package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
  290. package/carbon/src/client/pages/Home.tsx +187 -0
  291. package/carbon/src/client/pages/Legal.tsx +100 -0
  292. package/carbon/src/client/pages/Login.tsx +408 -0
  293. package/carbon/src/client/pages/MFAVerify.tsx +156 -0
  294. package/carbon/src/client/pages/NotFound.tsx +21 -0
  295. package/carbon/src/client/pages/Onboarding.tsx +246 -0
  296. package/carbon/src/client/pages/ResetPassword.tsx +200 -0
  297. package/carbon/src/client/pages/UIComponents.tsx +390 -0
  298. package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
  299. package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
  300. package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
  301. package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
  302. package/carbon/src/client/pages/admin/Logs.tsx +18 -0
  303. package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
  304. package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
  305. package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
  306. package/carbon/src/client/pages/admin/Settings.tsx +314 -0
  307. package/carbon/src/client/pages/admin/Theme.tsx +465 -0
  308. package/carbon/src/client/pages/admin/Users.tsx +365 -0
  309. package/carbon/src/client/pages/api-docs.css +156 -0
  310. package/carbon/src/client/pages/organizations/Details.tsx +200 -0
  311. package/carbon/src/client/pages/organizations/Members.tsx +402 -0
  312. package/carbon/src/client/pages/settings/Billing.tsx +473 -0
  313. package/carbon/src/client/pages/settings/Profile.tsx +160 -0
  314. package/carbon/src/client/pages/settings/Security.tsx +341 -0
  315. package/carbon/src/client/public/favicon.svg +19 -0
  316. package/carbon/src/client/public/robots.txt +8 -0
  317. package/carbon/src/server/billing/index.ts +104 -0
  318. package/carbon/src/server/billing/provider.ts +81 -0
  319. package/carbon/src/server/billing/providers/paddle.ts +314 -0
  320. package/carbon/src/server/billing/providers/polar.ts +325 -0
  321. package/carbon/src/server/billing/providers/stripe.ts +233 -0
  322. package/carbon/src/server/emails/templates.ts +116 -0
  323. package/carbon/src/server/index.ts +554 -0
  324. package/carbon/src/server/lib/auth.ts +6 -0
  325. package/carbon/src/server/lib/email.ts +64 -0
  326. package/carbon/src/server/lib/env.ts +112 -0
  327. package/carbon/src/server/lib/errors.ts +21 -0
  328. package/carbon/src/server/lib/logger.ts +17 -0
  329. package/carbon/src/server/lib/rate-limiter.ts +288 -0
  330. package/carbon/src/server/lib/request.ts +34 -0
  331. package/carbon/src/server/lib/stripe.ts +42 -0
  332. package/carbon/src/server/lib/supabase.ts +65 -0
  333. package/carbon/src/server/middleware/requirePlan.ts +80 -0
  334. package/carbon/src/server/routes/_internal/services-status.ts +958 -0
  335. package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
  336. package/carbon/src/server/routes/health.ts +48 -0
  337. package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
  338. package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
  339. package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
  340. package/carbon/src/server/routes/v1/auth.ts +390 -0
  341. package/carbon/src/server/routes/v1/billing.ts +718 -0
  342. package/carbon/src/server/routes/v1/contact.ts +93 -0
  343. package/carbon/src/server/routes/v1/index.ts +1333 -0
  344. package/carbon/src/server/routes/v1/newsletter.ts +181 -0
  345. package/carbon/src/server/routes/v1/performance.ts +157 -0
  346. package/carbon/src/server/routes/v1/stats.ts +170 -0
  347. package/carbon/src/server/routes/v1/theme.ts +106 -0
  348. package/carbon/src/server/routes/webhooks/billing.ts +376 -0
  349. package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
  350. package/carbon/src/server/types.ts +11 -0
  351. package/carbon/src/shared/pricing.ts +155 -0
  352. package/carbon/src/shared/types.ts +338 -0
  353. package/carbon/supabase/migrations/00001_init.sql +717 -0
  354. package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
  355. package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
  356. package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
  357. package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
  358. package/carbon/supabase/seed.sql +16 -0
  359. package/carbon/tests/_helpers/app.ts +45 -0
  360. package/carbon/tests/_helpers/env.ts +37 -0
  361. package/carbon/tests/_helpers/factories.ts +69 -0
  362. package/carbon/tests/_helpers/jwt.ts +23 -0
  363. package/carbon/tests/_helpers/setup-integration.ts +20 -0
  364. package/carbon/tests/_helpers/setup-rtl.ts +12 -0
  365. package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
  366. package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
  367. package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
  368. package/carbon/tests/integration/server/routes/health.test.ts +61 -0
  369. package/carbon/tests/structural/i18n-parity.test.ts +42 -0
  370. package/carbon/tests/unit/client/utils.test.ts +49 -0
  371. package/carbon/tests/unit/shared/pricing.test.ts +93 -0
  372. package/carbon/tsconfig.json +27 -0
  373. package/carbon/tsconfig.server.json +27 -0
  374. package/carbon/tsconfig.test.json +9 -0
  375. package/carbon/vite.config.ts +110 -0
  376. package/carbon/vitest.config.ts +74 -0
  377. package/carbon/volumes/db/jwt.sql +57 -0
  378. package/carbon/volumes/db/metabase-init.sh +29 -0
  379. package/carbon/volumes/db/n8n-init.sh +25 -0
  380. package/carbon/volumes/db/realtime.sql +33 -0
  381. package/carbon/volumes/db/roles.sql +93 -0
  382. package/carbon/volumes/db/set-passwords.sh +12 -0
  383. package/carbon/volumes/db/super-admin.dev.sql +113 -0
  384. package/carbon/volumes/db/super-admin.generated.sql +113 -0
  385. package/carbon/volumes/db/super-admin.sql +114 -0
  386. package/carbon/volumes/grafana/dashboards/logs.json +179 -0
  387. package/carbon/volumes/grafana/dashboards/overview.json +523 -0
  388. package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
  389. package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
  390. package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
  391. package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
  392. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
  393. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
  394. package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
  395. package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
  396. package/carbon/volumes/kong/kong.yml +208 -0
  397. package/carbon/volumes/loki/loki-config.yml +58 -0
  398. package/carbon/volumes/n8n/hooks.js +131 -0
  399. package/carbon/volumes/n8n/scripts/setup.sh +66 -0
  400. package/carbon/volumes/prometheus/prometheus.yml +43 -0
  401. package/carbon/volumes/promtail/promtail-config.yml +64 -0
  402. package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
  403. package/carbon/volumes/traefik/middlewares.yml +95 -0
  404. package/carbon/volumes/traefik/vite-dev.yml +20 -0
  405. package/package.json +95 -0
  406. package/src/access.js +354 -0
  407. package/src/activate.js +187 -0
  408. package/src/add.js +718 -0
  409. package/src/backup.js +786 -0
  410. package/src/cli.js +350 -0
  411. package/src/configure.js +967 -0
  412. package/src/console.js +155 -0
  413. package/src/create.js +1499 -0
  414. package/src/deploy.js +311 -0
  415. package/src/destroy.js +2033 -0
  416. package/src/diagnose.js +735 -0
  417. package/src/down.js +80 -0
  418. package/src/failover.js +1032 -0
  419. package/src/lib/backup-s3.js +179 -0
  420. package/src/lib/build.js +33 -0
  421. package/src/lib/checksum.js +28 -0
  422. package/src/lib/ci-setup.js +666 -0
  423. package/src/lib/cli/help.js +129 -0
  424. package/src/lib/cli/parse-flags.js +160 -0
  425. package/src/lib/cli/select-action.js +65 -0
  426. package/src/lib/cli/select-environment.js +108 -0
  427. package/src/lib/cli/tty-guard.js +75 -0
  428. package/src/lib/cloudflare.js +447 -0
  429. package/src/lib/colors.js +52 -0
  430. package/src/lib/command.js +361 -0
  431. package/src/lib/config.js +359 -0
  432. package/src/lib/cost.js +103 -0
  433. package/src/lib/deploy/bundle.js +231 -0
  434. package/src/lib/deploy/compose/acme-verify.js +121 -0
  435. package/src/lib/deploy/compose/build-args.js +78 -0
  436. package/src/lib/deploy/compose/ha.js +1874 -0
  437. package/src/lib/deploy/compose/index.js +1294 -0
  438. package/src/lib/deploy/compose/reconcile.js +74 -0
  439. package/src/lib/deploy/github.js +382 -0
  440. package/src/lib/deploy/image.js +191 -0
  441. package/src/lib/deploy/index.js +119 -0
  442. package/src/lib/deploy/k8s/LICENSE +99 -0
  443. package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
  444. package/src/lib/deploy/k8s/ha/index.js +1000 -0
  445. package/src/lib/deploy/k8s/index.js +62 -0
  446. package/src/lib/deploy/k8s/k3s.js +2515 -0
  447. package/src/lib/deploy/orchestrator.js +1401 -0
  448. package/src/lib/deploy/prompts.js +545 -0
  449. package/src/lib/deploy/remote-build.js +130 -0
  450. package/src/lib/deploy/state.js +120 -0
  451. package/src/lib/deploy/utils.js +328 -0
  452. package/src/lib/deploy-logger.js +93 -0
  453. package/src/lib/dns-propagation.js +48 -0
  454. package/src/lib/environment.js +58 -0
  455. package/src/lib/fetch-retry.js +103 -0
  456. package/src/lib/github-environments.js +335 -0
  457. package/src/lib/hetzner-dns.js +377 -0
  458. package/src/lib/hetzner-guided-setup.js +275 -0
  459. package/src/lib/host-keys.js +37 -0
  460. package/src/lib/iac/cloud-init.js +52 -0
  461. package/src/lib/iac/index.js +325 -0
  462. package/src/lib/iac/programs/hetzner-compose.js +130 -0
  463. package/src/lib/iac/programs/hetzner-k8s.js +320 -0
  464. package/src/lib/kubectl.js +81 -0
  465. package/src/lib/licensing/index.js +259 -0
  466. package/src/lib/licensing/tiers.js +181 -0
  467. package/src/lib/licensing/validator.js +171 -0
  468. package/src/lib/merge-package-json.js +90 -0
  469. package/src/lib/operator-ip.js +381 -0
  470. package/src/lib/package-manager.js +111 -0
  471. package/src/lib/perf.js +71 -0
  472. package/src/lib/project-guard.js +39 -0
  473. package/src/lib/project.js +334 -0
  474. package/src/lib/providers/base.js +276 -0
  475. package/src/lib/providers/hetzner-s3.js +656 -0
  476. package/src/lib/providers/hetzner.js +755 -0
  477. package/src/lib/providers/index.js +164 -0
  478. package/src/lib/s3.js +510 -0
  479. package/src/lib/secret-scan.js +583 -0
  480. package/src/lib/secrets.js +63 -0
  481. package/src/lib/server-types.js +195 -0
  482. package/src/lib/shell.js +91 -0
  483. package/src/lib/ssh.js +241 -0
  484. package/src/lib/tracker.js +242 -0
  485. package/src/lib/upgrade-policy.js +170 -0
  486. package/src/lib/validators.js +105 -0
  487. package/src/lib/version.js +5 -0
  488. package/src/remove.js +292 -0
  489. package/src/reset.js +97 -0
  490. package/src/restore.js +871 -0
  491. package/src/scale.js +1734 -0
  492. package/src/shell.js +222 -0
  493. package/src/status.js +981 -0
  494. package/src/up.js +264 -0
  495. package/src/upgrade.js +721 -0
@@ -0,0 +1,656 @@
1
+ /**
2
+ * Hetzner Object Storage (S3-compatible) Provider
3
+ *
4
+ * Manages S3 bucket operations using AWS SDK v3.
5
+ * Note: S3 credentials must be created manually in Hetzner Console.
6
+ *
7
+ * API Documentation: https://docs.hetzner.com/storage/object-storage/
8
+ *
9
+ * Retry posture: every SDK call routes through `_send()`, which retries
10
+ * transient errors (TimeoutError, ECONNRESET, EAI_AGAIN, 5xx, AWS SDK
11
+ * "UnknownError" wrappers) with capped backoff. Operation-specific
12
+ * conditions (NotFound, BucketAlreadyExists, BucketNotEmpty) are handled
13
+ * by the caller via a `terminal` classifier so they don't waste retries.
14
+ * Hetzner Ceph has a higher transient-error floor than AWS S3, so this
15
+ * uniform posture is what keeps e2e off the whack-a-mole treadmill.
16
+ */
17
+
18
+ import {
19
+ AbortMultipartUploadCommand,
20
+ CreateBucketCommand,
21
+ DeleteBucketCommand,
22
+ DeleteObjectsCommand,
23
+ HeadBucketCommand,
24
+ ListBucketsCommand,
25
+ ListMultipartUploadsCommand,
26
+ ListObjectsV2Command,
27
+ ListObjectVersionsCommand,
28
+ PutBucketCorsCommand,
29
+ S3Client,
30
+ } from '@aws-sdk/client-s3';
31
+
32
+ // Transient S3 error classifier. Matches AWS SDK error names ("TimeoutError",
33
+ // "UnknownError"), socket-level Node errors ("ECONNRESET", "EAI_AGAIN"), and
34
+ // 5xx HTTP statuses from $metadata.httpStatusCode. The regex covers cases
35
+ // where the status code is embedded in the error name/message (some SDK
36
+ // versions do this); the explicit $metadata check covers the common case
37
+ // where Hetzner returns a clean 502/503/504 with a typed name like
38
+ // "ServiceUnavailable" that wouldn't match the regex.
39
+ const TRANSIENT_S3_ERROR_RE =
40
+ /TimeoutError|ETIMEDOUT|ECONNRESET|EAI_AGAIN|NetworkingError|Network failure|UnknownError|503|504|502/i;
41
+ const TRANSIENT_HTTP_STATUSES = new Set([502, 503, 504]);
42
+
43
+ function isTransientS3Error(error) {
44
+ if (!error) return false;
45
+ if (TRANSIENT_S3_ERROR_RE.test(error.name ?? '')) return true;
46
+ if (TRANSIENT_S3_ERROR_RE.test(error.message ?? '')) return true;
47
+ const status = error.$metadata?.httpStatusCode;
48
+ if (status && TRANSIENT_HTTP_STATUSES.has(status)) return true;
49
+ return false;
50
+ }
51
+
52
+ export class HetznerS3Provider {
53
+ static ENDPOINTS = {
54
+ fsn1: 'https://fsn1.your-objectstorage.com',
55
+ nbg1: 'https://nbg1.your-objectstorage.com',
56
+ hel1: 'https://hel1.your-objectstorage.com',
57
+ };
58
+
59
+ static REGIONS = {
60
+ fsn1: 'Falkenstein, Germany',
61
+ nbg1: 'Nuremberg, Germany',
62
+ hel1: 'Helsinki, Finland',
63
+ };
64
+
65
+ /**
66
+ * Create a new Hetzner S3 provider instance
67
+ * @param {string} accessKeyId - S3 access key
68
+ * @param {string} secretAccessKey - S3 secret key
69
+ * @param {string} region - Region (fsn1, nbg1, hel1)
70
+ */
71
+ constructor(accessKeyId, secretAccessKey, region) {
72
+ if (!accessKeyId || !secretAccessKey) {
73
+ throw new Error('S3 credentials are required');
74
+ }
75
+
76
+ if (!HetznerS3Provider.ENDPOINTS[region]) {
77
+ throw new Error(
78
+ `Invalid region: ${region}. Valid regions: ${Object.keys(HetznerS3Provider.ENDPOINTS).join(', ')}`,
79
+ );
80
+ }
81
+
82
+ this.accessKeyId = accessKeyId;
83
+ this.secretAccessKey = secretAccessKey;
84
+ this.region = region;
85
+ this.endpoint = HetznerS3Provider.ENDPOINTS[region];
86
+ this._client = null;
87
+ }
88
+
89
+ /**
90
+ * Get or create the S3 client (lazy initialization)
91
+ * @returns {S3Client}
92
+ */
93
+ getClient() {
94
+ if (!this._client) {
95
+ // Explicit timeouts on the underlying http handler. The AWS SDK's
96
+ // NodeHttpHandler defaults to no socket timeout, so a Hetzner Ceph
97
+ // endpoint that accepts the TCP connection but stops responding mid-
98
+ // request will hang the call indefinitely. Observed in iter-initbackend
99
+ // 2026-05-01: compose destroy hung for 600s (test runner SIGKILL) on
100
+ // emptyAndDeleteBucket → ListObjectsV2; the per-iteration `client.send`
101
+ // never returned. With requestTimeout set, a stuck call surfaces as a
102
+ // TimeoutError after 60s and `_send` retries. maxAttempts here is the
103
+ // SDK's own internal retry; `_send` adds an outer layer with backoff
104
+ // tuned for Hetzner Ceph's recovery cadence.
105
+ this._client = new S3Client({
106
+ region: this.region,
107
+ endpoint: this.endpoint,
108
+ forcePathStyle: true, // Hetzner requires path-style URLs
109
+ credentials: {
110
+ accessKeyId: this.accessKeyId,
111
+ secretAccessKey: this.secretAccessKey,
112
+ },
113
+ maxAttempts: 3,
114
+ requestHandler: {
115
+ connectionTimeout: 10_000,
116
+ requestTimeout: 60_000,
117
+ },
118
+ });
119
+ }
120
+ return this._client;
121
+ }
122
+
123
+ /**
124
+ * Send a command through the S3 client with uniform transient-error retry.
125
+ *
126
+ * All SDK calls in this module route through here. The default 3-attempt
127
+ * loop with [5s, 10s] backoff absorbs Hetzner Ceph's typical blip
128
+ * envelope (one or two failed dispatches followed by recovery). Callers
129
+ * that need different retry economics — credential-validation prompts
130
+ * want fast failure, createBucket wants 5×exponential to ride out the
131
+ * post-delete bucket-name reservation window — pass `maxAttempts` and
132
+ * `backoffSeconds` explicitly.
133
+ *
134
+ * `terminal(error) → boolean` lets the caller short-circuit retry for
135
+ * conditions that are deterministic outcomes rather than transient
136
+ * faults (e.g., HeadBucket NotFound is "the bucket doesn't exist", not
137
+ * "Hetzner blipped"). Without this, NotFound would cost 15s+ of retry
138
+ * before bucketExists could return false.
139
+ *
140
+ * @param {object} command - AWS SDK command instance
141
+ * @param {object} [opts]
142
+ * @param {number} [opts.maxAttempts=3]
143
+ * @param {number[]} [opts.backoffSeconds=[5,10]] - delays BETWEEN attempts; length should be maxAttempts-1 (last entry repeats if shorter)
144
+ * @param {(error: Error) => boolean} [opts.terminal] - if returns true, throw immediately without retry
145
+ * @returns {Promise<*>} SDK response
146
+ */
147
+ async _send(command, { maxAttempts = 3, backoffSeconds = [5, 10], terminal = null } = {}) {
148
+ const client = this.getClient();
149
+ let lastError;
150
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
151
+ try {
152
+ return await client.send(command);
153
+ } catch (error) {
154
+ lastError = error;
155
+ if (terminal?.(error)) throw error;
156
+ if (isTransientS3Error(error) && attempt < maxAttempts - 1) {
157
+ const delay = backoffSeconds[Math.min(attempt, backoffSeconds.length - 1)];
158
+ await new Promise((r) => setTimeout(r, delay * 1000));
159
+ continue;
160
+ }
161
+ throw error;
162
+ }
163
+ }
164
+ // Loop above always returns or throws on the final attempt.
165
+ throw lastError;
166
+ }
167
+
168
+ /**
169
+ * Validate credentials by listing buckets
170
+ * @returns {Promise<{valid: boolean, error?: string}>}
171
+ */
172
+ async validateCredentials() {
173
+ // Tight retry budget tuned for the credential-prompt UX: terminal auth
174
+ // errors fail fast; transient errors get up to 3 quick re-tries before
175
+ // we surface "credentials invalid" to the user. We bypass _send's own
176
+ // retry (`maxAttempts: 1`) so the outer loop here is the single source
177
+ // of retry, keeping the worst-case wait under ~5s.
178
+ const terminalNames = new Set(['InvalidAccessKeyId', 'SignatureDoesNotMatch', 'AccessDenied']);
179
+ const maxAttempts = 3;
180
+ let lastError;
181
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
182
+ try {
183
+ await this._send(new ListBucketsCommand({}), { maxAttempts: 1 });
184
+ return { valid: true };
185
+ } catch (error) {
186
+ lastError = error;
187
+ if (terminalNames.has(error.name)) break;
188
+ if (attempt < maxAttempts) {
189
+ await new Promise((r) => setTimeout(r, 1500 * attempt));
190
+ }
191
+ }
192
+ }
193
+ if (lastError.name === 'InvalidAccessKeyId') {
194
+ return { valid: false, error: 'Invalid Access Key ID' };
195
+ }
196
+ if (lastError.name === 'SignatureDoesNotMatch') {
197
+ return { valid: false, error: 'Invalid Secret Key' };
198
+ }
199
+ if (lastError.name === 'AccessDenied') {
200
+ return { valid: false, error: 'Access denied. Check credentials permissions.' };
201
+ }
202
+ return {
203
+ valid: false,
204
+ error:
205
+ lastError.message ||
206
+ lastError.Code ||
207
+ lastError.name ||
208
+ `S3 error (HTTP ${lastError.$metadata?.httpStatusCode || 'unknown'})`,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * List all buckets
214
+ * @returns {Promise<Array<{name: string, creationDate: Date}>>}
215
+ */
216
+ async listBuckets() {
217
+ const response = await this._send(new ListBucketsCommand({}));
218
+ return (response.Buckets || []).map((bucket) => ({
219
+ name: bucket.Name,
220
+ creationDate: bucket.CreationDate,
221
+ }));
222
+ }
223
+
224
+ /**
225
+ * Check if a bucket exists. NotFound and 403 short-circuit `_send`'s
226
+ * retry via the terminal classifier — they're deterministic outcomes
227
+ * (bucket missing / cross-account collision), not Hetzner blips. Only
228
+ * actual transient errors (TimeoutError, 5xx) get retried by `_send`.
229
+ * @param {string} bucketName - Bucket name
230
+ * @returns {Promise<boolean>}
231
+ */
232
+ async bucketExists(bucketName) {
233
+ const command = new HeadBucketCommand({ Bucket: bucketName });
234
+ try {
235
+ await this._send(command, {
236
+ terminal: (err) => {
237
+ const status = err.$metadata?.httpStatusCode;
238
+ return err.name === 'NotFound' || status === 404 || status === 403;
239
+ },
240
+ });
241
+ return true;
242
+ } catch (error) {
243
+ const status = error.$metadata?.httpStatusCode;
244
+ if (error.name === 'NotFound' || status === 404) return false;
245
+ if (status === 403) {
246
+ throw new Error(
247
+ `Bucket "${bucketName}" exists but is owned by another account. Try a different project name.`,
248
+ );
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Search all S3 regions for a bucket we own.
256
+ * Each region's HeadBucket failure is intentionally swallowed — finding
257
+ * the bucket means we got a success on at least one region; finding it
258
+ * nowhere means it doesn't exist anywhere we own. We deliberately don't
259
+ * retry transients here because findBucketRegion is itself a fallback
260
+ * called from createBucket's BucketAlreadyExists branch — a per-region
261
+ * blip just means "try the next region", not "give up".
262
+ * @param {string} bucketName
263
+ * @returns {Promise<string|null>} The region where the bucket was found, or null
264
+ */
265
+ async findBucketRegion(bucketName) {
266
+ for (const [region, endpoint] of Object.entries(HetznerS3Provider.ENDPOINTS)) {
267
+ try {
268
+ const client = new S3Client({
269
+ region,
270
+ endpoint,
271
+ forcePathStyle: true,
272
+ credentials: {
273
+ accessKeyId: this.accessKeyId,
274
+ secretAccessKey: this.secretAccessKey,
275
+ },
276
+ });
277
+ await client.send(new HeadBucketCommand({ Bucket: bucketName }));
278
+ return region;
279
+ } catch {
280
+ // Not in this region or not accessible — try next
281
+ }
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * Create a new bucket.
288
+ *
289
+ * Outer loop handles two operation-specific conditions that aren't
290
+ * just "Hetzner blipped":
291
+ * - BucketAlreadyExists: Hetzner reserves bucket names for ~30-90s
292
+ * after deletion; on a destroy→restore cycle we ride that window
293
+ * out with exponential backoff before declaring cross-account
294
+ * collision.
295
+ * - BucketAlreadyOwnedByYou: success-equivalent — return early.
296
+ * Transient errors fall through to the same loop's backoff, courtesy
297
+ * of `_send` (with maxAttempts=1 inside) so we don't double-retry.
298
+ * @param {string} bucketName - Bucket name (must be globally unique)
299
+ * @returns {Promise<{name: string, created: boolean}>}
300
+ */
301
+ async createBucket(bucketName) {
302
+ if (await this.bucketExists(bucketName)) {
303
+ return { name: bucketName, created: false, message: 'Bucket already exists' };
304
+ }
305
+
306
+ const command = new CreateBucketCommand({ Bucket: bucketName });
307
+ const maxAttempts = 5;
308
+ const backoffSeconds = [10, 20, 30, 40, 50];
309
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
310
+ try {
311
+ await this._send(command, { maxAttempts: 1 });
312
+ return { name: bucketName, created: true };
313
+ } catch (error) {
314
+ if (error.name === 'BucketAlreadyOwnedByYou') {
315
+ return { name: bucketName, created: false, message: 'Bucket already exists' };
316
+ }
317
+ if (isTransientS3Error(error) && attempt < maxAttempts - 1) {
318
+ await new Promise((r) => setTimeout(r, backoffSeconds[attempt] * 1000));
319
+ continue;
320
+ }
321
+ if (error.name === 'BucketAlreadyExists') {
322
+ // Hetzner S3 bucket names are globally unique but region-scoped for access.
323
+ // HeadBucket returns 404 when checking a bucket that lives in a different region.
324
+ // Try all regions to find the bucket before assuming another account owns it.
325
+ const foundRegion = await this.findBucketRegion(bucketName);
326
+ if (foundRegion) {
327
+ this.region = foundRegion;
328
+ this.endpoint = HetznerS3Provider.ENDPOINTS[foundRegion];
329
+ this._client = null;
330
+ return { name: bucketName, created: false, message: 'Bucket already exists' };
331
+ }
332
+ if (attempt < maxAttempts - 1) {
333
+ // Eventual consistency window — probably our own prior delete
334
+ // hasn't fully propagated. Wait and retry.
335
+ await new Promise((r) => setTimeout(r, backoffSeconds[attempt] * 1000));
336
+ continue;
337
+ }
338
+ throw new Error(
339
+ `Bucket name "${bucketName}" is already taken by another account. Try a different name.`,
340
+ );
341
+ }
342
+ throw error;
343
+ }
344
+ }
345
+ // Unreachable; loop above either returns or throws.
346
+ throw new Error(`createBucket: unexpected exit after ${maxAttempts} attempts`);
347
+ }
348
+
349
+ /**
350
+ * Delete a bucket (must be empty).
351
+ * Outer loop handles BucketNotEmpty (eventual-consistency: object
352
+ * deletes from emptyAndDeleteBucket may not have fully propagated yet).
353
+ * Transient errors get retried by `_send` itself; we only wrap
354
+ * BucketNotEmpty in the operation-specific outer loop here.
355
+ * @param {string} bucketName - Bucket name
356
+ */
357
+ async deleteBucket(bucketName) {
358
+ for (let attempt = 0; attempt < 5; attempt++) {
359
+ try {
360
+ await this._send(new DeleteBucketCommand({ Bucket: bucketName }));
361
+ return;
362
+ } catch (err) {
363
+ const isNotEmpty = err.Code === 'BucketNotEmpty' || err.message?.includes('not empty');
364
+ if (!isNotEmpty || attempt === 4) throw err;
365
+ await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
366
+ }
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Empty all objects from a bucket and then delete it.
372
+ * Handles current objects, versioned objects/delete markers, and incomplete
373
+ * multipart uploads.
374
+ *
375
+ * Step errors that look like "API not supported by provider" (NotImplemented,
376
+ * MethodNotAllowed, 405/501) are tolerated and recorded as warnings — not all
377
+ * S3-compatible providers expose ListObjectVersions / multipart listing.
378
+ * Real errors (timeouts, 5xx, auth) propagate so callers can surface them
379
+ * instead of getting an opaque BucketNotEmpty downstream.
380
+ *
381
+ * If `deleteBucket` finally fails with BucketNotEmpty, the thrown error is
382
+ * enriched with a fresh listing summary so the operator can see exactly
383
+ * what's left blocking deletion.
384
+ *
385
+ * @param {string} bucketName
386
+ * @returns {Promise<{deleted: boolean, objectsRemoved: number, warnings: string[]}>}
387
+ */
388
+ async emptyAndDeleteBucket(bucketName) {
389
+ let objectsRemoved = 0;
390
+ const warnings = [];
391
+
392
+ // 1. Delete current objects via ListObjectsV2.
393
+ let continuationToken;
394
+ do {
395
+ const listResult = await this._send(
396
+ new ListObjectsV2Command({ Bucket: bucketName, ContinuationToken: continuationToken }),
397
+ );
398
+ if (listResult.Contents?.length > 0) {
399
+ await this._send(
400
+ new DeleteObjectsCommand({
401
+ Bucket: bucketName,
402
+ Delete: { Objects: listResult.Contents.map((obj) => ({ Key: obj.Key })) },
403
+ }),
404
+ );
405
+ objectsRemoved += listResult.Contents.length;
406
+ }
407
+ continuationToken = listResult.IsTruncated ? listResult.NextContinuationToken : undefined;
408
+ } while (continuationToken);
409
+
410
+ // 2. Delete all object versions and delete markers (required for versioned
411
+ // buckets — without this, DeleteBucket fails with BucketNotEmpty even after
412
+ // removing current objects).
413
+ try {
414
+ let keyMarker;
415
+ let versionIdMarker;
416
+ do {
417
+ const versionsResult = await this._send(
418
+ new ListObjectVersionsCommand({
419
+ Bucket: bucketName,
420
+ KeyMarker: keyMarker,
421
+ VersionIdMarker: versionIdMarker,
422
+ }),
423
+ );
424
+ const toDelete = [
425
+ ...(versionsResult.Versions || []).map((v) => ({ Key: v.Key, VersionId: v.VersionId })),
426
+ ...(versionsResult.DeleteMarkers || []).map((d) => ({
427
+ Key: d.Key,
428
+ VersionId: d.VersionId,
429
+ })),
430
+ ];
431
+ if (toDelete.length > 0) {
432
+ await this._send(
433
+ new DeleteObjectsCommand({ Bucket: bucketName, Delete: { Objects: toDelete } }),
434
+ );
435
+ objectsRemoved += toDelete.length;
436
+ }
437
+ keyMarker = versionsResult.IsTruncated ? versionsResult.NextKeyMarker : undefined;
438
+ versionIdMarker = versionsResult.IsTruncated
439
+ ? versionsResult.NextVersionIdMarker
440
+ : undefined;
441
+ } while (keyMarker);
442
+ } catch (err) {
443
+ if (HetznerS3Provider._isUnsupportedApiError(err)) {
444
+ warnings.push(`ListObjectVersions not supported by provider: ${err.name || err.message}`);
445
+ } else {
446
+ throw new Error(`Failed to list/delete object versions in ${bucketName}: ${err.message}`, {
447
+ cause: err,
448
+ });
449
+ }
450
+ }
451
+
452
+ // 3. Abort incomplete multipart uploads (can also prevent bucket deletion).
453
+ try {
454
+ const uploads = await this._send(new ListMultipartUploadsCommand({ Bucket: bucketName }));
455
+ for (const upload of uploads.Uploads || []) {
456
+ await this._send(
457
+ new AbortMultipartUploadCommand({
458
+ Bucket: bucketName,
459
+ Key: upload.Key,
460
+ UploadId: upload.UploadId,
461
+ }),
462
+ );
463
+ }
464
+ } catch (err) {
465
+ if (HetznerS3Provider._isUnsupportedApiError(err)) {
466
+ warnings.push(`ListMultipartUploads not supported by provider: ${err.name || err.message}`);
467
+ } else {
468
+ throw new Error(`Failed to abort multipart uploads in ${bucketName}: ${err.message}`, {
469
+ cause: err,
470
+ });
471
+ }
472
+ }
473
+
474
+ // Now delete the empty bucket. On BucketNotEmpty (eventual consistency
475
+ // OR a step above silently undercounted), enrich the error with a fresh
476
+ // listing summary so operators don't have to open the Hetzner console
477
+ // to figure out what's left.
478
+ try {
479
+ await this.deleteBucket(bucketName);
480
+ } catch (err) {
481
+ const isNotEmpty = err.Code === 'BucketNotEmpty' || /not empty/i.test(err.message ?? '');
482
+ if (!isNotEmpty) throw err;
483
+ const summary = await this._summarizeBucketContents(bucketName);
484
+ const enriched = new Error(`${bucketName} is not empty after cleanup: ${summary}`);
485
+ enriched.cause = err;
486
+ enriched.bucketSummary = summary;
487
+ throw enriched;
488
+ }
489
+ return { deleted: true, objectsRemoved, warnings };
490
+ }
491
+
492
+ /**
493
+ * Best-effort listing of remaining bucket contents for diagnostics. Used
494
+ * when DeleteBucket fails with BucketNotEmpty — returns a one-line summary
495
+ * like `47 objects (e.g. .placeholder, public/avatar.png), 3 versions + 1
496
+ * delete marker, 2 incomplete multipart uploads`. Each section is
497
+ * independently try/caught so partial visibility beats none.
498
+ * @param {string} bucketName
499
+ * @returns {Promise<string>}
500
+ */
501
+ async _summarizeBucketContents(bucketName) {
502
+ const parts = [];
503
+
504
+ try {
505
+ const r = await this._send(new ListObjectsV2Command({ Bucket: bucketName, MaxKeys: 5 }), {
506
+ maxAttempts: 1,
507
+ });
508
+ const count = r.KeyCount ?? r.Contents?.length ?? 0;
509
+ if (count > 0) {
510
+ const examples = (r.Contents ?? [])
511
+ .slice(0, 3)
512
+ .map((o) => o.Key)
513
+ .join(', ');
514
+ const more = r.IsTruncated ? '+' : '';
515
+ parts.push(`${count}${more} objects (e.g. ${examples})`);
516
+ }
517
+ } catch (err) {
518
+ parts.push(`object listing failed: ${err.name || err.message}`);
519
+ }
520
+
521
+ try {
522
+ const r = await this._send(
523
+ new ListObjectVersionsCommand({ Bucket: bucketName, MaxKeys: 5 }),
524
+ { maxAttempts: 1 },
525
+ );
526
+ const versionCount = (r.Versions ?? []).length;
527
+ const markerCount = (r.DeleteMarkers ?? []).length;
528
+ if (versionCount + markerCount > 0) {
529
+ parts.push(
530
+ `${versionCount} version${versionCount === 1 ? '' : 's'} + ${markerCount} delete marker${markerCount === 1 ? '' : 's'}`,
531
+ );
532
+ }
533
+ } catch {
534
+ // versioning API absent — covered by primary listing
535
+ }
536
+
537
+ try {
538
+ const r = await this._send(new ListMultipartUploadsCommand({ Bucket: bucketName }), {
539
+ maxAttempts: 1,
540
+ });
541
+ const uploadCount = (r.Uploads ?? []).length;
542
+ if (uploadCount > 0) {
543
+ parts.push(`${uploadCount} incomplete multipart upload${uploadCount === 1 ? '' : 's'}`);
544
+ }
545
+ } catch {
546
+ // multipart API absent — covered above
547
+ }
548
+
549
+ return parts.length > 0 ? parts.join(', ') : 'unable to list remaining contents';
550
+ }
551
+
552
+ /**
553
+ * Heuristic: does this S3 error mean "the provider doesn't expose this API"
554
+ * vs. a transient or auth failure that we should surface? AWS standard
555
+ * error codes plus a few HTTP status fallbacks for non-AWS providers.
556
+ */
557
+ static _isUnsupportedApiError(err) {
558
+ const name = err?.name ?? '';
559
+ const code = err?.Code ?? '';
560
+ const status = err?.$metadata?.httpStatusCode;
561
+ if (name === 'NotImplemented' || code === 'NotImplemented') return true;
562
+ if (name === 'MethodNotAllowed' || code === 'MethodNotAllowed') return true;
563
+ if (status === 405 || status === 501) return true;
564
+ return false;
565
+ }
566
+
567
+ /**
568
+ * Configure CORS policy for browser uploads
569
+ * @param {string} bucketName - Bucket name
570
+ * @param {string[]} [allowedOrigins=['*']] - Allowed origins
571
+ * @returns {Promise<void>}
572
+ */
573
+ async configureCORS(bucketName, allowedOrigins = ['*']) {
574
+ const command = new PutBucketCorsCommand({
575
+ Bucket: bucketName,
576
+ CORSConfiguration: {
577
+ CORSRules: [
578
+ {
579
+ AllowedHeaders: ['*'],
580
+ AllowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'],
581
+ AllowedOrigins: allowedOrigins,
582
+ ExposeHeaders: ['ETag', 'Content-Length', 'Content-Type'],
583
+ MaxAgeSeconds: 3600,
584
+ },
585
+ ],
586
+ },
587
+ });
588
+ await this._send(command);
589
+ }
590
+
591
+ /**
592
+ * Get the S3 endpoint URL for this region
593
+ * @returns {string}
594
+ */
595
+ getEndpoint() {
596
+ return this.endpoint;
597
+ }
598
+
599
+ /**
600
+ * Get all available regions
601
+ * @returns {Object<string, string>}
602
+ */
603
+ static getRegions() {
604
+ return HetznerS3Provider.REGIONS;
605
+ }
606
+
607
+ /**
608
+ * Get endpoint for a specific region
609
+ * @param {string} region - Region ID
610
+ * @returns {string|null}
611
+ */
612
+ static getEndpointForRegion(region) {
613
+ return HetznerS3Provider.ENDPOINTS[region] || null;
614
+ }
615
+
616
+ /**
617
+ * Resolve the nearest S3 region for a given deployment region.
618
+ * Returns the region itself if S3 is available there, otherwise maps to the closest one.
619
+ */
620
+ static resolveS3Region(deployRegion) {
621
+ if (HetznerS3Provider.ENDPOINTS[deployRegion]) return deployRegion;
622
+
623
+ // Map non-S3 regions to nearest S3 region
624
+ // hel1 is the only non-German EU S3 region, so EU regions map to fsn1 (central Germany)
625
+ // US regions also map to nearest EU S3 (no US S3 available)
626
+ const regionMap = {
627
+ ash: 'fsn1', // Ashburn, VA → Falkenstein (lowest latency EU)
628
+ hil: 'fsn1', // Hillsboro, OR → Falkenstein
629
+ sin: 'hel1', // Singapore → Helsinki (if ever added)
630
+ };
631
+ return regionMap[deployRegion] || 'fsn1';
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Sanitize a project name into a valid S3 bucket name
637
+ * S3 bucket naming rules:
638
+ * - 3-63 characters
639
+ * - Lowercase letters, numbers, and hyphens only
640
+ * - Must start and end with letter or number
641
+ * - No consecutive periods or adjacent period/hyphen
642
+ *
643
+ * @param {string} projectName - Project name to sanitize
644
+ * @param {string} [suffix='storage'] - Suffix to append
645
+ * @returns {string} - Valid bucket name
646
+ */
647
+ export function sanitizeBucketName(projectName, suffix = 'storage') {
648
+ const baseName = suffix ? `${projectName}-${suffix}` : projectName;
649
+
650
+ return baseName
651
+ .toLowerCase()
652
+ .replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens
653
+ .replace(/-+/g, '-') // Collapse multiple hyphens
654
+ .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
655
+ .slice(0, 63); // Truncate to max length
656
+ }