@app-vitals/agent-admin 0.0.0-stage → 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 +64 -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,289 @@
1
+ /**
2
+ * admin/src/accounts-api.ts
3
+ * Admin-only /accounts API (SSP-5.1): list/get/create/patch self-serve
4
+ * accounts. Flag-gated (404 when SHIPWRIGHT_SELF_SERVE_ENABLED is off, checked
5
+ * before auth) and admin-only (admin cookie or scope "*" key); every other
6
+ * caller class gets 403.
7
+ *
8
+ * POST /accounts goes through AccountService.create(), which seeds the owner
9
+ * AccountMember row — so that owner's first login joins this account instead
10
+ * of creating a new one.
11
+ *
12
+ * PATCH /accounts/:id routes through AccountLifecycle.update() (SSP-8.2):
13
+ * status=suspended locks down the account's agents' crons, status=active
14
+ * restores the lockdown-disabled ones (422 if trialExpiresAt is still past).
15
+ */
16
+
17
+ import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
18
+ import { HTTPException } from "hono/http-exception";
19
+ import type { AccountLifecycle } from "./account-lifecycle.ts";
20
+ import type { AccountService, UpdateAccountInput } from "./accounts.ts";
21
+ import type { AgentTokenService } from "./agent-tokens.ts";
22
+ import {
23
+ type AdminApiKey,
24
+ type AdminAuthEnv,
25
+ createAdminAuthMiddleware,
26
+ } from "./api-auth.ts";
27
+ import {
28
+ ApiError,
29
+ ConflictError,
30
+ ForbiddenError,
31
+ NotFoundError,
32
+ } from "./errors.ts";
33
+ import {
34
+ AccountIdParamSchema,
35
+ AccountResponseSchema,
36
+ AccountsListResponseSchema,
37
+ CreateAccountBodySchema,
38
+ ErrorSchema,
39
+ PatchAccountBodySchema,
40
+ } from "./openapi-schemas.ts";
41
+ import type { SelfServeConfig } from "./self-serve-config.ts";
42
+
43
+ interface AccountsApiDeps {
44
+ accountService: Pick<
45
+ AccountService,
46
+ "listWithCounts" | "getWithCounts" | "create"
47
+ >;
48
+ /**
49
+ * PATCH goes through the lifecycle so a status change runs its cron
50
+ * lockdown/restore side effect (SSP-8.2).
51
+ */
52
+ accountLifecycle: Pick<AccountLifecycle, "update">;
53
+ selfServe?: SelfServeConfig;
54
+ sessionSecret: string;
55
+ adminApiKeys?: Map<string, AdminApiKey>;
56
+ agentTokenService: Pick<AgentTokenService, "validate">;
57
+ }
58
+
59
+ const jsonError = { content: { "application/json": { schema: ErrorSchema } } };
60
+
61
+ const errorResponses = {
62
+ 401: { description: "Unauthorized", ...jsonError },
63
+ 403: { description: "Forbidden (non-admin caller)", ...jsonError },
64
+ 404: { description: "Not found (or self-serve disabled)", ...jsonError },
65
+ };
66
+
67
+ const listAccountsRoute = createRoute({
68
+ method: "get",
69
+ path: "/accounts",
70
+ tags: ["accounts"],
71
+ summary: "List accounts",
72
+ description:
73
+ "Admin-only, flag-gated. Returns every account with agentCount and memberCount.",
74
+ responses: {
75
+ 200: {
76
+ description: "Accounts",
77
+ content: { "application/json": { schema: AccountsListResponseSchema } },
78
+ },
79
+ ...errorResponses,
80
+ },
81
+ });
82
+
83
+ const getAccountRoute = createRoute({
84
+ method: "get",
85
+ path: "/accounts/{id}",
86
+ tags: ["accounts"],
87
+ summary: "Get an account",
88
+ description: "Admin-only, flag-gated.",
89
+ request: { params: AccountIdParamSchema },
90
+ responses: {
91
+ 200: {
92
+ description: "Account",
93
+ content: { "application/json": { schema: AccountResponseSchema } },
94
+ },
95
+ ...errorResponses,
96
+ },
97
+ });
98
+
99
+ const createAccountRoute = createRoute({
100
+ method: "post",
101
+ path: "/accounts",
102
+ tags: ["accounts"],
103
+ summary: "Create an account",
104
+ description:
105
+ "Admin-only, flag-gated. Creates the account and seeds ownerEmail as its owner member, so that owner's first login joins this account. 409 if ownerEmail already belongs to an account.",
106
+ request: {
107
+ body: {
108
+ content: { "application/json": { schema: CreateAccountBodySchema } },
109
+ },
110
+ },
111
+ responses: {
112
+ 201: {
113
+ description: "Account created",
114
+ content: { "application/json": { schema: AccountResponseSchema } },
115
+ },
116
+ 400: { description: "Bad request", ...jsonError },
117
+ 409: { description: "Owner email already in an account", ...jsonError },
118
+ ...errorResponses,
119
+ },
120
+ });
121
+
122
+ const patchAccountRoute = createRoute({
123
+ method: "patch",
124
+ path: "/accounts/{id}",
125
+ tags: ["accounts"],
126
+ summary: "Update an account",
127
+ description:
128
+ "Admin-only, flag-gated. Updates name, maxAgents, plan, status and/or trialExpiresAt. status=suspended (or trial_expired) disables every enabled cron of the account's agents with a lockdown marker; status=active re-enables exactly those, and requires trialExpiresAt to be cleared or in the future (422 otherwise). Nothing is deleted.",
129
+ request: {
130
+ params: AccountIdParamSchema,
131
+ body: {
132
+ content: { "application/json": { schema: PatchAccountBodySchema } },
133
+ },
134
+ },
135
+ responses: {
136
+ 200: {
137
+ description: "Account updated",
138
+ content: { "application/json": { schema: AccountResponseSchema } },
139
+ },
140
+ 400: { description: "Bad request", ...jsonError },
141
+ 422: {
142
+ description: "Reactivation with trialExpiresAt still in the past",
143
+ ...jsonError,
144
+ },
145
+ ...errorResponses,
146
+ },
147
+ });
148
+
149
+ function serialize(a: {
150
+ id: string;
151
+ name: string;
152
+ status: string;
153
+ maxAgents: number;
154
+ plan: string | null;
155
+ trialExpiresAt: Date | null;
156
+ createdAt: Date;
157
+ updatedAt: Date;
158
+ agentCount: number;
159
+ memberCount: number;
160
+ }) {
161
+ return {
162
+ id: a.id,
163
+ name: a.name,
164
+ status: a.status as "active" | "suspended" | "trial_expired",
165
+ maxAgents: a.maxAgents,
166
+ plan: a.plan,
167
+ trialExpiresAt: a.trialExpiresAt?.toISOString() ?? null,
168
+ createdAt: a.createdAt.toISOString(),
169
+ updatedAt: a.updatedAt.toISOString(),
170
+ agentCount: a.agentCount,
171
+ memberCount: a.memberCount,
172
+ };
173
+ }
174
+
175
+ export function createAccountsApp(
176
+ deps: AccountsApiDeps,
177
+ ): OpenAPIHono<AdminAuthEnv> {
178
+ const { accountService, selfServe } = deps;
179
+
180
+ const app = new OpenAPIHono<AdminAuthEnv>({
181
+ defaultHook: (result, c) => {
182
+ if (!result.success) {
183
+ const message = result.error.issues
184
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
185
+ .join(", ");
186
+ return c.json({ error: message }, 400);
187
+ }
188
+ },
189
+ });
190
+
191
+ app.onError((err, c) => {
192
+ if (err instanceof ApiError && err.statusCode < 500) {
193
+ return c.json(
194
+ { error: err.message },
195
+ err.statusCode as 400 | 401 | 403 | 404 | 409 | 422,
196
+ );
197
+ }
198
+ if (err instanceof HTTPException && err.status < 500) {
199
+ return c.json({ error: err.message }, err.status as 400 | 415);
200
+ }
201
+ console.error("[accounts-api] unhandled error:", err);
202
+ return c.json({ error: "Internal server error" }, 500);
203
+ });
204
+
205
+ const authMiddleware = createAdminAuthMiddleware({
206
+ sessionSecret: deps.sessionSecret,
207
+ agentTokenService: deps.agentTokenService,
208
+ adminApiKeys: deps.adminApiKeys,
209
+ });
210
+
211
+ // Flag gate runs before auth so a disabled deployment reveals nothing.
212
+ const flagGate: Parameters<typeof app.use>[1] = async (c, next) => {
213
+ if (selfServe?.enabled !== true) return c.json({ error: "Not found" }, 404);
214
+ return next();
215
+ };
216
+ // "/accounts/*" also matches the bare "/accounts" path in Hono.
217
+ app.use("/accounts/*", flagGate);
218
+ app.use("/accounts/*", authMiddleware);
219
+
220
+ const requireAdmin = (c: { get: (k: "isAdmin") => unknown }) => {
221
+ if (c.get("isAdmin") !== true) {
222
+ throw new ForbiddenError("Admin access required");
223
+ }
224
+ };
225
+
226
+ app.openapi(listAccountsRoute, async (c) => {
227
+ requireAdmin(c);
228
+ const accounts = await accountService.listWithCounts();
229
+ return c.json({ accounts: accounts.map(serialize) }, 200);
230
+ });
231
+
232
+ app.openapi(getAccountRoute, async (c) => {
233
+ requireAdmin(c);
234
+ const { id } = c.req.valid("param");
235
+ const account = await accountService.getWithCounts(id);
236
+ if (!account) throw new NotFoundError(`account ${id} not found`);
237
+ return c.json(serialize(account), 200);
238
+ });
239
+
240
+ app.openapi(createAccountRoute, async (c) => {
241
+ requireAdmin(c);
242
+ const body = c.req.valid("json");
243
+ let createdId: string;
244
+ try {
245
+ const created = await accountService.create(body.name, body.ownerEmail, {
246
+ maxAgents: body.maxAgents,
247
+ ...(body.plan !== undefined ? { plan: body.plan } : {}),
248
+ ...(body.trialExpiresAt !== undefined
249
+ ? {
250
+ trialExpiresAt: body.trialExpiresAt
251
+ ? new Date(body.trialExpiresAt)
252
+ : null,
253
+ }
254
+ : {}),
255
+ });
256
+ createdId = created.id;
257
+ } catch (err) {
258
+ if ((err as { code?: string }).code === "P2002") {
259
+ throw new ConflictError("ownerEmail already belongs to an account");
260
+ }
261
+ throw err;
262
+ }
263
+ const account = await accountService.getWithCounts(createdId);
264
+ if (!account) throw new NotFoundError(`account ${createdId} not found`);
265
+ return c.json(serialize(account), 201);
266
+ });
267
+
268
+ app.openapi(patchAccountRoute, async (c) => {
269
+ requireAdmin(c);
270
+ const { id } = c.req.valid("param");
271
+ const body = c.req.valid("json");
272
+ if (!(await accountService.getWithCounts(id))) {
273
+ throw new NotFoundError(`account ${id} not found`);
274
+ }
275
+ const { trialExpiresAt, ...rest } = body;
276
+ const data: UpdateAccountInput = {
277
+ ...rest,
278
+ ...(trialExpiresAt !== undefined
279
+ ? { trialExpiresAt: trialExpiresAt ? new Date(trialExpiresAt) : null }
280
+ : {}),
281
+ };
282
+ await deps.accountLifecycle.update(id, data);
283
+ const account = await accountService.getWithCounts(id);
284
+ if (!account) throw new NotFoundError(`account ${id} not found`);
285
+ return c.json(serialize(account), 200);
286
+ });
287
+
288
+ return app;
289
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * admin/src/accounts-api.unit.test.ts
3
+ * Parse/reject tests for the /accounts OpenAPI schemas (SSP-5.1).
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ AccountResponseSchema,
9
+ CreateAccountBodySchema,
10
+ PatchAccountBodySchema,
11
+ } from "./openapi-schemas.ts";
12
+
13
+ describe("CreateAccountBodySchema", () => {
14
+ test("accepts the minimal body", () => {
15
+ const r = CreateAccountBodySchema.safeParse({
16
+ name: "Acme",
17
+ ownerEmail: "Owner@Acme.com",
18
+ maxAgents: 3,
19
+ });
20
+ expect(r.success).toBe(true);
21
+ });
22
+
23
+ test("accepts plan and trialExpiresAt", () => {
24
+ const r = CreateAccountBodySchema.safeParse({
25
+ name: "Acme",
26
+ ownerEmail: "o@acme.com",
27
+ maxAgents: 0,
28
+ plan: "pro",
29
+ trialExpiresAt: "2030-01-01T00:00:00.000Z",
30
+ });
31
+ expect(r.success).toBe(true);
32
+ });
33
+
34
+ test.each([
35
+ ["empty name", { name: "", ownerEmail: "o@a.com", maxAgents: 1 }],
36
+ ["bad email", { name: "A", ownerEmail: "nope", maxAgents: 1 }],
37
+ ["negative maxAgents", { name: "A", ownerEmail: "o@a.com", maxAgents: -1 }],
38
+ [
39
+ "fractional maxAgents",
40
+ { name: "A", ownerEmail: "o@a.com", maxAgents: 1.5 },
41
+ ],
42
+ ["missing maxAgents", { name: "A", ownerEmail: "o@a.com" }],
43
+ [
44
+ "bad trialExpiresAt",
45
+ { name: "A", ownerEmail: "o@a.com", maxAgents: 1, trialExpiresAt: "x" },
46
+ ],
47
+ ])("rejects %s", (_label, body) => {
48
+ expect(CreateAccountBodySchema.safeParse(body).success).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("PatchAccountBodySchema", () => {
53
+ test("accepts partial bodies and nullable plan/trial", () => {
54
+ expect(PatchAccountBodySchema.safeParse({ maxAgents: 3 }).success).toBe(
55
+ true,
56
+ );
57
+ expect(
58
+ PatchAccountBodySchema.safeParse({ plan: null, trialExpiresAt: null })
59
+ .success,
60
+ ).toBe(true);
61
+ expect(
62
+ PatchAccountBodySchema.safeParse({ status: "suspended" }).success,
63
+ ).toBe(true);
64
+ });
65
+
66
+ test("rejects unknown status and negative maxAgents", () => {
67
+ expect(PatchAccountBodySchema.safeParse({ status: "bogus" }).success).toBe(
68
+ false,
69
+ );
70
+ expect(PatchAccountBodySchema.safeParse({ maxAgents: -2 }).success).toBe(
71
+ false,
72
+ );
73
+ });
74
+ });
75
+
76
+ describe("AccountResponseSchema", () => {
77
+ test("parses a full account with counts", () => {
78
+ const now = new Date().toISOString();
79
+ const r = AccountResponseSchema.safeParse({
80
+ id: "a1",
81
+ name: "Acme",
82
+ status: "active",
83
+ maxAgents: 3,
84
+ plan: null,
85
+ trialExpiresAt: null,
86
+ createdAt: now,
87
+ updatedAt: now,
88
+ agentCount: 1,
89
+ memberCount: 2,
90
+ });
91
+ expect(r.success).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * admin/src/accounts.integration.test.ts
3
+ * Integration tests for AccountService, AccountMemberService and
4
+ * AccountInviteService (SSP-1.2) against real Postgres.
5
+ *
6
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
10
+ import type { PrismaClient } from "../prisma/client/client.ts";
11
+ import { AccountInviteService } from "./account-invites.ts";
12
+ import {
13
+ AccountMemberNotFoundError,
14
+ AccountMemberService,
15
+ LastOwnerError,
16
+ } from "./account-members.ts";
17
+ import { AccountService } from "./accounts.ts";
18
+ import { createAdminPrismaClient } from "./prisma-client.ts";
19
+
20
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
21
+
22
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
23
+
24
+ describeOrSkip("Account services (integration)", () => {
25
+ let prisma: PrismaClient;
26
+ let accounts: AccountService;
27
+ let members: AccountMemberService;
28
+ let invites: AccountInviteService;
29
+
30
+ beforeEach(async () => {
31
+ prisma = createAdminPrismaClient(TEST_DB as string);
32
+ await prisma.accountInvite.deleteMany();
33
+ await prisma.accountMember.deleteMany();
34
+ await prisma.agent.deleteMany();
35
+ await prisma.account.deleteMany();
36
+ accounts = new AccountService(prisma);
37
+ members = new AccountMemberService(prisma);
38
+ invites = new AccountInviteService(prisma);
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await prisma.$disconnect();
43
+ });
44
+
45
+ describe("AccountService", () => {
46
+ it("create() makes the account with defaults and a lowercased owner row", async () => {
47
+ const account = await accounts.create("Acme", "Owner@Acme.COM");
48
+ expect(account.status).toBe("active");
49
+ expect(account.maxAgents).toBe(0);
50
+ const rows = await members.listByAccount(account.id);
51
+ expect(rows.map((r) => [r.email, r.role])).toEqual([
52
+ ["owner@acme.com", "owner"],
53
+ ]);
54
+ });
55
+
56
+ it("create() rolls back the account when the owner email is taken", async () => {
57
+ await accounts.create("A", "dup@x.com");
58
+ await expect(
59
+ (async () => accounts.create("B", "DUP@x.com"))(),
60
+ ).rejects.toThrow();
61
+ expect((await accounts.list()).map((a) => a.name)).toEqual(["A"]);
62
+ });
63
+
64
+ it("getByMemberEmail() is case-insensitive", async () => {
65
+ const account = await accounts.create("Acme", "o@acme.com");
66
+ expect((await accounts.getByMemberEmail("O@ACME.com"))?.id).toBe(
67
+ account.id,
68
+ );
69
+ expect(await accounts.getByMemberEmail("nobody@x.com")).toBeNull();
70
+ });
71
+
72
+ it("countAgents() counts only agents of that account", async () => {
73
+ const a = await accounts.create("A", "a@x.com");
74
+ const b = await accounts.create("B", "b@x.com");
75
+ await prisma.agent.create({ data: { name: "1", accountId: a.id } });
76
+ await prisma.agent.create({ data: { name: "2", accountId: a.id } });
77
+ await prisma.agent.create({ data: { name: "3", accountId: b.id } });
78
+ expect(await accounts.countAgents(a.id)).toBe(2);
79
+ });
80
+
81
+ it("existing agents keep accountId NULL and an account with agents cannot be deleted", async () => {
82
+ const legacy = await prisma.agent.create({ data: { name: "legacy" } });
83
+ expect(legacy.accountId).toBeNull();
84
+ const a = await accounts.create("A", "a@x.com");
85
+ await prisma.agent.create({ data: { name: "x", accountId: a.id } });
86
+ await expect(
87
+ (async () => prisma.account.delete({ where: { id: a.id } }))(),
88
+ ).rejects.toThrow();
89
+ });
90
+
91
+ it("update() changes fields", async () => {
92
+ const a = await accounts.create("A", "a@x.com");
93
+ const updated = await accounts.update(a.id, {
94
+ status: "suspended",
95
+ maxAgents: 3,
96
+ });
97
+ expect(updated.status).toBe("suspended");
98
+ expect(updated.maxAgents).toBe(3);
99
+ });
100
+ });
101
+
102
+ describe("AccountMemberService", () => {
103
+ it("UNIQUE(email) rejects the same email in a second account", async () => {
104
+ await accounts.create("A", "shared@x.com");
105
+ const b = await accounts.create("B", "b@x.com");
106
+ await expect(
107
+ (async () => members.add(b.id, "Shared@X.com"))(),
108
+ ).rejects.toThrow();
109
+ });
110
+
111
+ it("refuses to remove the last owner and changes nothing", async () => {
112
+ const a = await accounts.create("A", "o@x.com");
113
+ await members.add(a.id, "m@x.com");
114
+ await expect(
115
+ (async () => members.remove(a.id, "O@x.com"))(),
116
+ ).rejects.toBeInstanceOf(LastOwnerError);
117
+ expect(await members.listByAccount(a.id)).toHaveLength(2);
118
+ });
119
+
120
+ it("refuses to demote the last owner and changes nothing", async () => {
121
+ const a = await accounts.create("A", "o@x.com");
122
+ await expect(
123
+ (async () => members.demote(a.id, "o@x.com"))(),
124
+ ).rejects.toBeInstanceOf(LastOwnerError);
125
+ expect((await members.getByEmail("o@x.com"))?.role).toBe("owner");
126
+ });
127
+
128
+ it("allows demoting/removing an owner once another owner exists", async () => {
129
+ const a = await accounts.create("A", "o@x.com");
130
+ await members.add(a.id, "m@x.com");
131
+ await members.promote(a.id, "M@x.com");
132
+ expect((await members.demote(a.id, "o@x.com")).role).toBe("member");
133
+ await expect(
134
+ (async () => members.remove(a.id, "m@x.com"))(),
135
+ ).rejects.toBeInstanceOf(LastOwnerError);
136
+ });
137
+
138
+ it("removes a plain member and errors on unknown members", async () => {
139
+ const a = await accounts.create("A", "o@x.com");
140
+ await members.add(a.id, "m@x.com");
141
+ await members.remove(a.id, "m@x.com");
142
+ expect(await members.getByEmail("m@x.com")).toBeNull();
143
+ await expect(
144
+ (async () => members.remove(a.id, "m@x.com"))(),
145
+ ).rejects.toBeInstanceOf(AccountMemberNotFoundError);
146
+ });
147
+ });
148
+
149
+ describe("AccountInviteService", () => {
150
+ it("create() lowercases and listPending() returns it", async () => {
151
+ const a = await accounts.create("A", "o@x.com");
152
+ await invites.create(a.id, "New@X.com", "o@x.com");
153
+ const pending = await invites.listPending(a.id);
154
+ expect(pending.map((i) => i.email)).toEqual(["new@x.com"]);
155
+ });
156
+
157
+ it("acceptForEmail() marks accepted and returns the account; second call is a no-op", async () => {
158
+ const a = await accounts.create("A", "o@x.com");
159
+ await invites.create(a.id, "new@x.com", "o@x.com");
160
+ const first = await invites.acceptForEmail("NEW@x.com");
161
+ expect(first?.id).toBe(a.id);
162
+ expect(await invites.listPending(a.id)).toHaveLength(0);
163
+ const row = await prisma.accountInvite.findFirst({
164
+ where: { accountId: a.id },
165
+ });
166
+ expect(row?.acceptedAt).not.toBeNull();
167
+ expect(await invites.acceptForEmail("new@x.com")).toBeNull();
168
+ });
169
+
170
+ it("revoke() removes a pending invite", async () => {
171
+ const a = await accounts.create("A", "o@x.com");
172
+ await invites.create(a.id, "new@x.com", "o@x.com");
173
+ await invites.revoke(a.id, "NEW@x.com");
174
+ expect(await invites.listPending(a.id)).toHaveLength(0);
175
+ expect(await invites.acceptForEmail("new@x.com")).toBeNull();
176
+ });
177
+ });
178
+ });