@app-vitals/agent-admin 0.0.1 → 0.1.1
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.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- 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
|
+
}
|