@app-vitals/agent-admin 0.0.1 → 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 (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * admin/src/account-lifecycle.ts
3
+ *
4
+ * AccountLifecycle (SSP-8.2) — the one place an account's status transition
5
+ * turns into cron side effects, shared by the admin PATCH /accounts/:id
6
+ * handler (accounts-api.ts), the admin UI suspend/reactivate actions
7
+ * (admin-ui-accounts.ts) and the account trial-expiry sweeper
8
+ * (account-trial-expiry-sweeper.ts).
9
+ *
10
+ * - active → suspended | trial_expired: lock down — every *enabled* cron of
11
+ * the account's agents is disabled via AgentCronJobService.setEnabled()
12
+ * with `{ lockdown: true }`, stamping lockdownDisabledAt (SSP-8.1). Crons
13
+ * the user had already disabled stay unmarked.
14
+ * - suspended | trial_expired → active: restore — restoreLockdownDisabled()
15
+ * re-enables exactly the marked crons. Agents whose *own* per-agent
16
+ * trialExpiresAt has lapsed are skipped, so the per-agent lockdown
17
+ * (trial-expiry-sweeper.ts) keeps its crons off. Reactivation requires
18
+ * the account's trialExpiresAt to be cleared or in the future (422
19
+ * otherwise — the expiry sweeper would just flip it straight back), and
20
+ * resets trialExpiryWarnedAt so a new trial gets its own warning.
21
+ *
22
+ * Nothing is ever deleted — no deleteAgentFully call, no workload changes. Both
23
+ * directions are idempotent: lockdown only touches enabled crons, restore
24
+ * only touches marked ones.
25
+ */
26
+
27
+ import type {
28
+ Account,
29
+ AccountService,
30
+ UpdateAccountInput,
31
+ } from "./accounts.ts";
32
+ import type { AgentCronJobService } from "./agent-cron-jobs.ts";
33
+ import { type Clock, SystemClock } from "./clock.ts";
34
+ import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
35
+ import {
36
+ DEFAULT_TRIAL_EXPIRY_WARNING_DAYS,
37
+ isDueForWarning,
38
+ } from "./trial-expiry-warning-sweeper.ts";
39
+
40
+ // ─── Pure helpers ───────────────────────────────────────────────────────────
41
+
42
+ const LOCKED_STATUSES: ReadonlySet<string> = new Set([
43
+ "suspended",
44
+ "trial_expired",
45
+ ]);
46
+
47
+ /** Whether an account status means its agents are locked down. */
48
+ export function isLockedStatus(status: string): boolean {
49
+ return LOCKED_STATUSES.has(status);
50
+ }
51
+
52
+ type LockdownAction = "lockdown" | "restore" | "none";
53
+
54
+ /** Cron side effect implied by moving an account from `prev` to `next`. */
55
+ export function resolveLockdownAction(
56
+ prev: string,
57
+ next: string,
58
+ ): LockdownAction {
59
+ if (prev === next) return "none";
60
+ if (isLockedStatus(next)) return "lockdown";
61
+ if (isLockedStatus(prev) && next === "active") return "restore";
62
+ return "none";
63
+ }
64
+
65
+ /**
66
+ * Whether a trial has lapsed — strictly before `now`, matching the per-agent
67
+ * sweeper's `lt` (a trial expiring at exactly `now` is not yet expired).
68
+ */
69
+ export function isTrialLapsed(trialExpiresAt: Date | null, now: Date): boolean {
70
+ return trialExpiresAt !== null && trialExpiresAt.getTime() < now.getTime();
71
+ }
72
+
73
+ /**
74
+ * Account-level warning window — the same comparison the per-agent warning
75
+ * sweeper uses (isDueForWarning), applied to the account's fields.
76
+ */
77
+ export function isAccountTrialWarningDue(
78
+ trialExpiresAt: Date | null,
79
+ trialExpiryWarnedAt: Date | null,
80
+ now: Date,
81
+ windowDays: number = DEFAULT_TRIAL_EXPIRY_WARNING_DAYS,
82
+ graceDays: number = windowDays,
83
+ ): boolean {
84
+ return isDueForWarning(
85
+ trialExpiresAt,
86
+ trialExpiryWarnedAt,
87
+ now,
88
+ windowDays,
89
+ graceDays,
90
+ );
91
+ }
92
+
93
+ // ─── Service ────────────────────────────────────────────────────────────────
94
+
95
+ interface AccountLifecycleDeps {
96
+ accounts: Pick<AccountService, "getById" | "update" | "listAgentTrialStates">;
97
+ cronJobs: Pick<
98
+ AgentCronJobService,
99
+ "listEnabledInLockedAccounts" | "setEnabled" | "restoreLockdownDisabled"
100
+ >;
101
+ clock?: Clock;
102
+ log?: (line: string) => void;
103
+ }
104
+
105
+ export class AccountLifecycle {
106
+ private readonly clock: Clock;
107
+ private readonly log: (line: string) => void;
108
+
109
+ constructor(private readonly deps: AccountLifecycleDeps) {
110
+ this.clock = deps.clock ?? SystemClock();
111
+ this.log = deps.log ?? ((line) => console.log(line));
112
+ }
113
+
114
+ /**
115
+ * Apply an admin edit to an account and run the cron side effect its
116
+ * status transition implies. Throws NotFoundError for an unknown account
117
+ * and UnprocessableEntityError for a reactivation with a lapsed trial
118
+ * (nothing is written in either case).
119
+ */
120
+ async update(id: string, data: UpdateAccountInput): Promise<Account> {
121
+ const current = await this.deps.accounts.getById(id);
122
+ if (!current) throw new NotFoundError(`account ${id} not found`);
123
+
124
+ const now = this.clock.now();
125
+ const nextStatus = data.status ?? current.status;
126
+ const action = resolveLockdownAction(current.status, nextStatus);
127
+ const nextTrial =
128
+ data.trialExpiresAt !== undefined
129
+ ? data.trialExpiresAt
130
+ : current.trialExpiresAt;
131
+
132
+ if (action === "restore" && isTrialLapsed(nextTrial, now)) {
133
+ throw new UnprocessableEntityError(
134
+ "Clear or extend trialExpiresAt before reactivating the account.",
135
+ );
136
+ }
137
+
138
+ const trialChanged =
139
+ data.trialExpiresAt !== undefined &&
140
+ (data.trialExpiresAt?.getTime() ?? null) !==
141
+ (current.trialExpiresAt?.getTime() ?? null);
142
+ const write: UpdateAccountInput = { ...data };
143
+ if (
144
+ (action === "restore" || trialChanged) &&
145
+ data.trialExpiryWarnedAt === undefined
146
+ ) {
147
+ write.trialExpiryWarnedAt = null;
148
+ }
149
+
150
+ const updated = await this.deps.accounts.update(id, write);
151
+
152
+ if (action === "lockdown") await this.lockDown(id);
153
+ if (action === "restore") await this.restore(id);
154
+ return updated;
155
+ }
156
+
157
+ /**
158
+ * Disable (with the lockdown marker) every enabled cron of an agent in a
159
+ * locked account — one account, or all of them when `accountId` is
160
+ * omitted (the sweeper's self-healing pass). Per-row failures are logged
161
+ * and skipped. Returns the number disabled.
162
+ */
163
+ async lockDown(accountId?: string): Promise<number> {
164
+ const rows =
165
+ await this.deps.cronJobs.listEnabledInLockedAccounts(accountId);
166
+ let disabled = 0;
167
+ for (const row of rows) {
168
+ try {
169
+ await this.deps.cronJobs.setEnabled(row.agentId, row.id, false, {
170
+ lockdown: true,
171
+ });
172
+ disabled++;
173
+ } catch (err) {
174
+ console.error(
175
+ `[account-lifecycle] disable cron ${row.id} (agent ${row.agentId}) failed:`,
176
+ err,
177
+ );
178
+ }
179
+ }
180
+ if (disabled > 0) {
181
+ this.log(
182
+ `[account-lifecycle] lockdown${accountId ? ` ${accountId}` : ""}: disabled=${disabled}`,
183
+ );
184
+ }
185
+ return disabled;
186
+ }
187
+
188
+ /**
189
+ * Re-enable the lockdown-disabled crons of the account's agents, skipping
190
+ * agents whose own per-agent trial has lapsed. Returns the number restored.
191
+ */
192
+ async restore(accountId: string): Promise<number> {
193
+ const now = this.clock.now();
194
+ const agents = await this.deps.accounts.listAgentTrialStates(accountId);
195
+ const ids = agents
196
+ .filter((a) => !isTrialLapsed(a.trialExpiresAt, now))
197
+ .map((a) => a.id);
198
+ const restored = await this.deps.cronJobs.restoreLockdownDisabled(ids);
199
+ if (restored > 0) {
200
+ this.log(
201
+ `[account-lifecycle] restore ${accountId}: restored=${restored}`,
202
+ );
203
+ }
204
+ return restored;
205
+ }
206
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * admin/src/account-lifecycle.unit.test.ts
3
+ * Pure helpers behind the account lockdown lifecycle (SSP-8.2): which status
4
+ * transitions lock down / restore, the trial-lapsed comparison, and the
5
+ * account-level warning window (reusing isDueForWarning). Also a static guard
6
+ * that none of the account lifecycle modules ever reach for deleteAgentFully.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import {
13
+ isAccountTrialWarningDue,
14
+ isLockedStatus,
15
+ isTrialLapsed,
16
+ resolveLockdownAction,
17
+ } from "./account-lifecycle.ts";
18
+
19
+ const DAY = 24 * 60 * 60 * 1000;
20
+ const NOW = new Date("2026-10-08T12:00:00.000Z");
21
+ const days = (n: number) => new Date(NOW.getTime() + n * DAY);
22
+
23
+ describe("isLockedStatus", () => {
24
+ it("suspended and trial_expired are locked; active is not", () => {
25
+ expect(isLockedStatus("suspended")).toBe(true);
26
+ expect(isLockedStatus("trial_expired")).toBe(true);
27
+ expect(isLockedStatus("active")).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("resolveLockdownAction", () => {
32
+ it("active -> suspended / trial_expired locks down", () => {
33
+ expect(resolveLockdownAction("active", "suspended")).toBe("lockdown");
34
+ expect(resolveLockdownAction("active", "trial_expired")).toBe("lockdown");
35
+ });
36
+
37
+ it("locked -> active restores", () => {
38
+ expect(resolveLockdownAction("suspended", "active")).toBe("restore");
39
+ expect(resolveLockdownAction("trial_expired", "active")).toBe("restore");
40
+ });
41
+
42
+ it("locked -> other locked status re-applies the (idempotent) lockdown", () => {
43
+ expect(resolveLockdownAction("trial_expired", "suspended")).toBe(
44
+ "lockdown",
45
+ );
46
+ });
47
+
48
+ it("no status change does nothing", () => {
49
+ expect(resolveLockdownAction("active", "active")).toBe("none");
50
+ expect(resolveLockdownAction("suspended", "suspended")).toBe("none");
51
+ });
52
+ });
53
+
54
+ describe("isTrialLapsed", () => {
55
+ it("null never lapses", () => {
56
+ expect(isTrialLapsed(null, NOW)).toBe(false);
57
+ });
58
+
59
+ it("strictly-past lapses; now and future do not", () => {
60
+ expect(isTrialLapsed(days(-1), NOW)).toBe(true);
61
+ expect(isTrialLapsed(new Date(NOW.getTime() - 1), NOW)).toBe(true);
62
+ expect(isTrialLapsed(NOW, NOW)).toBe(false);
63
+ expect(isTrialLapsed(days(1), NOW)).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe("isAccountTrialWarningDue (window comparison)", () => {
68
+ it("2 days away with a 3-day window is due", () => {
69
+ expect(isAccountTrialWarningDue(days(2), null, NOW, 3)).toBe(true);
70
+ });
71
+
72
+ it("exactly at the window edge is due; just past it is not", () => {
73
+ expect(isAccountTrialWarningDue(days(3), null, NOW, 3)).toBe(true);
74
+ expect(
75
+ isAccountTrialWarningDue(new Date(days(3).getTime() + 1), null, NOW, 3),
76
+ ).toBe(false);
77
+ });
78
+
79
+ it("5 days away with a 3-day window is not due", () => {
80
+ expect(isAccountTrialWarningDue(days(5), null, NOW, 3)).toBe(false);
81
+ });
82
+
83
+ it("already warned is never due", () => {
84
+ expect(isAccountTrialWarningDue(days(2), days(-1), NOW, 3)).toBe(false);
85
+ });
86
+
87
+ it("no trial is never due", () => {
88
+ expect(isAccountTrialWarningDue(null, null, NOW, 3)).toBe(false);
89
+ });
90
+
91
+ it("defaults to the 3-day window", () => {
92
+ expect(isAccountTrialWarningDue(days(2), null, NOW)).toBe(true);
93
+ expect(isAccountTrialWarningDue(days(4), null, NOW)).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("no deprovisioning in account lifecycle flows", () => {
98
+ it("none of the SSP-8.2 modules reference deleteAgentFully", () => {
99
+ for (const file of [
100
+ "account-lifecycle.ts",
101
+ "account-trial-expiry-sweeper.ts",
102
+ "account-trial-warning-sweeper.ts",
103
+ ]) {
104
+ const src = readFileSync(join(import.meta.dir, file), "utf8");
105
+ expect(src).not.toContain("deleteAgentFully(");
106
+ }
107
+ });
108
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * admin/src/account-members.ts
3
+ * AccountMemberService — email-keyed members of an account (SSP-1.2).
4
+ *
5
+ * UNIQUE(email) means an email belongs to at most one account in Phase 1.
6
+ * The last owner of an account can never be removed or demoted.
7
+ */
8
+
9
+ import type { AccountMember, PrismaClient } from "../prisma/client/client.ts";
10
+ import { normalizeEmail } from "./account-email.ts";
11
+ import type { PrismaTransactionClient } from "./prisma-tx.ts";
12
+
13
+ export type { AccountMember };
14
+
15
+ type AccountRole = "owner" | "member";
16
+
17
+ export class LastOwnerError extends Error {
18
+ constructor(public readonly accountId: string) {
19
+ super(`Cannot remove or demote the last owner of account ${accountId}`);
20
+ this.name = "LastOwnerError";
21
+ }
22
+ }
23
+
24
+ export class AccountMemberNotFoundError extends Error {
25
+ constructor(accountId: string, email: string) {
26
+ super(`No member ${email} in account ${accountId}`);
27
+ this.name = "AccountMemberNotFoundError";
28
+ }
29
+ }
30
+
31
+ export class AccountMemberService {
32
+ constructor(private prisma: PrismaClient) {}
33
+
34
+ async listByAccount(accountId: string): Promise<AccountMember[]> {
35
+ return this.prisma.accountMember.findMany({
36
+ where: { accountId },
37
+ orderBy: { createdAt: "asc" },
38
+ });
39
+ }
40
+
41
+ async getByEmail(email: string): Promise<AccountMember | null> {
42
+ return this.prisma.accountMember.findUnique({
43
+ where: { email: normalizeEmail(email) },
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Add a member. Throws (unique-constraint violation) if the email already
49
+ * belongs to any account.
50
+ *
51
+ * @param client - pass a `tx` from `prisma.$transaction` to participate in it.
52
+ */
53
+ async add(
54
+ accountId: string,
55
+ email: string,
56
+ role: AccountRole = "member",
57
+ client: PrismaTransactionClient = this.prisma,
58
+ ): Promise<AccountMember> {
59
+ return client.accountMember.create({
60
+ data: { accountId, email: normalizeEmail(email), role },
61
+ });
62
+ }
63
+
64
+ async remove(accountId: string, email: string): Promise<void> {
65
+ await this.guardedChange(accountId, email, async (tx, member) => {
66
+ await tx.accountMember.delete({ where: { id: member.id } });
67
+ });
68
+ }
69
+
70
+ async promote(accountId: string, email: string): Promise<AccountMember> {
71
+ return this.setRole(accountId, email, "owner");
72
+ }
73
+
74
+ async demote(accountId: string, email: string): Promise<AccountMember> {
75
+ return this.setRole(accountId, email, "member");
76
+ }
77
+
78
+ private async setRole(
79
+ accountId: string,
80
+ email: string,
81
+ role: AccountRole,
82
+ ): Promise<AccountMember> {
83
+ let updated: AccountMember | undefined;
84
+ await this.guardedChange(
85
+ accountId,
86
+ email,
87
+ async (tx, member) => {
88
+ updated = await tx.accountMember.update({
89
+ where: { id: member.id },
90
+ data: { role },
91
+ });
92
+ },
93
+ role === "member",
94
+ );
95
+ return updated as AccountMember;
96
+ }
97
+
98
+ /**
99
+ * Runs `change` in a transaction after verifying the member exists and, when
100
+ * the change would remove an owner, that they are not the last one. The
101
+ * account row is locked first so concurrent removals/demotions serialize.
102
+ */
103
+ private async guardedChange(
104
+ accountId: string,
105
+ rawEmail: string,
106
+ change: (
107
+ tx: PrismaTransactionClient,
108
+ member: AccountMember,
109
+ ) => Promise<void>,
110
+ removesOwnerRole = true,
111
+ ): Promise<void> {
112
+ const email = normalizeEmail(rawEmail);
113
+ await this.prisma.$transaction(async (tx) => {
114
+ await tx.$queryRaw`SELECT id FROM "Account" WHERE id = ${accountId} FOR UPDATE`;
115
+ const member = await tx.accountMember.findUnique({
116
+ where: { accountId_email: { accountId, email } },
117
+ });
118
+ if (!member) throw new AccountMemberNotFoundError(accountId, email);
119
+ if (removesOwnerRole && member.role === "owner") {
120
+ const owners = await tx.accountMember.count({
121
+ where: { accountId, role: "owner" },
122
+ });
123
+ if (owners <= 1) throw new LastOwnerError(accountId);
124
+ }
125
+ await change(tx, member);
126
+ });
127
+ }
128
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * admin/src/account-onboarding.integration.test.ts
3
+ * Integration tests for AccountOnboardingService (SSP-3.1) against real Postgres.
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import { AccountInviteService } from "./account-invites.ts";
11
+ import { AccountOnboardingService } from "./account-onboarding.ts";
12
+ import { AccountService } from "./accounts.ts";
13
+ import { createAdminPrismaClient } from "./prisma-client.ts";
14
+
15
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
16
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
17
+
18
+ describeOrSkip("AccountOnboardingService (integration)", () => {
19
+ let prisma: PrismaClient;
20
+ let onboarding: AccountOnboardingService;
21
+
22
+ beforeEach(async () => {
23
+ prisma = createAdminPrismaClient(TEST_DB as string);
24
+ await prisma.accountInvite.deleteMany();
25
+ await prisma.accountMember.deleteMany();
26
+ await prisma.agent.deleteMany();
27
+ await prisma.account.deleteMany();
28
+ onboarding = new AccountOnboardingService(prisma, 3);
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await prisma.$disconnect();
33
+ });
34
+
35
+ it("creates an account with the owner row, default quota and local-part name", async () => {
36
+ const result = await onboarding.provisionForEmail("New.User@Example.com");
37
+ expect(result.kind).toBe("created");
38
+ const account = await prisma.account.findUniqueOrThrow({
39
+ where: { id: result.accountId },
40
+ });
41
+ expect(account.name).toBe("new.user");
42
+ expect(account.status).toBe("active");
43
+ expect(account.maxAgents).toBe(3);
44
+ const members = await prisma.accountMember.findMany();
45
+ expect(members.map((m) => [m.email, m.role])).toEqual([
46
+ ["new.user@example.com", "owner"],
47
+ ]);
48
+ });
49
+
50
+ it("joins the invited account as member and marks the invite accepted", async () => {
51
+ const owner = await new AccountService(prisma).create("Acme", "o@acme.com");
52
+ await new AccountInviteService(prisma).create(
53
+ owner.id,
54
+ "Invitee@Acme.com",
55
+ "o@acme.com",
56
+ );
57
+ const result = await onboarding.provisionForEmail("invitee@acme.com");
58
+ expect(result).toEqual({ kind: "joined", accountId: owner.id });
59
+ expect(await prisma.account.count()).toBe(1);
60
+ const member = await prisma.accountMember.findUniqueOrThrow({
61
+ where: { email: "invitee@acme.com" },
62
+ });
63
+ expect(member.role).toBe("member");
64
+ const invite = await prisma.accountInvite.findFirstOrThrow({
65
+ where: { email: "invitee@acme.com" },
66
+ });
67
+ expect(invite.acceptedAt).not.toBeNull();
68
+ });
69
+
70
+ it("returns existing for an email that already has an account", async () => {
71
+ const first = await onboarding.provisionForEmail("a@x.com");
72
+ const second = await onboarding.provisionForEmail("A@x.com");
73
+ expect(second).toEqual({ kind: "existing", accountId: first.accountId });
74
+ expect(await prisma.account.count()).toBe(1);
75
+ });
76
+
77
+ it("concurrent first logins for one email yield exactly one account", async () => {
78
+ const results = await Promise.all(
79
+ Array.from({ length: 8 }, () =>
80
+ onboarding.provisionForEmail("race@x.com"),
81
+ ),
82
+ );
83
+ expect(await prisma.account.count()).toBe(1);
84
+ expect(await prisma.accountMember.count()).toBe(1);
85
+ expect(new Set(results.map((r) => r.accountId)).size).toBe(1);
86
+ expect(results.filter((r) => r.kind === "created")).toHaveLength(1);
87
+ });
88
+
89
+ it("concurrent first logins with a pending invite join once and create no account", async () => {
90
+ const owner = await new AccountService(prisma).create("Acme", "o@acme.com");
91
+ await new AccountInviteService(prisma).create(owner.id, "r@acme.com", "o");
92
+ const results = await Promise.all(
93
+ Array.from({ length: 6 }, () =>
94
+ onboarding.provisionForEmail("r@acme.com"),
95
+ ),
96
+ );
97
+ expect(await prisma.account.count()).toBe(1);
98
+ expect(results.every((r) => r.accountId === owner.id)).toBe(true);
99
+ expect(results.filter((r) => r.kind === "joined")).toHaveLength(1);
100
+ });
101
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * admin/src/account-onboarding.ts
3
+ * AccountOnboardingService — first-login account provisioning for self-serve
4
+ * agent provisioning (SSP-3.1).
5
+ *
6
+ * A verified email with no account yet either joins the account that invited
7
+ * it (as 'member') or gets a brand-new account (as 'owner'). Everything runs
8
+ * in one transaction, and concurrent first logins for the same email are made
9
+ * idempotent by UNIQUE(AccountMember.email): the losing transaction rolls back
10
+ * entirely (including any account it created) and re-reads the winner's row.
11
+ */
12
+
13
+ import type { PrismaClient } from "../prisma/client/client.ts";
14
+ import { normalizeEmail } from "./account-email.ts";
15
+
16
+ type OnboardingResult =
17
+ | { kind: "created"; accountId: string }
18
+ | { kind: "joined"; accountId: string }
19
+ | { kind: "existing"; accountId: string };
20
+
21
+ const MAX_ATTEMPTS = 3;
22
+ const UNIQUE_VIOLATION = "P2002";
23
+
24
+ function isUniqueViolation(err: unknown): boolean {
25
+ return (err as { code?: unknown } | null)?.code === UNIQUE_VIOLATION;
26
+ }
27
+
28
+ export class AccountOnboardingService {
29
+ constructor(
30
+ private prisma: PrismaClient,
31
+ private defaultMaxAgents: number,
32
+ ) {}
33
+
34
+ /**
35
+ * Ensure the email belongs to an account: no-op ("existing") if it already
36
+ * does, else join via pending invite ("joined"), else create ("created").
37
+ */
38
+ async provisionForEmail(rawEmail: string): Promise<OnboardingResult> {
39
+ const email = normalizeEmail(rawEmail);
40
+ let lastErr: unknown;
41
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
42
+ try {
43
+ return await this.prisma.$transaction((tx) =>
44
+ this.provisionInTx(tx, email),
45
+ );
46
+ } catch (err) {
47
+ if (!isUniqueViolation(err)) throw err;
48
+ // A concurrent first login won the race — read-after-conflict.
49
+ lastErr = err;
50
+ const member = await this.prisma.accountMember.findUnique({
51
+ where: { email },
52
+ });
53
+ if (member) return { kind: "existing", accountId: member.accountId };
54
+ }
55
+ }
56
+ throw lastErr;
57
+ }
58
+
59
+ private async provisionInTx(
60
+ tx: Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0],
61
+ email: string,
62
+ ): Promise<OnboardingResult> {
63
+ const existing = await tx.accountMember.findUnique({ where: { email } });
64
+ if (existing) return { kind: "existing", accountId: existing.accountId };
65
+
66
+ const invite = await tx.accountInvite.findFirst({
67
+ where: { email, acceptedAt: null },
68
+ orderBy: { createdAt: "asc" },
69
+ });
70
+ if (invite) {
71
+ const claimed = await tx.accountInvite.updateMany({
72
+ where: { id: invite.id, acceptedAt: null },
73
+ data: { acceptedAt: new Date() },
74
+ });
75
+ if (claimed.count === 1) {
76
+ await tx.accountMember.create({
77
+ data: { accountId: invite.accountId, email, role: "member" },
78
+ });
79
+ return { kind: "joined", accountId: invite.accountId };
80
+ }
81
+ }
82
+
83
+ const account = await tx.account.create({
84
+ data: {
85
+ name: email.split("@")[0] || email,
86
+ status: "active",
87
+ maxAgents: this.defaultMaxAgents,
88
+ },
89
+ });
90
+ await tx.accountMember.create({
91
+ data: { accountId: account.id, email, role: "owner" },
92
+ });
93
+ return { kind: "created", accountId: account.id };
94
+ }
95
+ }