devpush 0.0.0-stage → 1.0.0-beta.4

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 (456) hide show
  1. package/README.md +22 -2
  2. package/agent-release.json +37 -0
  3. package/dist/package/LICENSE.md +21 -0
  4. package/dist/package/migrate.mjs +182 -0
  5. package/dist/package/plugins.mjs +125 -0
  6. package/dist/package/vite.mjs +439 -0
  7. package/package.json +168 -3
  8. package/public/apple-touch-icon.png +0 -0
  9. package/public/favicon.svg +1 -0
  10. package/public/logo-email.png +0 -0
  11. package/public/robots.txt +3 -0
  12. package/public/social.png +0 -0
  13. package/src/cloudflare-runtime.d.ts +78 -0
  14. package/src/components/about-dialog.tsx +70 -0
  15. package/src/components/copy-button.tsx +74 -0
  16. package/src/components/logo-mark.tsx +20 -0
  17. package/src/components/pending-collection.tsx +103 -0
  18. package/src/components/resource-avatar.tsx +65 -0
  19. package/src/components/route-error-state.tsx +95 -0
  20. package/src/components/section-navigation.tsx +39 -0
  21. package/src/components/settings-layout.tsx +111 -0
  22. package/src/components/storage-capabilities.tsx +11 -0
  23. package/src/components/table-pager.tsx +101 -0
  24. package/src/components/ui/alert-dialog.tsx +180 -0
  25. package/src/components/ui/alert.tsx +78 -0
  26. package/src/components/ui/avatar.tsx +58 -0
  27. package/src/components/ui/badge.tsx +49 -0
  28. package/src/components/ui/button-group.tsx +38 -0
  29. package/src/components/ui/button.tsx +67 -0
  30. package/src/components/ui/card.tsx +82 -0
  31. package/src/components/ui/chart.tsx +307 -0
  32. package/src/components/ui/checkbox.tsx +30 -0
  33. package/src/components/ui/combobox.tsx +256 -0
  34. package/src/components/ui/dialog.tsx +163 -0
  35. package/src/components/ui/dropdown-menu.tsx +110 -0
  36. package/src/components/ui/empty.tsx +65 -0
  37. package/src/components/ui/field.tsx +192 -0
  38. package/src/components/ui/input-group.tsx +118 -0
  39. package/src/components/ui/input-otp.tsx +70 -0
  40. package/src/components/ui/input.tsx +19 -0
  41. package/src/components/ui/label.tsx +24 -0
  42. package/src/components/ui/pagination.tsx +34 -0
  43. package/src/components/ui/popover.tsx +86 -0
  44. package/src/components/ui/select.tsx +145 -0
  45. package/src/components/ui/skeleton.tsx +19 -0
  46. package/src/components/ui/sonner.tsx +47 -0
  47. package/src/components/ui/switch.tsx +31 -0
  48. package/src/components/ui/table.tsx +81 -0
  49. package/src/components/ui/textarea.tsx +18 -0
  50. package/src/components/ui/tooltip.tsx +55 -0
  51. package/src/features/admin/admin-settings.tsx +1048 -0
  52. package/src/features/auth/current-user-context-query.ts +11 -0
  53. package/src/features/auth/login-form.tsx +442 -0
  54. package/src/features/auth/sign-out-menu-item.tsx +42 -0
  55. package/src/features/deployments/deployment-actions.tsx +336 -0
  56. package/src/features/deployments/deployment-alias-badge.tsx +35 -0
  57. package/src/features/deployments/deployment-detail.tsx +596 -0
  58. package/src/features/deployments/deployment-filters.ts +66 -0
  59. package/src/features/deployments/deployment-list.tsx +465 -0
  60. package/src/features/deployments/deployment-visit-links.tsx +104 -0
  61. package/src/features/deployments/deployments-page.tsx +376 -0
  62. package/src/features/logs/log-scroll-area.tsx +69 -0
  63. package/src/features/notifications/email-preferences.tsx +232 -0
  64. package/src/features/onboarding/profile-onboarding.tsx +162 -0
  65. package/src/features/plugins/contributions.tsx +118 -0
  66. package/src/features/projects/backup-job-logs.tsx +143 -0
  67. package/src/features/projects/backup-policy-delete-dialog.tsx +82 -0
  68. package/src/features/projects/backup-restore-actions.tsx +573 -0
  69. package/src/features/projects/backup-status-icon.tsx +33 -0
  70. package/src/features/projects/backup-target-list.tsx +222 -0
  71. package/src/features/projects/backup-timestamp.tsx +32 -0
  72. package/src/features/projects/backup-volume-picker.tsx +158 -0
  73. package/src/features/projects/compose-service-input.tsx +64 -0
  74. package/src/features/projects/deploy-readiness.ts +28 -0
  75. package/src/features/projects/environment-dot.tsx +91 -0
  76. package/src/features/projects/log-keyword-filter.tsx +48 -0
  77. package/src/features/projects/manual-deploy-dialog.tsx +563 -0
  78. package/src/features/projects/pending-section.ts +26 -0
  79. package/src/features/projects/project-backup-details-pending.tsx +305 -0
  80. package/src/features/projects/project-backup-details.tsx +936 -0
  81. package/src/features/projects/project-backup-policy.tsx +644 -0
  82. package/src/features/projects/project-backup-utils.ts +190 -0
  83. package/src/features/projects/project-backups-pending.tsx +136 -0
  84. package/src/features/projects/project-backups.tsx +876 -0
  85. package/src/features/projects/project-environment-settings.tsx +1128 -0
  86. package/src/features/projects/project-heading.tsx +68 -0
  87. package/src/features/projects/project-layout.tsx +188 -0
  88. package/src/features/projects/project-logs.tsx +461 -0
  89. package/src/features/projects/project-overview.tsx +280 -0
  90. package/src/features/projects/project-route-pending.tsx +1064 -0
  91. package/src/features/projects/project-routing-settings.tsx +663 -0
  92. package/src/features/projects/project-settings-section.tsx +64 -0
  93. package/src/features/projects/project-settings-utils.ts +101 -0
  94. package/src/features/projects/project-settings.tsx +1472 -0
  95. package/src/features/projects/repository-picker-dialog.tsx +404 -0
  96. package/src/features/repositories/new-project-dialog-launcher.tsx +44 -0
  97. package/src/features/repositories/new-project-dialog.tsx +1204 -0
  98. package/src/features/repositories/new-project-pending.tsx +28 -0
  99. package/src/features/repositories/repository-list-pending.tsx +59 -0
  100. package/src/features/servers/server-availability-alert.tsx +44 -0
  101. package/src/features/servers/server-availability.ts +29 -0
  102. package/src/features/servers/server-avatar.tsx +52 -0
  103. package/src/features/servers/server-detail-pending.tsx +315 -0
  104. package/src/features/servers/server-detail.tsx +855 -0
  105. package/src/features/servers/server-metric-chart-pending.tsx +38 -0
  106. package/src/features/servers/server-metric-chart.tsx +182 -0
  107. package/src/features/servers/server-metric-format.ts +50 -0
  108. package/src/features/servers/server-metrics-pending.tsx +75 -0
  109. package/src/features/servers/server-metrics.tsx +136 -0
  110. package/src/features/servers/team-servers.tsx +432 -0
  111. package/src/features/settings/settings-route-pending.tsx +406 -0
  112. package/src/features/teams/backup-storage-settings.tsx +483 -0
  113. package/src/features/teams/navigation.ts +51 -0
  114. package/src/features/teams/new-team-dialog.tsx +132 -0
  115. package/src/features/teams/pending-section.ts +19 -0
  116. package/src/features/teams/team-header.tsx +1286 -0
  117. package/src/features/teams/team-heading.tsx +57 -0
  118. package/src/features/teams/team-home.tsx +391 -0
  119. package/src/features/teams/team-invite.tsx +155 -0
  120. package/src/features/teams/team-route-pending.tsx +505 -0
  121. package/src/features/teams/team-settings.tsx +1555 -0
  122. package/src/features/teams/use-workspace-updates.ts +126 -0
  123. package/src/features/users/api-keys-settings.tsx +544 -0
  124. package/src/features/users/api-scope-selection.tsx +269 -0
  125. package/src/features/users/connected-apps-settings.tsx +449 -0
  126. package/src/features/users/mcp-consent.tsx +220 -0
  127. package/src/features/users/profile-picture.tsx +171 -0
  128. package/src/features/users/user-settings.tsx +1064 -0
  129. package/src/lib/account-settings-schema.ts +78 -0
  130. package/src/lib/action-feedback.ts +81 -0
  131. package/src/lib/admin-search-schema.ts +8 -0
  132. package/src/lib/agent-release.d.mts +21 -0
  133. package/src/lib/agent-release.mjs +90 -0
  134. package/src/lib/allowlist-schema.ts +79 -0
  135. package/src/lib/api-key-schema.ts +176 -0
  136. package/src/lib/auth/client.ts +20 -0
  137. package/src/lib/avatar-schema.ts +11 -0
  138. package/src/lib/backup-cron.ts +118 -0
  139. package/src/lib/backup-database.ts +41 -0
  140. package/src/lib/backup-feature.ts +2 -0
  141. package/src/lib/backup-paths.ts +7 -0
  142. package/src/lib/backup-policy-schema.ts +85 -0
  143. package/src/lib/backup-resource-schema.ts +12 -0
  144. package/src/lib/backup-restore-schema.ts +15 -0
  145. package/src/lib/backup-storage-schema.ts +55 -0
  146. package/src/lib/deferred-module.ts +38 -0
  147. package/src/lib/deployment-logs-query.ts +63 -0
  148. package/src/lib/display-name.ts +7 -0
  149. package/src/lib/error-message.ts +119 -0
  150. package/src/lib/form-errors.ts +10 -0
  151. package/src/lib/internal-redirect.ts +25 -0
  152. package/src/lib/manual-deploy-options-query.ts +21 -0
  153. package/src/lib/notification-preferences.ts +43 -0
  154. package/src/lib/onboarding.ts +11 -0
  155. package/src/lib/operation-logs-query.ts +51 -0
  156. package/src/lib/page-head.ts +14 -0
  157. package/src/lib/project-context-query.ts +257 -0
  158. package/src/lib/project-creation-schema.ts +64 -0
  159. package/src/lib/project-env-schema.ts +23 -0
  160. package/src/lib/project-environments-schema.ts +56 -0
  161. package/src/lib/project-routing-schema.ts +94 -0
  162. package/src/lib/project-runtime-config.ts +131 -0
  163. package/src/lib/project-settings-schema.ts +75 -0
  164. package/src/lib/public-api-schema.ts +128 -0
  165. package/src/lib/public-error.ts +337 -0
  166. package/src/lib/query.ts +13 -0
  167. package/src/lib/repository-selection-query.ts +11 -0
  168. package/src/lib/resource-cache.ts +30 -0
  169. package/src/lib/runtime-id.ts +19 -0
  170. package/src/lib/search.ts +14 -0
  171. package/src/lib/server-context-query.ts +43 -0
  172. package/src/lib/server-name-schema.ts +11 -0
  173. package/src/lib/settings-conflict.ts +45 -0
  174. package/src/lib/settings-version.ts +7 -0
  175. package/src/lib/team-context-query.ts +126 -0
  176. package/src/lib/team-context-schema.ts +8 -0
  177. package/src/lib/team-forms-schema.ts +45 -0
  178. package/src/lib/team-role.ts +13 -0
  179. package/src/lib/team-settings-schema.ts +48 -0
  180. package/src/lib/urls.ts +3 -0
  181. package/src/lib/use-avatar-input.ts +61 -0
  182. package/src/lib/use-refresh-error.ts +18 -0
  183. package/src/lib/use-settings-version.ts +23 -0
  184. package/src/lib/utils.ts +6 -0
  185. package/src/lib/validation.ts +5 -0
  186. package/src/lib/workspace-updates.ts +53 -0
  187. package/src/package/config.ts +10 -0
  188. package/src/package/env.mjs +54 -0
  189. package/src/package/migrate-api.d.ts +5 -0
  190. package/src/package/migrate.ts +56 -0
  191. package/src/package/plugins.ts +141 -0
  192. package/src/package/sqlite.mjs +2 -0
  193. package/src/package/vite-api.d.ts +5 -0
  194. package/src/package/vite.ts +324 -0
  195. package/src/package/watch-guard.cjs +26 -0
  196. package/src/plugins/client.ts +57 -0
  197. package/src/plugins/contract.ts +188 -0
  198. package/src/plugins/default.server.ts +4 -0
  199. package/src/plugins/default.ui.ts +4 -0
  200. package/src/plugins/operations.ts +45 -0
  201. package/src/plugins/server.ts +197 -0
  202. package/src/plugins/version.ts +1 -0
  203. package/src/routeTree.gen.ts +1275 -0
  204. package/src/router.tsx +46 -0
  205. package/src/routes/$teamSlug.index.tsx +53 -0
  206. package/src/routes/$teamSlug.new-project.tsx +13 -0
  207. package/src/routes/$teamSlug.projects.$projectName.backups.$backupId.tsx +40 -0
  208. package/src/routes/$teamSlug.projects.$projectName.backups.tsx +82 -0
  209. package/src/routes/$teamSlug.projects.$projectName.deployments.$deploymentId.tsx +52 -0
  210. package/src/routes/$teamSlug.projects.$projectName.deployments.tsx +86 -0
  211. package/src/routes/$teamSlug.projects.$projectName.logs.tsx +55 -0
  212. package/src/routes/$teamSlug.projects.$projectName.settings.tsx +51 -0
  213. package/src/routes/$teamSlug.projects.$projectName.tsx +106 -0
  214. package/src/routes/$teamSlug.projects.tsx +40 -0
  215. package/src/routes/$teamSlug.servers.$serverId.tsx +31 -0
  216. package/src/routes/$teamSlug.servers.index.tsx +59 -0
  217. package/src/routes/$teamSlug.servers.tsx +7 -0
  218. package/src/routes/$teamSlug.settings.tsx +56 -0
  219. package/src/routes/$teamSlug.tsx +86 -0
  220. package/src/routes/[.]well-known.oauth-authorization-server.api.auth.ts +13 -0
  221. package/src/routes/[.]well-known.oauth-protected-resource.mcp.ts +6 -0
  222. package/src/routes/__root.tsx +155 -0
  223. package/src/routes/admin.tsx +55 -0
  224. package/src/routes/api/agents/certificates.ts +21 -0
  225. package/src/routes/api/agents/credentials/rotate.ts +22 -0
  226. package/src/routes/api/agents/heartbeat.ts +42 -0
  227. package/src/routes/api/agents/job-logs.ts +22 -0
  228. package/src/routes/api/agents/jobs/complete.ts +22 -0
  229. package/src/routes/api/agents/jobs/next.ts +29 -0
  230. package/src/routes/api/agents/jobs/progress.ts +32 -0
  231. package/src/routes/api/agents/jobs/status.ts +22 -0
  232. package/src/routes/api/agents/register.ts +45 -0
  233. package/src/routes/api/agents/runtime-logs.ts +22 -0
  234. package/src/routes/api/agents/switch.ts +25 -0
  235. package/src/routes/api/auth/$.ts +38 -0
  236. package/src/routes/api/cron/backups.ts +33 -0
  237. package/src/routes/api/cron/maintenance.ts +30 -0
  238. package/src/routes/api/github/install.callback.ts +51 -0
  239. package/src/routes/api/github/install.ts +41 -0
  240. package/src/routes/api/github/webhook.ts +11 -0
  241. package/src/routes/api/health.ts +19 -0
  242. package/src/routes/api/install/agent.$token.ts +50 -0
  243. package/src/routes/api/install/agent.binary.ts +83 -0
  244. package/src/routes/api/install/agent.checksum.ts +45 -0
  245. package/src/routes/api/install/agent.ts +26 -0
  246. package/src/routes/api/plugins/$.ts +11 -0
  247. package/src/routes/api/v1/$.ts +15 -0
  248. package/src/routes/api/workspaces/updates.ts +8 -0
  249. package/src/routes/auth.login.tsx +50 -0
  250. package/src/routes/deployments.$deploymentId.unavailable.tsx +21 -0
  251. package/src/routes/index.tsx +23 -0
  252. package/src/routes/invites.$token.tsx +35 -0
  253. package/src/routes/mcp.ts +13 -0
  254. package/src/routes/oauth.consent.tsx +54 -0
  255. package/src/routes/onboarding.tsx +37 -0
  256. package/src/routes/onboarding.workspace.tsx +44 -0
  257. package/src/routes/settings.tsx +30 -0
  258. package/src/routes/uploads/avatars/$fileName.ts +44 -0
  259. package/src/server/admin/access.ts +67 -0
  260. package/src/server/admin/functions.ts +122 -0
  261. package/src/server/admin/service.ts +260 -0
  262. package/src/server/agents/backup-state.ts +210 -0
  263. package/src/server/agents/certificates.ts +96 -0
  264. package/src/server/agents/claims.ts +116 -0
  265. package/src/server/agents/cleanup.ts +52 -0
  266. package/src/server/agents/compatibility.ts +116 -0
  267. package/src/server/agents/completion.ts +83 -0
  268. package/src/server/agents/container-cleanup-state.ts +51 -0
  269. package/src/server/agents/deployment-state.ts +111 -0
  270. package/src/server/agents/job-payloads.ts +159 -0
  271. package/src/server/agents/jobs.ts +1293 -0
  272. package/src/server/agents/protocol.ts +400 -0
  273. package/src/server/agents/reconciliation.ts +43 -0
  274. package/src/server/agents/runtime-logs.ts +337 -0
  275. package/src/server/agents/runtime-state.ts +125 -0
  276. package/src/server/api/admission.ts +12 -0
  277. package/src/server/api/authorization.ts +115 -0
  278. package/src/server/api/credentials.ts +569 -0
  279. package/src/server/api/cursors.ts +72 -0
  280. package/src/server/api/errors.ts +24 -0
  281. package/src/server/api/functions.ts +60 -0
  282. package/src/server/api/handler.ts +274 -0
  283. package/src/server/api/mutation-operations.ts +913 -0
  284. package/src/server/api/operation-context.ts +139 -0
  285. package/src/server/api/operation-contract.ts +1099 -0
  286. package/src/server/api/operations.ts +64 -0
  287. package/src/server/api/read-operations.ts +891 -0
  288. package/src/server/api/retention.ts +33 -0
  289. package/src/server/auth/access.ts +109 -0
  290. package/src/server/auth/auth-methods.ts +28 -0
  291. package/src/server/auth/better-auth.ts +518 -0
  292. package/src/server/auth/context.ts +28 -0
  293. package/src/server/auth/email-change.ts +47 -0
  294. package/src/server/auth/email-delivery.ts +51 -0
  295. package/src/server/auth/provider-identity.ts +19 -0
  296. package/src/server/auth/provider-tokens.ts +27 -0
  297. package/src/server/auth/session.server.ts +19 -0
  298. package/src/server/backups/database-commands.ts +40 -0
  299. package/src/server/backups/destination.ts +12 -0
  300. package/src/server/backups/feature.ts +6 -0
  301. package/src/server/backups/policy-validation.ts +51 -0
  302. package/src/server/backups/queue-state.ts +137 -0
  303. package/src/server/backups/service.ts +918 -0
  304. package/src/server/backups/storage-functions.ts +42 -0
  305. package/src/server/backups/storage-state.ts +67 -0
  306. package/src/server/backups/storage.ts +237 -0
  307. package/src/server/config.ts +274 -0
  308. package/src/server/db/atomic.ts +16 -0
  309. package/src/server/db/d1-connection.ts +83 -0
  310. package/src/server/db/errors.ts +17 -0
  311. package/src/server/db/index.cloudflare.ts +15 -0
  312. package/src/server/db/index.ts +35 -0
  313. package/src/server/db/libsql-connection.ts +72 -0
  314. package/src/server/db/log-schema.ts +102 -0
  315. package/src/server/db/migrate-libsql.ts +29 -0
  316. package/src/server/db/migrate-sqlite.ts +47 -0
  317. package/src/server/db/migration-history.ts +12 -0
  318. package/src/server/db/migrations-logs/0000_initial.sql +48 -0
  319. package/src/server/db/migrations-logs/0001_runtime_log_order.sql +2 -0
  320. package/src/server/db/migrations-logs/meta/0000_snapshot.json +327 -0
  321. package/src/server/db/migrations-logs/meta/0001_snapshot.json +329 -0
  322. package/src/server/db/migrations-logs/meta/_journal.json +20 -0
  323. package/src/server/db/migrations-sqlite/0000_initial.sql +894 -0
  324. package/src/server/db/migrations-sqlite/0001_agent_indexes.sql +5 -0
  325. package/src/server/db/migrations-sqlite/meta/0000_snapshot.json +5816 -0
  326. package/src/server/db/migrations-sqlite/meta/0001_snapshot.json +5841 -0
  327. package/src/server/db/migrations-sqlite/meta/_journal.json +20 -0
  328. package/src/server/db/oauth-schema.ts +216 -0
  329. package/src/server/db/remote-config.ts +47 -0
  330. package/src/server/db/schema.ts +1218 -0
  331. package/src/server/db/sqlite-connection.ts +94 -0
  332. package/src/server/db/sqlite-files.ts +27 -0
  333. package/src/server/db/sqlite-params.ts +19 -0
  334. package/src/server/db/worker-connection.ts +47 -0
  335. package/src/server/deleted-resources.ts +133 -0
  336. package/src/server/deletion-recovery.ts +272 -0
  337. package/src/server/email/layout.tsx +85 -0
  338. package/src/server/email/notification-template.tsx +62 -0
  339. package/src/server/email/otp-template.tsx +48 -0
  340. package/src/server/email/service.ts +251 -0
  341. package/src/server/email/team-invite-template.tsx +59 -0
  342. package/src/server/email/theme.ts +10 -0
  343. package/src/server/github/http.ts +14 -0
  344. package/src/server/github/install-state.ts +48 -0
  345. package/src/server/github/service.ts +831 -0
  346. package/src/server/github/webhook-handler.ts +198 -0
  347. package/src/server/github/webhooks.ts +205 -0
  348. package/src/server/http/auth.ts +11 -0
  349. package/src/server/http/error-diagnostics.ts +66 -0
  350. package/src/server/http/plugin-request.ts +15 -0
  351. package/src/server/http/proxy-request.ts +53 -0
  352. package/src/server/http/public-errors.ts +48 -0
  353. package/src/server/http/rate-limit.ts +59 -0
  354. package/src/server/http/request-body.ts +54 -0
  355. package/src/server/http/request-ip.ts +54 -0
  356. package/src/server/http/security-headers.ts +36 -0
  357. package/src/server/install/agent-binary.ts +230 -0
  358. package/src/server/install/agent-script.ts +565 -0
  359. package/src/server/install/release-binary.ts +65 -0
  360. package/src/server/logs/budget.ts +72 -0
  361. package/src/server/logs/cleanup.ts +64 -0
  362. package/src/server/logs/deployments.ts +98 -0
  363. package/src/server/logs/operations.ts +145 -0
  364. package/src/server/logs/retention.ts +39 -0
  365. package/src/server/maintenance.ts +61 -0
  366. package/src/server/mcp/connections.ts +358 -0
  367. package/src/server/mcp/functions.ts +63 -0
  368. package/src/server/mcp/handler.ts +295 -0
  369. package/src/server/mcp/oauth-discovery.cloudflare.ts +6 -0
  370. package/src/server/mcp/oauth-discovery.ts +8 -0
  371. package/src/server/mcp/oauth-provider.ts +220 -0
  372. package/src/server/mcp/urls.ts +20 -0
  373. package/src/server/notifications/functions.ts +51 -0
  374. package/src/server/notifications/observe.ts +303 -0
  375. package/src/server/notifications/outbox.ts +335 -0
  376. package/src/server/notifications/preferences.ts +131 -0
  377. package/src/server/notifications/query.ts +16 -0
  378. package/src/server/plugins/scope.ts +17 -0
  379. package/src/server/plugins/server-context.ts +157 -0
  380. package/src/server/plugins/services.server.ts +10 -0
  381. package/src/server/plugins/workspace-access.ts +74 -0
  382. package/src/server/projects/backup-policy-state.ts +68 -0
  383. package/src/server/projects/backups.ts +219 -0
  384. package/src/server/projects/compose-metadata.ts +174 -0
  385. package/src/server/projects/configuration-state.ts +90 -0
  386. package/src/server/projects/context.ts +909 -0
  387. package/src/server/projects/creation-state.ts +46 -0
  388. package/src/server/projects/deletion-state.ts +69 -0
  389. package/src/server/projects/deployment-config.ts +139 -0
  390. package/src/server/projects/deployment-logs.ts +97 -0
  391. package/src/server/projects/deployments.ts +1123 -0
  392. package/src/server/projects/domain-state.ts +71 -0
  393. package/src/server/projects/environment-branches.ts +75 -0
  394. package/src/server/projects/environment-state.ts +158 -0
  395. package/src/server/projects/environments.ts +424 -0
  396. package/src/server/projects/functions.ts +748 -0
  397. package/src/server/projects/generated-hostname.ts +77 -0
  398. package/src/server/projects/hostnames.ts +244 -0
  399. package/src/server/projects/mappers.ts +457 -0
  400. package/src/server/projects/names.ts +41 -0
  401. package/src/server/projects/project-env-vars.ts +28 -0
  402. package/src/server/projects/queue-state.ts +118 -0
  403. package/src/server/projects/repository.ts +368 -0
  404. package/src/server/projects/route-state.ts +37 -0
  405. package/src/server/projects/runtime-logs.ts +97 -0
  406. package/src/server/projects/settings-context.ts +58 -0
  407. package/src/server/projects/settings.ts +738 -0
  408. package/src/server/projects/types.ts +355 -0
  409. package/src/server/realtime/agent-events.ts +32 -0
  410. package/src/server/realtime/node-broadcaster.ts +71 -0
  411. package/src/server/realtime/node-handler.ts +55 -0
  412. package/src/server/realtime/service.ts +125 -0
  413. package/src/server/realtime/workspace-object.cloudflare.ts +62 -0
  414. package/src/server/realtime/workspace-object.ts +3 -0
  415. package/src/server/resources/color.ts +13 -0
  416. package/src/server/runtime/background.ts +21 -0
  417. package/src/server/runtime/node-asset-cache-plugin.ts +12 -0
  418. package/src/server/runtime/node-scheduler-plugin.ts +17 -0
  419. package/src/server/runtime/node-scheduler.ts +51 -0
  420. package/src/server/runtime/platform-env.cloudflare.ts +17 -0
  421. package/src/server/runtime/platform-env.ts +19 -0
  422. package/src/server/runtime/scheduled-task-error.ts +9 -0
  423. package/src/server/scheduled-tasks.ts +28 -0
  424. package/src/server/secrets/crypto.ts +56 -0
  425. package/src/server/servers/functions.ts +233 -0
  426. package/src/server/servers/lifecycle.ts +122 -0
  427. package/src/server/servers/metric-history-schema.ts +24 -0
  428. package/src/server/servers/metric-history.ts +55 -0
  429. package/src/server/servers/monitoring.ts +592 -0
  430. package/src/server/servers/service.ts +1211 -0
  431. package/src/server/servers/status.ts +32 -0
  432. package/src/server/servers/switch.ts +96 -0
  433. package/src/server/settings-version.ts +43 -0
  434. package/src/server/storage/capabilities.ts +6 -0
  435. package/src/server/storage/local-object-store.cloudflare.ts +7 -0
  436. package/src/server/storage/local-object-store.ts +50 -0
  437. package/src/server/storage/object-store.ts +160 -0
  438. package/src/server/teams/creation.ts +51 -0
  439. package/src/server/teams/delete-policy.ts +13 -0
  440. package/src/server/teams/deletion-state.ts +80 -0
  441. package/src/server/teams/functions.ts +324 -0
  442. package/src/server/teams/invitations.ts +38 -0
  443. package/src/server/teams/membership.ts +86 -0
  444. package/src/server/teams/queries.ts +261 -0
  445. package/src/server/teams/service.ts +1127 -0
  446. package/src/server/teams/types.ts +101 -0
  447. package/src/server/uploads/avatar.ts +133 -0
  448. package/src/server/users/deletion-state.ts +93 -0
  449. package/src/server/users/functions.ts +160 -0
  450. package/src/server/users/onboarding.ts +17 -0
  451. package/src/server/users/service.ts +644 -0
  452. package/src/server.ts +55 -0
  453. package/src/styles/shadcn.css +653 -0
  454. package/src/styles/theme.css +76 -0
  455. package/src/styles.css +177 -0
  456. package/wrangler.template.jsonc +37 -0
@@ -0,0 +1,1127 @@
1
+ import { withWorkspaceAccess } from '#devpush/server/plugins/workspace-access'
2
+ import { PublicError, getPublicErrorMessage } from '#devpush/lib/public-error'
3
+ import { toPublicError } from '#devpush/server/http/public-errors'
4
+ import { bootstrapOwnedTeam, insertOwnedTeam } from './creation'
5
+ import { acceptInvitation } from './invitations'
6
+ import { mutateTeamMembership, mutateTeamOwnership } from './membership'
7
+ import { isUniqueConstraintError } from '#devpush/server/db/errors'
8
+ import { and, count, desc, eq, gt, inArray, lte, ne, sql } from 'drizzle-orm'
9
+
10
+ import {
11
+ canManageTeamMembers,
12
+ canManageTeamSettings,
13
+ } from '#devpush/lib/team-role'
14
+ import type { TeamRole } from '#devpush/lib/team-role'
15
+ import { config } from '#devpush/server/config'
16
+ import { db } from '#devpush/server/db'
17
+ import { deleteTeamState } from './deletion-state'
18
+ import {
19
+ teamInvites,
20
+ teamMembers,
21
+ teams,
22
+ user,
23
+ } from '#devpush/server/db/schema'
24
+ import { sendTeamInviteEmail } from '#devpush/server/email/service'
25
+ import { getResourceColor } from '#devpush/server/resources/color'
26
+ import {
27
+ removeOwnedAvatar,
28
+ resolveAvatarUpdate,
29
+ } from '#devpush/server/uploads/avatar'
30
+ import { canDeleteTeam } from './delete-policy'
31
+ import { isInstallationAdmin } from '#devpush/server/admin/access'
32
+ import {
33
+ listTeamInvites,
34
+ listTeamMembers,
35
+ listUserPendingTeamInvites,
36
+ } from './queries'
37
+ import type { TeamInviteSummary, TeamSummary, UserContext } from './types'
38
+
39
+ export type { TeamSummary, UserContext } from './types'
40
+
41
+ const forbiddenTeamSlugs = new Set([
42
+ 'admin',
43
+ 'api',
44
+ 'assets',
45
+ 'auth',
46
+ 'deployment-not-found',
47
+ 'health',
48
+ 'invites',
49
+ 'mcp',
50
+ 'new-team',
51
+ 'oauth',
52
+ 'settings',
53
+ 'setup',
54
+ 'upload',
55
+ 'uploads',
56
+ 'user',
57
+ ])
58
+
59
+ export async function ensureUserBootstrap(userId: string) {
60
+ const [currentUser] = await db
61
+ .select()
62
+ .from(user)
63
+ .where(eq(user.id, userId))
64
+ .limit(1)
65
+ if (!currentUser || currentUser.status !== 'active')
66
+ throw new PublicError('ACCOUNT_NOT_FOUND')
67
+ const teamName =
68
+ currentUser.name || currentUser.email.split('@')[0] || currentUser.email
69
+ if (
70
+ !(await bootstrapOwnedTeam(db, {
71
+ userId,
72
+ teamId: crypto.randomUUID(),
73
+ name: teamName,
74
+ baseSlug: slugify(teamName),
75
+ }))
76
+ )
77
+ throw new PublicError('ACCOUNT_NOT_FOUND')
78
+ const [updatedUser] = await db
79
+ .select({ defaultTeamId: user.defaultTeamId })
80
+ .from(user)
81
+ .where(eq(user.id, userId))
82
+ .limit(1)
83
+ if (!updatedUser?.defaultTeamId) throw new Error('Team could not be created.')
84
+ return updatedUser.defaultTeamId
85
+ }
86
+
87
+ export async function getUserContext(userId: string): Promise<UserContext> {
88
+ const [currentUserRows, membershipRows, invites] = await Promise.all([
89
+ db.select().from(user).where(eq(user.id, userId)).limit(1),
90
+ listUserTeamMemberships(userId),
91
+ listUserPendingTeamInvites(userId),
92
+ ])
93
+ let currentUser = currentUserRows.at(0)
94
+
95
+ if (!currentUser || currentUser.status !== 'active') {
96
+ throw new PublicError('ACCOUNT_NOT_FOUND')
97
+ }
98
+
99
+ let memberships = membershipRows
100
+
101
+ // Account creation performs this bootstrap in the auth hook. Keep a recovery
102
+ // path for old or partially-created accounts without writing on normal reads.
103
+ if (memberships.length === 0) {
104
+ await ensureUserBootstrap(userId)
105
+ ;[currentUser, memberships] = await Promise.all([
106
+ db
107
+ .select()
108
+ .from(user)
109
+ .where(eq(user.id, userId))
110
+ .limit(1)
111
+ .then((rows) => rows.at(0)),
112
+ listUserTeamMemberships(userId),
113
+ ])
114
+ }
115
+
116
+ if (!currentUser || currentUser.status !== 'active') {
117
+ throw new PublicError('ACCOUNT_NOT_FOUND')
118
+ }
119
+
120
+ if (memberships.length === 0) {
121
+ throw new Error('User has no teams')
122
+ }
123
+
124
+ const mappedTeams = memberships.map((team) => ({
125
+ ...team,
126
+ color: getResourceColor(team.id),
127
+ }))
128
+
129
+ const defaultTeam =
130
+ mappedTeams.find((team) => team.id === currentUser.defaultTeamId) ??
131
+ mappedTeams[0]
132
+ if (!defaultTeam) throw new Error('User has no teams')
133
+
134
+ return {
135
+ user: {
136
+ profileConfirmedAt: currentUser.profileConfirmedAt?.getTime() ?? null,
137
+ settingsVersion: currentUser.settingsVersion,
138
+ id: currentUser.id,
139
+ name: currentUser.name,
140
+ username: currentUser.username,
141
+ email: currentUser.email,
142
+ image: currentUser.image,
143
+ defaultTeamId: currentUser.defaultTeamId,
144
+ isInstallationAdmin: await isInstallationAdmin({
145
+ id: currentUser.id,
146
+ email: currentUser.email,
147
+ }),
148
+ },
149
+ teams: mappedTeams,
150
+ defaultTeam,
151
+ invites,
152
+ }
153
+ }
154
+
155
+ function listUserTeamMemberships(userId: string) {
156
+ return db
157
+ .select({
158
+ settingsVersion: teams.settingsVersion,
159
+ id: teams.id,
160
+ slug: teams.slug,
161
+ name: teams.name,
162
+ image: teams.avatarPath,
163
+ role: teamMembers.role,
164
+ })
165
+ .from(teamMembers)
166
+ .innerJoin(teams, eq(teams.id, teamMembers.teamId))
167
+ .where(and(eq(teamMembers.userId, userId), eq(teams.status, 'active')))
168
+ .orderBy(desc(teams.updatedAt))
169
+ }
170
+
171
+ export async function requireTeam(
172
+ userId: string,
173
+ teamSlug: string,
174
+ teamId?: string,
175
+ ) {
176
+ const teamRows = await db
177
+ .select({
178
+ settingsVersion: teams.settingsVersion,
179
+ id: teams.id,
180
+ slug: teams.slug,
181
+ name: teams.name,
182
+ image: teams.avatarPath,
183
+ role: teamMembers.role,
184
+ })
185
+ .from(teamMembers)
186
+ .innerJoin(teams, eq(teams.id, teamMembers.teamId))
187
+ .innerJoin(user, eq(user.id, teamMembers.userId))
188
+ .where(
189
+ and(
190
+ eq(teamMembers.userId, userId),
191
+ teamId ? eq(teams.id, teamId) : eq(teams.slug, teamSlug),
192
+ eq(teams.status, 'active'),
193
+ eq(user.status, 'active'),
194
+ ),
195
+ )
196
+ .limit(1)
197
+ const team = teamRows.at(0)
198
+
199
+ if (!team) {
200
+ throw new PublicError('TEAM_NOT_FOUND')
201
+ }
202
+
203
+ return {
204
+ ...team,
205
+ color: getResourceColor(team.id),
206
+ }
207
+ }
208
+
209
+ export async function requireTeamManager(userId: string, teamSlug: string) {
210
+ const team = await requireTeam(userId, teamSlug)
211
+
212
+ if (!canManageTeamSettings(team.role)) {
213
+ throw new PublicError('FORBIDDEN')
214
+ }
215
+
216
+ return team
217
+ }
218
+
219
+ export async function createTeam({
220
+ userId,
221
+ name,
222
+ }: {
223
+ userId: string
224
+ name: string
225
+ }) {
226
+ const normalizedName = name.trim()
227
+
228
+ if (!normalizedName) {
229
+ throw new PublicError('TEAM_NAME_REQUIRED')
230
+ }
231
+
232
+ const teamId = crypto.randomUUID()
233
+
234
+ if (
235
+ !(await insertOwnedTeam(db, {
236
+ userId,
237
+ teamId,
238
+ name: normalizedName,
239
+ baseSlug: slugify(normalizedName),
240
+ }))
241
+ )
242
+ throw new PublicError('ACCOUNT_NOT_FOUND')
243
+
244
+ const rows = await db
245
+ .select({
246
+ slug: teams.slug,
247
+ })
248
+ .from(teams)
249
+ .where(eq(teams.id, teamId))
250
+ .limit(1)
251
+ const team = rows.at(0)
252
+
253
+ if (!team) {
254
+ throw new Error('Team could not be created.')
255
+ }
256
+
257
+ return requireTeam(userId, team.slug)
258
+ }
259
+
260
+ export async function getTeamDeletionStatus({ teamId }: { teamId: string }) {
261
+ const defaultUserRows = await db
262
+ .select({ id: user.id })
263
+ .from(user)
264
+ .where(and(eq(user.defaultTeamId, teamId), eq(user.status, 'active')))
265
+
266
+ return {
267
+ canDelete: canDeleteTeam({
268
+ defaultUserIds: defaultUserRows.map((row) => row.id),
269
+ }),
270
+ defaultUserCount: defaultUserRows.length,
271
+ }
272
+ }
273
+
274
+ export async function updateTeamGeneral({
275
+ settingsVersion,
276
+ teamId,
277
+ userId,
278
+ teamSlug,
279
+ name,
280
+ slug,
281
+ avatarDataUrl,
282
+ deleteAvatar,
283
+ }: {
284
+ settingsVersion?: number
285
+ teamId?: string
286
+ userId: string
287
+ teamSlug: string
288
+ name: string
289
+ slug: string
290
+ avatarDataUrl?: string | null
291
+ deleteAvatar?: boolean
292
+ }) {
293
+ const team = await requireTeam(userId, teamSlug, teamId)
294
+ requireAdminRole(team.role)
295
+ return withWorkspaceAccess(
296
+ team.id,
297
+ 'workspace.update',
298
+ async () => {
299
+ const expectedVersion = settingsVersion ?? team.settingsVersion
300
+ if (expectedVersion !== team.settingsVersion)
301
+ throw new PublicError(
302
+ 'SETTINGS_CONFLICT',
303
+ `/${encodeURIComponent(team.slug)}/settings`,
304
+ )
305
+ const normalizedName = name.trim()
306
+ const normalizedSlug = normalizeTeamSlug(slug)
307
+
308
+ if (!normalizedName) {
309
+ throw new PublicError('TEAM_NAME_REQUIRED')
310
+ }
311
+
312
+ if (forbiddenTeamSlugs.has(normalizedSlug)) {
313
+ throw new PublicError('TEAM_SLUG_RESERVED')
314
+ }
315
+
316
+ const avatarPath = await resolveAvatarUpdate({
317
+ filePrefix: 'team',
318
+ resourceId: team.id,
319
+ currentAvatarPath: team.image,
320
+ avatarDataUrl,
321
+ deleteAvatar: deleteAvatar ?? false,
322
+ })
323
+
324
+ try {
325
+ const changed = await db
326
+ .update(teams)
327
+ .set({
328
+ name: normalizedName,
329
+ slug: normalizedSlug,
330
+ avatarPath,
331
+ settingsVersion: sql`${teams.settingsVersion} + 1`,
332
+ updatedAt: new Date(),
333
+ })
334
+ .where(
335
+ and(
336
+ eq(teams.id, team.id),
337
+ eq(teams.status, 'active'),
338
+ eq(teams.settingsVersion, expectedVersion),
339
+ sql`${teams.avatarPath} is ${team.image}`,
340
+ sql`exists(select 1 from team_members m join user u on u.id = m.user_id where m.team_id = ${team.id} and m.user_id = ${userId} and m.role in ('owner','admin') and u.status = 'active')`,
341
+ ),
342
+ )
343
+ .returning({ id: teams.id })
344
+ if (!changed.length)
345
+ throw new PublicError(
346
+ 'SETTINGS_CONFLICT',
347
+ `/${encodeURIComponent(team.slug)}/settings`,
348
+ )
349
+ } catch (error) {
350
+ if (avatarPath && avatarPath !== team.image) {
351
+ await removeOwnedAvatar(avatarPath, {
352
+ filePrefix: 'team',
353
+ resourceId: team.id,
354
+ })
355
+ }
356
+
357
+ if (isUniqueConstraintError(error))
358
+ throw new PublicError('TEAM_SLUG_CONFLICT')
359
+ throw error
360
+ }
361
+
362
+ if (team.image !== avatarPath) {
363
+ await removeOwnedAvatar(team.image, {
364
+ filePrefix: 'team',
365
+ resourceId: team.id,
366
+ })
367
+ }
368
+
369
+ return requireTeam(userId, normalizedSlug)
370
+ },
371
+ { kind: 'user', userId },
372
+ )
373
+ }
374
+
375
+ export async function createTeamInvites({
376
+ userId,
377
+ teamSlug,
378
+ emails,
379
+ role,
380
+ }: {
381
+ userId: string
382
+ teamSlug: string
383
+ emails: Array<string>
384
+ role: Exclude<TeamRole, 'owner'>
385
+ }) {
386
+ if (emails.length === 0) {
387
+ throw new PublicError('TEAM_INVITE_EMAIL_REQUIRED')
388
+ }
389
+
390
+ const team = await requireTeam(userId, teamSlug)
391
+ requireAdminRole(team.role)
392
+
393
+ let emailCount = 0
394
+ const failedEmails: Array<{ email: string; message: string }> = []
395
+ let emailSentCount = 0
396
+ let inviteUrl: string | null = null
397
+ let invites: Array<TeamInviteSummary> = []
398
+
399
+ for (const email of emails) {
400
+ try {
401
+ const result = await upsertTeamInvite({ userId, team, email, role })
402
+ emailCount += 1
403
+ if (result.emailSent) emailSentCount += 1
404
+ inviteUrl = result.inviteUrl
405
+ invites = result.invites
406
+ } catch (error) {
407
+ failedEmails.push({
408
+ email,
409
+ message: getPublicErrorMessage(
410
+ toPublicError(error),
411
+ 'Unable to create this invitation. Please try again.',
412
+ ),
413
+ })
414
+ }
415
+ }
416
+
417
+ return {
418
+ emailCount,
419
+ failedEmails,
420
+ emailSentCount,
421
+ inviteUrl,
422
+ invites,
423
+ }
424
+ }
425
+
426
+ export async function resendTeamInvite({
427
+ userId,
428
+ teamSlug,
429
+ inviteId,
430
+ }: {
431
+ userId: string
432
+ teamSlug: string
433
+ inviteId: string
434
+ }) {
435
+ const team = await requireTeam(userId, teamSlug)
436
+ requireAdminRole(team.role)
437
+
438
+ const rows = await db
439
+ .select()
440
+ .from(teamInvites)
441
+ .where(
442
+ and(
443
+ eq(teamInvites.id, inviteId),
444
+ eq(teamInvites.teamId, team.id),
445
+ eq(teamInvites.status, 'pending'),
446
+ ),
447
+ )
448
+ .limit(1)
449
+ const invite = rows.at(0)
450
+
451
+ if (!invite) {
452
+ throw new PublicError('TEAM_INVITE_NOT_FOUND')
453
+ }
454
+
455
+ return upsertTeamInvite({
456
+ userId,
457
+ team,
458
+ email: invite.email,
459
+ role: invite.role === 'admin' ? 'admin' : 'member',
460
+ existingInviteId: invite.id,
461
+ })
462
+ }
463
+
464
+ export async function revokeTeamInvite({
465
+ userId,
466
+ teamSlug,
467
+ inviteId,
468
+ }: {
469
+ userId: string
470
+ teamSlug: string
471
+ inviteId: string
472
+ }) {
473
+ const team = await requireTeam(userId, teamSlug)
474
+ requireAdminRole(team.role)
475
+ return withWorkspaceAccess(
476
+ team.id,
477
+ 'member.revoke',
478
+ async () => {
479
+ const revokedRows = await db
480
+ .update(teamInvites)
481
+ .set({ status: 'revoked', updatedAt: new Date() })
482
+ .where(
483
+ and(
484
+ eq(teamInvites.id, inviteId),
485
+ eq(teamInvites.teamId, team.id),
486
+ eq(teamInvites.status, 'pending'),
487
+ sql`exists(select 1 from team_members m join user u on u.id = m.user_id join teams t on t.id = m.team_id where m.user_id = ${userId} and m.team_id = ${team.id} and m.role in ('owner','admin') and u.status = 'active' and t.status = 'active')`,
488
+ ),
489
+ )
490
+ .returning({ id: teamInvites.id })
491
+
492
+ if (!revokedRows.at(0)) {
493
+ throw new PublicError('TEAM_INVITE_NOT_FOUND')
494
+ }
495
+
496
+ return listTeamInvites(team.id)
497
+ },
498
+ { kind: 'user', userId },
499
+ )
500
+ }
501
+
502
+ export async function cleanupExpiredTeamInvites({
503
+ now = new Date(),
504
+ }: {
505
+ now?: Date
506
+ } = {}) {
507
+ const rows = await db
508
+ .update(teamInvites)
509
+ .set({
510
+ status: 'expired',
511
+ updatedAt: now,
512
+ })
513
+ .where(
514
+ and(eq(teamInvites.status, 'pending'), lte(teamInvites.expiresAt, now)),
515
+ )
516
+ .returning({ id: teamInvites.id })
517
+
518
+ return {
519
+ expiredCount: rows.length,
520
+ expiredInviteIds: rows.map((row) => row.id),
521
+ }
522
+ }
523
+
524
+ export async function updateTeamMemberRole({
525
+ userId,
526
+ teamSlug,
527
+ memberUserId,
528
+ role,
529
+ }: {
530
+ userId: string
531
+ teamSlug: string
532
+ memberUserId: string
533
+ role: TeamRole
534
+ }) {
535
+ const team = await requireTeam(userId, teamSlug)
536
+ requireMemberManagerRole(team.role)
537
+ return withWorkspaceAccess(
538
+ team.id,
539
+ 'member.role',
540
+ async () => {
541
+ const memberRows = await db
542
+ .select()
543
+ .from(teamMembers)
544
+ .where(
545
+ and(
546
+ eq(teamMembers.teamId, team.id),
547
+ eq(teamMembers.userId, memberUserId),
548
+ ),
549
+ )
550
+ .limit(1)
551
+ const member = memberRows.at(0)
552
+
553
+ if (!member) {
554
+ throw new PublicError('TEAM_MEMBER_NOT_FOUND')
555
+ }
556
+
557
+ if (
558
+ team.role !== 'owner' &&
559
+ (member.role === 'owner' || role === 'owner')
560
+ ) {
561
+ throw new PublicError('TEAM_OWNER_ROLE_FORBIDDEN')
562
+ }
563
+
564
+ const changed = await mutateTeamMembership(db, {
565
+ teamId: team.id,
566
+ actorId: userId,
567
+ memberId: memberUserId,
568
+ action: 'role',
569
+ role,
570
+ })
571
+ if (!changed) {
572
+ await throwMembershipChangeError({
573
+ userId,
574
+ teamSlug,
575
+ memberUserId,
576
+ role,
577
+ })
578
+ }
579
+
580
+ return listTeamMembers(team.id)
581
+ },
582
+ { kind: 'user', userId },
583
+ )
584
+ }
585
+
586
+ export async function removeTeamMember({
587
+ userId,
588
+ teamSlug,
589
+ memberUserId,
590
+ }: {
591
+ userId: string
592
+ teamSlug: string
593
+ memberUserId: string
594
+ }) {
595
+ const team = await requireTeam(userId, teamSlug)
596
+ requireMemberManagerRole(team.role)
597
+ return withWorkspaceAccess(
598
+ team.id,
599
+ 'member.remove',
600
+ async () => {
601
+ const memberRows = await db
602
+ .select()
603
+ .from(teamMembers)
604
+ .where(
605
+ and(
606
+ eq(teamMembers.teamId, team.id),
607
+ eq(teamMembers.userId, memberUserId),
608
+ ),
609
+ )
610
+ .limit(1)
611
+ const member = memberRows.at(0)
612
+
613
+ if (!member) {
614
+ throw new PublicError('TEAM_MEMBER_NOT_FOUND')
615
+ }
616
+
617
+ if (team.role !== 'owner' && member.role === 'owner') {
618
+ throw new PublicError('TEAM_OWNER_REMOVAL_FORBIDDEN')
619
+ }
620
+
621
+ const changed = await mutateTeamMembership(db, {
622
+ teamId: team.id,
623
+ actorId: userId,
624
+ memberId: memberUserId,
625
+ action: 'remove',
626
+ })
627
+ if (!changed) {
628
+ await throwMembershipChangeError({
629
+ userId,
630
+ teamSlug,
631
+ memberUserId,
632
+ role: null,
633
+ })
634
+ }
635
+
636
+ return listTeamMembers(team.id)
637
+ },
638
+ { kind: 'user', userId },
639
+ )
640
+ }
641
+
642
+ async function throwMembershipChangeError({
643
+ userId,
644
+ teamSlug,
645
+ memberUserId,
646
+ role,
647
+ }: {
648
+ userId: string
649
+ teamSlug: string
650
+ memberUserId: string
651
+ role: TeamRole | null
652
+ }): Promise<never> {
653
+ const team = await requireTeam(userId, teamSlug)
654
+ requireMemberManagerRole(team.role)
655
+ const [member] = await db
656
+ .select()
657
+ .from(teamMembers)
658
+ .where(
659
+ and(
660
+ eq(teamMembers.teamId, team.id),
661
+ eq(teamMembers.userId, memberUserId),
662
+ ),
663
+ )
664
+ .limit(1)
665
+ if (!member) throw new PublicError('TEAM_MEMBER_NOT_FOUND')
666
+ if (team.role !== 'owner' && (member.role === 'owner' || role === 'owner')) {
667
+ throw new PublicError(
668
+ role === null
669
+ ? 'TEAM_OWNER_REMOVAL_FORBIDDEN'
670
+ : 'TEAM_OWNER_ROLE_FORBIDDEN',
671
+ )
672
+ }
673
+ if (member.role === 'owner' && role !== 'owner')
674
+ throw new PublicError('TEAM_LAST_OWNER')
675
+ throw new PublicError('TEAM_MEMBERSHIP_CHANGED')
676
+ }
677
+
678
+ export async function transferTeamOwnership({
679
+ userId,
680
+ teamSlug,
681
+ memberUserId,
682
+ }: {
683
+ userId: string
684
+ teamSlug: string
685
+ memberUserId: string
686
+ }) {
687
+ const team = await requireTeam(userId, teamSlug)
688
+ requireOwnerRole(team.role)
689
+ return withWorkspaceAccess(
690
+ team.id,
691
+ 'owner.transfer',
692
+ async () => {
693
+ if (userId === memberUserId) {
694
+ throw new PublicError('TEAM_TRANSFER_MEMBER_REQUIRED')
695
+ }
696
+
697
+ const memberRows = await db
698
+ .select()
699
+ .from(teamMembers)
700
+ .where(
701
+ and(
702
+ eq(teamMembers.teamId, team.id),
703
+ inArray(teamMembers.userId, [userId, memberUserId]),
704
+ ),
705
+ )
706
+ const memberships = new Map(
707
+ memberRows.map((member) => [member.userId, member]),
708
+ )
709
+ const currentMembership = memberships.get(userId)
710
+ const targetMembership = memberships.get(memberUserId)
711
+
712
+ if (!currentMembership || currentMembership.role !== 'owner') {
713
+ throw new PublicError('TEAM_TRANSFER_FORBIDDEN')
714
+ }
715
+
716
+ if (!targetMembership) {
717
+ throw new PublicError('TEAM_MEMBER_NOT_FOUND')
718
+ }
719
+
720
+ if (targetMembership.role === 'owner') {
721
+ throw new PublicError('TEAM_MEMBER_ALREADY_OWNER')
722
+ }
723
+
724
+ if (
725
+ !(await mutateTeamOwnership(db, {
726
+ teamId: team.id,
727
+ actorId: userId,
728
+ memberId: memberUserId,
729
+ }))
730
+ ) {
731
+ throw new PublicError('TEAM_MEMBERSHIP_CHANGED')
732
+ }
733
+
734
+ return listTeamMembers(team.id)
735
+ },
736
+ { kind: 'user', userId },
737
+ )
738
+ }
739
+
740
+ export async function deleteTeam({
741
+ userId,
742
+ teamSlug,
743
+ confirmationName,
744
+ }: {
745
+ confirmationName?: string
746
+ userId: string
747
+ teamSlug: string
748
+ }) {
749
+ const team = await requireTeam(userId, teamSlug)
750
+ requireOwnerRole(team.role)
751
+ return withWorkspaceAccess(
752
+ team.id,
753
+ 'workspace.delete',
754
+ async () => {
755
+ if (confirmationName !== team.slug) {
756
+ throw new PublicError('TEAM_DELETE_CONFIRMATION')
757
+ }
758
+
759
+ const defaultUserRows = await db
760
+ .select({ id: user.id })
761
+ .from(user)
762
+ .where(and(eq(user.defaultTeamId, team.id), eq(user.status, 'active')))
763
+ if (
764
+ !canDeleteTeam({
765
+ defaultUserIds: defaultUserRows.map((row) => row.id),
766
+ })
767
+ )
768
+ throw new PublicError('TEAM_DEFAULT_DELETE_FORBIDDEN')
769
+ if (
770
+ !(await deleteTeamState(db, {
771
+ teamId: team.id,
772
+ userId,
773
+ now: new Date(),
774
+ }))
775
+ )
776
+ throw new PublicError('TEAM_STATE_CHANGED')
777
+ },
778
+ { kind: 'user', userId },
779
+ )
780
+ }
781
+
782
+ export async function acceptTeamInvite({
783
+ userId,
784
+ token,
785
+ }: {
786
+ userId: string
787
+ token: string
788
+ }) {
789
+ return acceptPendingInvitation(
790
+ userId,
791
+ eq(teamInvites.tokenHash, await hashToken(token)),
792
+ )
793
+ }
794
+
795
+ export async function acceptUserTeamInvite({
796
+ userId,
797
+ inviteId,
798
+ }: {
799
+ userId: string
800
+ inviteId: string
801
+ }) {
802
+ return acceptPendingInvitation(userId, eq(teamInvites.id, inviteId))
803
+ }
804
+
805
+ async function acceptPendingInvitation(
806
+ userId: string,
807
+ selector: ReturnType<typeof eq>,
808
+ ) {
809
+ const now = new Date()
810
+ const [currentUser] = await db
811
+ .select({
812
+ email: user.email,
813
+ emailVerified: user.emailVerified,
814
+ status: user.status,
815
+ })
816
+ .from(user)
817
+ .where(eq(user.id, userId))
818
+ .limit(1)
819
+ if (!currentUser || currentUser.status !== 'active')
820
+ throw new PublicError('ACCOUNT_NOT_FOUND')
821
+
822
+ const [row] = await db
823
+ .select({ invite: teamInvites, teamSlug: teams.slug })
824
+ .from(teamInvites)
825
+ .innerJoin(teams, eq(teams.id, teamInvites.teamId))
826
+ .where(
827
+ and(
828
+ selector,
829
+ eq(teamInvites.status, 'pending'),
830
+ eq(teams.status, 'active'),
831
+ ),
832
+ )
833
+ .limit(1)
834
+ if (!row || row.invite.expiresAt <= now)
835
+ throw new PublicError('TEAM_INVITATION_INVALID')
836
+ if (normalizeEmail(currentUser.email) !== row.invite.email) {
837
+ throw new PublicError('TEAM_INVITATION_EMAIL_MISMATCH')
838
+ }
839
+ if (!currentUser.emailVerified) {
840
+ throw new PublicError('TEAM_INVITATION_EMAIL_UNVERIFIED')
841
+ }
842
+
843
+ if (
844
+ !(await withWorkspaceAccess(
845
+ row.invite.teamId,
846
+ 'member.accept',
847
+ () =>
848
+ acceptInvitation(db, {
849
+ userId,
850
+ inviteId: row.invite.id,
851
+ tokenHash: row.invite.tokenHash,
852
+ now,
853
+ }),
854
+ { kind: 'user', userId },
855
+ ))
856
+ )
857
+ throw new PublicError('TEAM_INVITATION_INVALID')
858
+ return row.teamSlug
859
+ }
860
+
861
+ export async function declineUserTeamInvite({
862
+ userId,
863
+ inviteId,
864
+ }: {
865
+ userId: string
866
+ inviteId: string
867
+ }) {
868
+ return declinePendingInvitation(userId, eq(teamInvites.id, inviteId))
869
+ }
870
+
871
+ export async function declineTeamInvite({
872
+ userId,
873
+ token,
874
+ }: {
875
+ userId: string
876
+ token: string
877
+ }) {
878
+ return declinePendingInvitation(
879
+ userId,
880
+ eq(teamInvites.tokenHash, await hashToken(token)),
881
+ )
882
+ }
883
+
884
+ async function declinePendingInvitation(
885
+ userId: string,
886
+ selector: ReturnType<typeof eq>,
887
+ ) {
888
+ const declined = await db
889
+ .delete(teamInvites)
890
+ .where(
891
+ and(
892
+ selector,
893
+ eq(teamInvites.status, 'pending'),
894
+ gt(teamInvites.expiresAt, new Date()),
895
+ sql`exists(select 1 from ${user} where ${user.id} = ${userId} and ${user.status} = 'active' and ${user.emailVerified} = 1 and lower(trim(${user.email})) = ${teamInvites.email})`,
896
+ sql`exists(select 1 from ${teams} where ${teams.id} = ${teamInvites.teamId} and ${teams.status} = 'active')`,
897
+ ),
898
+ )
899
+ .returning({ id: teamInvites.id })
900
+ if (!declined.length) throw new PublicError('TEAM_INVITATION_INVALID')
901
+ }
902
+
903
+ function slugify(value: string) {
904
+ const slug = value
905
+ .toLowerCase()
906
+ .replace(/[\s_.]+/g, '-')
907
+ .replace(/[^a-z0-9-]/g, '')
908
+ .replace(/-+/g, '-')
909
+ .slice(0, 40)
910
+ .replace(/^-|-$/g, '')
911
+
912
+ return slug && !forbiddenTeamSlugs.has(slug) ? slug : 'team'
913
+ }
914
+
915
+ async function upsertTeamInvite({
916
+ userId,
917
+ team,
918
+ email,
919
+ role,
920
+ existingInviteId,
921
+ }: {
922
+ userId: string
923
+ team: TeamSummary
924
+ email: string
925
+ role: Exclude<TeamRole, 'owner'>
926
+ existingInviteId?: string
927
+ }) {
928
+ const normalizedEmail = normalizeEmail(email)
929
+
930
+ if (!normalizedEmail) {
931
+ throw new PublicError('TEAM_EMAIL_REQUIRED')
932
+ }
933
+
934
+ const existingMemberRows = await db
935
+ .select({ id: teamMembers.id })
936
+ .from(teamMembers)
937
+ .innerJoin(user, eq(user.id, teamMembers.userId))
938
+ .where(
939
+ and(
940
+ eq(teamMembers.teamId, team.id),
941
+ eq(user.status, 'active'),
942
+ sql`lower(${user.email}) = ${normalizedEmail}`,
943
+ ),
944
+ )
945
+ .limit(1)
946
+
947
+ if (existingMemberRows.length > 0) {
948
+ throw new PublicError('TEAM_ALREADY_MEMBER')
949
+ }
950
+
951
+ const { token, tokenHash } = await createInviteToken()
952
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
953
+ const now = new Date()
954
+ const newInviteId = crypto.randomUUID()
955
+ let inviteId = existingInviteId ?? newInviteId
956
+ let shouldUpdateInvite = Boolean(existingInviteId)
957
+
958
+ if (!existingInviteId) {
959
+ const pendingInviteRows = await db
960
+ .select({
961
+ id: teamInvites.id,
962
+ expiresAt: teamInvites.expiresAt,
963
+ })
964
+ .from(teamInvites)
965
+ .where(
966
+ and(
967
+ eq(teamInvites.teamId, team.id),
968
+ eq(teamInvites.status, 'pending'),
969
+ sql`lower(${teamInvites.email}) = ${normalizedEmail}`,
970
+ ),
971
+ )
972
+ .limit(1)
973
+ const pendingInvite = pendingInviteRows.at(0)
974
+
975
+ if (pendingInvite) {
976
+ if (pendingInvite.expiresAt > now) {
977
+ throw new PublicError('TEAM_INVITATION_ALREADY_SENT')
978
+ }
979
+
980
+ inviteId = pendingInvite.id
981
+ shouldUpdateInvite = true
982
+ }
983
+ }
984
+
985
+ try {
986
+ await withWorkspaceAccess(
987
+ team.id,
988
+ 'member.invite',
989
+ async () => {
990
+ if (shouldUpdateInvite) {
991
+ const updated = await db
992
+ .update(teamInvites)
993
+ .set({
994
+ role,
995
+ tokenHash,
996
+ expiresAt,
997
+ inviterId: userId,
998
+ updatedAt: now,
999
+ })
1000
+ .where(
1001
+ and(
1002
+ eq(teamInvites.id, inviteId),
1003
+ eq(teamInvites.teamId, team.id),
1004
+ eq(teamInvites.status, 'pending'),
1005
+ sql`exists(select 1 from team_members m join user u on u.id = m.user_id join teams t on t.id = m.team_id
1006
+ where m.user_id = ${userId} and m.team_id = ${team.id} and m.role in ('owner','admin') and u.status = 'active' and t.status = 'active')`,
1007
+ ),
1008
+ )
1009
+ .returning({ id: teamInvites.id })
1010
+ if (updated.length === 0)
1011
+ throw new PublicError('TEAM_INVITE_NOT_FOUND')
1012
+ } else {
1013
+ const created =
1014
+ await db.all(sql`insert into team_invites (id,team_id,email,role,inviter_id,token_hash,expires_at,created_at,updated_at)
1015
+ select ${inviteId},${team.id},${normalizedEmail},${role},${userId},${tokenHash},${expiresAt.getTime()},${now.getTime()},${now.getTime()}
1016
+ where exists(select 1 from team_members m join user u on u.id=m.user_id join teams t on t.id=m.team_id
1017
+ where m.team_id=${team.id} and m.user_id=${userId} and m.role=${team.role} and m.role in ('owner','admin') and u.status='active' and t.status='active') returning id`)
1018
+ if (!created.length) throw new PublicError('FORBIDDEN')
1019
+ }
1020
+ },
1021
+ { kind: 'user', userId },
1022
+ )
1023
+ } catch (error) {
1024
+ if (!isUniqueConstraintError(error)) {
1025
+ throw error
1026
+ }
1027
+
1028
+ throw new PublicError('TEAM_INVITATION_ALREADY_SENT')
1029
+ }
1030
+
1031
+ const inviteUrl = new URL(
1032
+ `/invites/${token}`,
1033
+ config.BETTER_AUTH_URL,
1034
+ ).toString()
1035
+
1036
+ let emailSent = true
1037
+ try {
1038
+ await sendTeamInviteEmail({
1039
+ email: normalizedEmail,
1040
+ inviteUrl,
1041
+ teamName: team.name,
1042
+ })
1043
+ } catch (error) {
1044
+ emailSent = false
1045
+ console.error('Failed to send team invite email', error)
1046
+ }
1047
+
1048
+ return {
1049
+ invites: await listTeamInvites(team.id),
1050
+ inviteUrl,
1051
+ emailSent,
1052
+ }
1053
+ }
1054
+
1055
+ function requireAdminRole(role: TeamRole) {
1056
+ if (!canManageTeamSettings(role)) {
1057
+ throw new PublicError('TEAM_MANAGEMENT_FORBIDDEN')
1058
+ }
1059
+ }
1060
+
1061
+ function requireMemberManagerRole(role: TeamRole) {
1062
+ if (!canManageTeamMembers(role)) {
1063
+ throw new PublicError('TEAM_MANAGER_REQUIRED')
1064
+ }
1065
+ }
1066
+
1067
+ function requireOwnerRole(role: TeamRole) {
1068
+ if (role !== 'owner') {
1069
+ throw new PublicError('TEAM_OWNER_REQUIRED')
1070
+ }
1071
+ }
1072
+
1073
+ function normalizeEmail(value: string) {
1074
+ return value.trim().toLowerCase()
1075
+ }
1076
+
1077
+ function normalizeTeamSlug(value: string) {
1078
+ const slug = value.trim().toLowerCase()
1079
+
1080
+ if (!/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/.test(slug) || slug.length > 100) {
1081
+ throw new PublicError('TEAM_SLUG_INVALID')
1082
+ }
1083
+
1084
+ return slug
1085
+ }
1086
+
1087
+ async function createInviteToken() {
1088
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
1089
+ const token = Buffer.from(bytes).toString('base64url')
1090
+
1091
+ return {
1092
+ token,
1093
+ tokenHash: await hashToken(token),
1094
+ }
1095
+ }
1096
+
1097
+ async function hashToken(token: string) {
1098
+ const hash = await crypto.subtle.digest(
1099
+ 'SHA-256',
1100
+ new TextEncoder().encode(token),
1101
+ )
1102
+
1103
+ return Buffer.from(hash).toString('base64url')
1104
+ }
1105
+
1106
+ export async function assertOtherOwner(
1107
+ tx: Pick<typeof db, 'select'>,
1108
+ teamId: string,
1109
+ userId: string,
1110
+ ) {
1111
+ const rows = await tx
1112
+ .select({ value: count() })
1113
+ .from(teamMembers)
1114
+ .innerJoin(user, eq(user.id, teamMembers.userId))
1115
+ .where(
1116
+ and(
1117
+ eq(teamMembers.teamId, teamId),
1118
+ ne(teamMembers.userId, userId),
1119
+ eq(teamMembers.role, 'owner'),
1120
+ eq(user.status, 'active'),
1121
+ ),
1122
+ )
1123
+
1124
+ if ((rows.at(0)?.value ?? 0) === 0) {
1125
+ throw new PublicError('TEAM_LAST_OWNER')
1126
+ }
1127
+ }