@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,71 @@
1
+ /**
2
+ * admin/src/account-created-notifier.ts
3
+ * Best-effort operator notification when self-serve sign-in auto-creates an
4
+ * account (SSP-3.3). Reuses the existing web-push wiring — no new integration.
5
+ * When push isn't configured, the structured `account_created` log emitted by
6
+ * the Google callback (SSP-3.1) is the only signal.
7
+ *
8
+ * Never throws and is never awaited by the login path: a notifier failure
9
+ * must not fail or slow sign-in.
10
+ */
11
+
12
+ import type { PushDetailLevel } from "./push-content.ts";
13
+
14
+ interface AccountCreatedInfo {
15
+ accountId: string;
16
+ emailDomain: string;
17
+ }
18
+
19
+ export type AccountCreatedNotifier = (
20
+ info: AccountCreatedInfo,
21
+ ) => Promise<void>;
22
+
23
+ interface AccountCreatedPushLike {
24
+ sendToUsers(
25
+ emails: string[],
26
+ buildPayload: (level: PushDetailLevel) => string,
27
+ ): Promise<unknown>;
28
+ }
29
+
30
+ const TITLE = "New account created";
31
+
32
+ /** Returns undefined when push or the operator allowlist isn't configured. */
33
+ export function createAccountCreatedNotifier(deps: {
34
+ pushService?: AccountCreatedPushLike;
35
+ adminEmails: string[];
36
+ }): AccountCreatedNotifier | undefined {
37
+ const { pushService, adminEmails } = deps;
38
+ if (!pushService || adminEmails.length === 0) return undefined;
39
+ return async (info) => {
40
+ // Generic payload only: the domain is user-derived, so it stays out of the
41
+ // lock-screen-visible fields; operators read it from the structured log.
42
+ await pushService.sendToUsers(adminEmails, () =>
43
+ JSON.stringify({
44
+ title: TITLE,
45
+ body: "",
46
+ url: "/admin/agents",
47
+ tag: `account-created:${info.accountId}`,
48
+ }),
49
+ );
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Fire-and-forget: invokes the notifier without awaiting it and swallows both
55
+ * synchronous throws and async rejections.
56
+ */
57
+ export function fireAccountCreatedNotification(
58
+ notifier: AccountCreatedNotifier | undefined,
59
+ info: AccountCreatedInfo,
60
+ logError: (msg: string, err: unknown) => void = (msg, err) =>
61
+ console.error(msg, err),
62
+ ): void {
63
+ if (!notifier) return;
64
+ try {
65
+ notifier(info).catch((err) =>
66
+ logError("[admin] account-created notification failed", err),
67
+ );
68
+ } catch (err) {
69
+ logError("[admin] account-created notification failed", err);
70
+ }
71
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createAccountCreatedNotifier,
4
+ fireAccountCreatedNotification,
5
+ } from "./account-created-notifier.ts";
6
+
7
+ const info = { accountId: "acc_1", emailDomain: "example.com" };
8
+
9
+ describe("createAccountCreatedNotifier", () => {
10
+ test("not configured: no push service or no admins → undefined", () => {
11
+ expect(createAccountCreatedNotifier({ adminEmails: ["a@x.com"] })).toBe(
12
+ undefined,
13
+ );
14
+ const pushService = { sendToUsers: mock(async () => ({})) };
15
+ expect(createAccountCreatedNotifier({ pushService, adminEmails: [] })).toBe(
16
+ undefined,
17
+ );
18
+ });
19
+
20
+ test("success: pushes one generic payload to the operators", async () => {
21
+ const sendToUsers = mock(async () => ({}));
22
+ const notifier = createAccountCreatedNotifier({
23
+ pushService: { sendToUsers },
24
+ adminEmails: ["a@x.com"],
25
+ });
26
+ await notifier?.(info);
27
+ expect(sendToUsers).toHaveBeenCalledTimes(1);
28
+ const [emails, build] = sendToUsers.mock.calls[0] as unknown as [
29
+ string[],
30
+ (l: string) => string,
31
+ ];
32
+ expect(emails).toEqual(["a@x.com"]);
33
+ const payload = JSON.parse(build("preview"));
34
+ expect(payload.title).toBe("New account created");
35
+ expect(payload.body).toBe("");
36
+ expect(payload.url).toBe("/admin/agents");
37
+ expect(JSON.stringify(payload)).not.toContain("example.com");
38
+ });
39
+ });
40
+
41
+ describe("fireAccountCreatedNotification", () => {
42
+ test("async rejection is caught and logged, never thrown", async () => {
43
+ const logError = mock(() => {});
44
+ const notifier = mock(async () => {
45
+ throw new Error("push down");
46
+ });
47
+ expect(() =>
48
+ fireAccountCreatedNotification(notifier, info, logError),
49
+ ).not.toThrow();
50
+ await Promise.resolve();
51
+ await Promise.resolve();
52
+ expect(notifier).toHaveBeenCalledTimes(1);
53
+ expect(logError).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ test("synchronous throw is caught and logged", () => {
57
+ const logError = mock(() => {});
58
+ const notifier = (() => {
59
+ throw new Error("boom");
60
+ }) as never;
61
+ expect(() =>
62
+ fireAccountCreatedNotification(notifier, info, logError),
63
+ ).not.toThrow();
64
+ expect(logError).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ test("not configured: no-op", () => {
68
+ expect(() => fireAccountCreatedNotification(undefined, info)).not.toThrow();
69
+ });
70
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * admin/src/account-email.ts
3
+ * Shared email normalization for the account services (SSP-1.2). All account
4
+ * emails are lowercased (and trimmed) on write and on read.
5
+ */
6
+
7
+ export function normalizeEmail(email: string): string {
8
+ return email.trim().toLowerCase();
9
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * admin/src/account-email.unit.test.ts
3
+ * Unit tests for normalizeEmail (admin/src/account-email.ts).
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { normalizeEmail } from "./account-email.ts";
8
+
9
+ describe("normalizeEmail", () => {
10
+ it("lowercases", () => {
11
+ expect(normalizeEmail("Dan@Example.COM")).toBe("dan@example.com");
12
+ });
13
+
14
+ it("trims surrounding whitespace", () => {
15
+ expect(normalizeEmail(" a@b.co \n")).toBe("a@b.co");
16
+ });
17
+
18
+ it("is idempotent", () => {
19
+ expect(normalizeEmail(normalizeEmail("X@Y.Z"))).toBe("x@y.z");
20
+ });
21
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * admin/src/account-invites.ts
3
+ * AccountInviteService — pending invites to join an account (SSP-1.2).
4
+ */
5
+
6
+ import type {
7
+ Account,
8
+ AccountInvite,
9
+ PrismaClient,
10
+ } from "../prisma/client/client.ts";
11
+ import { normalizeEmail } from "./account-email.ts";
12
+
13
+ export type { AccountInvite };
14
+
15
+ export class AccountInviteService {
16
+ constructor(private prisma: PrismaClient) {}
17
+
18
+ /**
19
+ * Create an invite. Re-inviting an email that already has an invite for this
20
+ * account resets it to pending (clears acceptedAt, updates invitedBy).
21
+ */
22
+ async create(
23
+ accountId: string,
24
+ email: string,
25
+ invitedBy: string,
26
+ ): Promise<AccountInvite> {
27
+ const normalized = normalizeEmail(email);
28
+ return this.prisma.accountInvite.upsert({
29
+ where: { accountId_email: { accountId, email: normalized } },
30
+ create: { accountId, email: normalized, invitedBy },
31
+ update: { invitedBy, acceptedAt: null },
32
+ });
33
+ }
34
+
35
+ async listPending(accountId: string): Promise<AccountInvite[]> {
36
+ return this.prisma.accountInvite.findMany({
37
+ where: { accountId, acceptedAt: null },
38
+ orderBy: { createdAt: "asc" },
39
+ });
40
+ }
41
+
42
+ /** Delete a pending invite. No-ops if absent or already accepted. */
43
+ async revoke(accountId: string, email: string): Promise<void> {
44
+ await this.prisma.accountInvite.deleteMany({
45
+ where: { accountId, email: normalizeEmail(email), acceptedAt: null },
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Accept the oldest pending invite for an email: marks it accepted and
51
+ * returns the account. Returns null if there is no pending invite, so a
52
+ * second call is a no-op.
53
+ */
54
+ async acceptForEmail(email: string): Promise<Account | null> {
55
+ const normalized = normalizeEmail(email);
56
+ return this.prisma.$transaction(async (tx) => {
57
+ const invite = await tx.accountInvite.findFirst({
58
+ where: { email: normalized, acceptedAt: null },
59
+ orderBy: { createdAt: "asc" },
60
+ include: { account: true },
61
+ });
62
+ if (!invite) return null;
63
+ const claimed = await tx.accountInvite.updateMany({
64
+ where: { id: invite.id, acceptedAt: null },
65
+ data: { acceptedAt: new Date() },
66
+ });
67
+ return claimed.count === 1 ? invite.account : null;
68
+ });
69
+ }
70
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * admin/src/account-lifecycle.integration.test.ts
3
+ * SSP-8.2 against real Postgres: account trial expiry (AccountTrialExpirySweeper),
4
+ * admin suspend, and reactivation (AccountLifecycle.update) all driving the
5
+ * SSP-8.1 cron lockdown marker. A FixedClock stands in for time.
6
+ *
7
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
11
+ import type { PrismaClient } from "../prisma/client/client.ts";
12
+ import { AccountLifecycle } from "./account-lifecycle.ts";
13
+ import { AccountTrialExpirySweeper } from "./account-trial-expiry-sweeper.ts";
14
+ import { AccountService } from "./accounts.ts";
15
+ import { AgentCronJobService } from "./agent-cron-jobs.ts";
16
+ import { FixedClock } from "./clock.ts";
17
+ import { UnprocessableEntityError } from "./errors.ts";
18
+ import { createAdminPrismaClient } from "./prisma-client.ts";
19
+ import { TrialExpirySweeper } from "./trial-expiry-sweeper.ts";
20
+
21
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
22
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
23
+
24
+ const DAY = 24 * 60 * 60 * 1000;
25
+ const NOW = new Date("2026-10-08T12:00:00.000Z");
26
+ const days = (n: number) => new Date(NOW.getTime() + n * DAY);
27
+
28
+ describeOrSkip("account lifecycle (integration)", () => {
29
+ let prisma: PrismaClient;
30
+ let accounts: AccountService;
31
+ let cronJobs: AgentCronJobService;
32
+ let lifecycle: AccountLifecycle;
33
+ let sweeper: AccountTrialExpirySweeper;
34
+
35
+ beforeEach(async () => {
36
+ prisma = createAdminPrismaClient(TEST_DB as string);
37
+ await prisma.agentToken.deleteMany();
38
+ await prisma.agentCronJob.deleteMany();
39
+ await prisma.agentTool.deleteMany();
40
+ await prisma.agentEnv.deleteMany();
41
+ await prisma.agent.deleteMany();
42
+ await prisma.accountInvite.deleteMany();
43
+ await prisma.accountMember.deleteMany();
44
+ await prisma.account.deleteMany();
45
+ accounts = new AccountService(prisma);
46
+ cronJobs = new AgentCronJobService(prisma);
47
+ const clock = FixedClock(NOW);
48
+ lifecycle = new AccountLifecycle({
49
+ accounts,
50
+ cronJobs,
51
+ clock,
52
+ log: () => {},
53
+ });
54
+ sweeper = new AccountTrialExpirySweeper({
55
+ accounts,
56
+ lifecycle,
57
+ clock,
58
+ log: () => {},
59
+ });
60
+ });
61
+
62
+ afterEach(async () => {
63
+ await prisma.$disconnect();
64
+ });
65
+
66
+ async function makeAccount(
67
+ opts: {
68
+ status?: "active" | "suspended" | "trial_expired";
69
+ trialExpiresAt?: Date | null;
70
+ trialExpiryWarnedAt?: Date | null;
71
+ } = {},
72
+ ): Promise<string> {
73
+ const a = await prisma.account.create({
74
+ data: {
75
+ name: "Acme",
76
+ status: opts.status ?? "active",
77
+ trialExpiresAt: opts.trialExpiresAt ?? null,
78
+ trialExpiryWarnedAt: opts.trialExpiryWarnedAt ?? null,
79
+ },
80
+ });
81
+ return a.id;
82
+ }
83
+
84
+ async function makeAgent(
85
+ accountId: string | null,
86
+ trialExpiresAt: Date | null = null,
87
+ ): Promise<string> {
88
+ const a = await prisma.agent.create({
89
+ data: { name: "agent", typeName: "coding", accountId, trialExpiresAt },
90
+ });
91
+ return a.id;
92
+ }
93
+
94
+ async function makeCron(agentId: string, enabled = true): Promise<string> {
95
+ const c = await prisma.agentCronJob.create({
96
+ data: {
97
+ agentId,
98
+ schedule: "0 * * * *",
99
+ prompt: "p",
100
+ channel: "C1",
101
+ silent: false,
102
+ enabled,
103
+ },
104
+ });
105
+ return c.id;
106
+ }
107
+
108
+ const cron = (id: string) =>
109
+ prisma.agentCronJob.findUniqueOrThrow({ where: { id } });
110
+ const account = (id: string) =>
111
+ prisma.account.findUniqueOrThrow({ where: { id } });
112
+
113
+ // ─── Expiry ────────────────────────────────────────────────────────────────
114
+
115
+ it("expired active account -> trial_expired, every cron disabled + marked; re-run does nothing", async () => {
116
+ const acc = await makeAccount({ trialExpiresAt: days(-1) });
117
+ const agentA = await makeAgent(acc);
118
+ const agentB = await makeAgent(acc);
119
+ const c1 = await makeCron(agentA);
120
+ const c2 = await makeCron(agentB);
121
+ const userOff = await makeCron(agentA, false);
122
+
123
+ const first = await sweeper.tick();
124
+ expect(first).toEqual({ expired: 1, disabled: 2 });
125
+ expect((await account(acc)).status).toBe("trial_expired");
126
+ for (const id of [c1, c2]) {
127
+ const row = await cron(id);
128
+ expect(row.enabled).toBe(false);
129
+ expect(row.lockdownDisabledAt).not.toBeNull();
130
+ }
131
+ // A cron the user had already disabled is left unmarked.
132
+ expect((await cron(userOff)).lockdownDisabledAt).toBeNull();
133
+
134
+ const before = await cron(c1);
135
+ const second = await sweeper.tick();
136
+ expect(second).toEqual({ expired: 0, disabled: 0 });
137
+ const after = await cron(c1);
138
+ expect(after.updatedAt).toEqual(before.updatedAt);
139
+ expect(after.lockdownDisabledAt).toEqual(before.lockdownDisabledAt);
140
+ });
141
+
142
+ it("leaves future-trial, no-trial and expiring-exactly-now accounts alone", async () => {
143
+ const future = await makeAccount({ trialExpiresAt: days(2) });
144
+ const none = await makeAccount();
145
+ const exactlyNow = await makeAccount({ trialExpiresAt: NOW });
146
+ const crons = await Promise.all(
147
+ [future, none, exactlyNow].map(async (a) => makeCron(await makeAgent(a))),
148
+ );
149
+
150
+ expect(await sweeper.tick()).toEqual({ expired: 0, disabled: 0 });
151
+ for (const a of [future, none, exactlyNow]) {
152
+ expect((await account(a)).status).toBe("active");
153
+ }
154
+ for (const c of crons) expect((await cron(c)).enabled).toBe(true);
155
+ });
156
+
157
+ it("does not flip a suspended account with a lapsed trial to trial_expired", async () => {
158
+ const acc = await makeAccount({
159
+ status: "suspended",
160
+ trialExpiresAt: days(-1),
161
+ });
162
+ await sweeper.tick();
163
+ expect((await account(acc)).status).toBe("suspended");
164
+ });
165
+
166
+ it("re-locks a cron re-enabled while the account is locked", async () => {
167
+ const acc = await makeAccount({ status: "suspended" });
168
+ const agent = await makeAgent(acc);
169
+ const c = await makeCron(agent);
170
+
171
+ expect(await sweeper.tick()).toEqual({ expired: 0, disabled: 1 });
172
+ const row = await cron(c);
173
+ expect(row.enabled).toBe(false);
174
+ expect(row.lockdownDisabledAt).not.toBeNull();
175
+ });
176
+
177
+ // ─── Suspend ───────────────────────────────────────────────────────────────
178
+
179
+ it("suspend locks down every enabled cron of the account's agents only", async () => {
180
+ const acc = await makeAccount();
181
+ const other = await makeAccount();
182
+ const agent = await makeAgent(acc);
183
+ const c = await makeCron(agent);
184
+ const userOff = await makeCron(agent, false);
185
+ const otherCron = await makeCron(await makeAgent(other));
186
+ const freeCron = await makeCron(await makeAgent(null));
187
+
188
+ const updated = await lifecycle.update(acc, { status: "suspended" });
189
+ expect(updated.status).toBe("suspended");
190
+
191
+ const row = await cron(c);
192
+ expect(row.enabled).toBe(false);
193
+ expect(row.lockdownDisabledAt).not.toBeNull();
194
+ expect((await cron(userOff)).lockdownDisabledAt).toBeNull();
195
+ expect((await cron(otherCron)).enabled).toBe(true);
196
+ expect((await cron(freeCron)).enabled).toBe(true);
197
+ });
198
+
199
+ it("suspending an already-suspended account is a no-op", async () => {
200
+ const acc = await makeAccount();
201
+ const c = await makeCron(await makeAgent(acc));
202
+ await lifecycle.update(acc, { status: "suspended" });
203
+ const before = await cron(c);
204
+ await lifecycle.update(acc, { status: "suspended" });
205
+ expect((await cron(c)).updatedAt).toEqual(before.updatedAt);
206
+ });
207
+
208
+ // ─── Reactivate ────────────────────────────────────────────────────────────
209
+
210
+ it("reactivation restores exactly the lockdown-disabled crons, not user-disabled ones", async () => {
211
+ const acc = await makeAccount();
212
+ const agent = await makeAgent(acc);
213
+ const locked = await makeCron(agent);
214
+ const userOff = await makeCron(agent, false);
215
+
216
+ await lifecycle.update(acc, { status: "suspended" });
217
+ // A cron the user disables manually while suspended carries no marker.
218
+ const userOffDuring = await makeCron(agent, false);
219
+
220
+ await lifecycle.update(acc, { status: "active" });
221
+
222
+ const row = await cron(locked);
223
+ expect(row.enabled).toBe(true);
224
+ expect(row.lockdownDisabledAt).toBeNull();
225
+ expect((await cron(userOff)).enabled).toBe(false);
226
+ expect((await cron(userOffDuring)).enabled).toBe(false);
227
+ });
228
+
229
+ it("reactivating a trial_expired account with a future trial restores crons and resets trialExpiryWarnedAt", async () => {
230
+ const acc = await makeAccount({
231
+ trialExpiresAt: days(-1),
232
+ trialExpiryWarnedAt: days(-3),
233
+ });
234
+ const c = await makeCron(await makeAgent(acc));
235
+ await sweeper.tick();
236
+ expect((await cron(c)).enabled).toBe(false);
237
+
238
+ const updated = await lifecycle.update(acc, {
239
+ status: "active",
240
+ trialExpiresAt: days(30),
241
+ });
242
+ expect(updated.status).toBe("active");
243
+ expect(updated.trialExpiryWarnedAt).toBeNull();
244
+ expect((await cron(c)).enabled).toBe(true);
245
+
246
+ // And the expiry sweeper leaves it alone now.
247
+ expect(await sweeper.tick()).toEqual({ expired: 0, disabled: 0 });
248
+ });
249
+
250
+ it("reactivating with trialExpiresAt cleared is allowed", async () => {
251
+ const acc = await makeAccount({
252
+ status: "trial_expired",
253
+ trialExpiresAt: days(-1),
254
+ });
255
+ const updated = await lifecycle.update(acc, {
256
+ status: "active",
257
+ trialExpiresAt: null,
258
+ });
259
+ expect(updated.status).toBe("active");
260
+ expect(updated.trialExpiresAt).toBeNull();
261
+ });
262
+
263
+ it("rejects reactivation while trialExpiresAt is still in the past (no write)", async () => {
264
+ const acc = await makeAccount({
265
+ status: "trial_expired",
266
+ trialExpiresAt: days(-1),
267
+ });
268
+ const c = await makeCron(await makeAgent(acc));
269
+ await sweeper.tick();
270
+
271
+ await expect(lifecycle.update(acc, { status: "active" })).rejects.toThrow(
272
+ UnprocessableEntityError,
273
+ );
274
+ expect((await account(acc)).status).toBe("trial_expired");
275
+ expect((await cron(c)).enabled).toBe(false);
276
+ });
277
+
278
+ it("does not restore crons of an agent whose own per-agent trial has lapsed", async () => {
279
+ const acc = await makeAccount();
280
+ const ownExpired = await makeAgent(acc, days(-1));
281
+ const c = await makeCron(ownExpired);
282
+ await lifecycle.update(acc, { status: "suspended" });
283
+ await lifecycle.update(acc, { status: "active" });
284
+ expect((await cron(c)).enabled).toBe(false);
285
+ });
286
+
287
+ it("changing trialExpiresAt resets trialExpiryWarnedAt; resubmitting the same value does not", async () => {
288
+ const acc = await makeAccount({
289
+ trialExpiresAt: days(2),
290
+ trialExpiryWarnedAt: days(-1),
291
+ });
292
+ const same = await lifecycle.update(acc, {
293
+ name: "Renamed",
294
+ trialExpiresAt: days(2),
295
+ });
296
+ expect(same.trialExpiryWarnedAt).not.toBeNull();
297
+ const extended = await lifecycle.update(acc, { trialExpiresAt: days(20) });
298
+ expect(extended.trialExpiryWarnedAt).toBeNull();
299
+ });
300
+
301
+ it("update on a missing account throws NotFound", async () => {
302
+ await expect(
303
+ lifecycle.update("nope", { status: "suspended" }),
304
+ ).rejects.toThrow("not found");
305
+ });
306
+
307
+ // ─── Regression: per-agent trial expiry ────────────────────────────────────
308
+
309
+ it("per-agent trialExpiresAt still locks crons for agents with accountId null or set", async () => {
310
+ const acc = await makeAccount();
311
+ const inAccount = await makeAgent(acc, days(-1));
312
+ const free = await makeAgent(null, days(-1));
313
+ const c1 = await makeCron(inAccount);
314
+ const c2 = await makeCron(free);
315
+
316
+ // Account sweeper alone doesn't touch them (account is active, no trial)…
317
+ expect(await sweeper.tick()).toEqual({ expired: 0, disabled: 0 });
318
+ // …the per-agent sweeper still does, exactly as before.
319
+ const perAgent = new TrialExpirySweeper({
320
+ agentCronJobService: cronJobs,
321
+ clock: FixedClock(NOW),
322
+ log: () => {},
323
+ });
324
+ expect((await perAgent.tick()).disabled).toBe(2);
325
+ expect((await cron(c1)).enabled).toBe(false);
326
+ expect((await cron(c2)).enabled).toBe(false);
327
+ expect((await account(acc)).status).toBe("active");
328
+ });
329
+ });