@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,237 @@
1
+ /**
2
+ * admin/src/anthropic-identity-client.fixtures.ts
3
+ * Test support for the Anthropic identity client (never imported by production
4
+ * code):
5
+ *
6
+ * - the recorded fixture loader and a replaying `fetch` double that asserts
7
+ * every outgoing request matches the next recorded interaction, and
8
+ * - `FakeAnthropicTokenExchange`, a test double for POST /v1/oauth/token.
9
+ *
10
+ * KNOWN GAP: POST /v1/oauth/token is not part of the Anthropic OpenAPI spec
11
+ * (anthropics/anthropic-sdk-typescript scripts/mock-spec.json.gz), so the token
12
+ * exchange cannot be validated as a recorded fixture against it. The fake below mirrors the
13
+ * wire format the Anthropic SDK itself sends (RFC 7523 jwt-bearer grant) and is
14
+ * the only coverage for that call. It is documented in
15
+ * docs/test-readiness/test-system.md.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+
20
+ // ─── Recorded fixtures ───────────────────────────────────────────────────────
21
+
22
+ export interface RecordedRequest {
23
+ method: string;
24
+ path: string;
25
+ query?: Record<string, string>;
26
+ body?: unknown;
27
+ }
28
+
29
+ export interface RecordedResponse {
30
+ status: number;
31
+ body: unknown;
32
+ }
33
+
34
+ export interface RecordedInteraction {
35
+ request: RecordedRequest;
36
+ response: RecordedResponse;
37
+ }
38
+
39
+ export interface AnthropicRecordedFixtures {
40
+ scenarios: Record<string, RecordedInteraction[]>;
41
+ }
42
+
43
+ export const RECORDED_FIXTURES_PATH = new URL(
44
+ "./fixtures/anthropic-identity-recorded-fixtures.json",
45
+ import.meta.url,
46
+ ).pathname;
47
+
48
+ export function loadRecordedFixtures(): AnthropicRecordedFixtures {
49
+ return JSON.parse(
50
+ readFileSync(RECORDED_FIXTURES_PATH, "utf-8"),
51
+ ) as AnthropicRecordedFixtures;
52
+ }
53
+
54
+ export interface SeenRequest {
55
+ method: string;
56
+ url: URL;
57
+ headers: Headers;
58
+ body: unknown;
59
+ }
60
+
61
+ /** Type a hand-written async function as a `fetch` (adds no behaviour). */
62
+ export function asFetch(
63
+ fn: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
64
+ ): typeof fetch {
65
+ return fn as unknown as typeof fetch;
66
+ }
67
+
68
+ export const TOKEN_EXCHANGE_PATH = "/v1/oauth/token";
69
+
70
+ export interface RecordedFixtureFetch {
71
+ fetchFn: typeof fetch;
72
+ /** Every request seen by the double, including token exchanges. */
73
+ seen: SeenRequest[];
74
+ /** API (non token-exchange) requests only. */
75
+ apiRequests: () => SeenRequest[];
76
+ }
77
+
78
+ async function readBody(input: RequestInfo | URL, init?: RequestInit) {
79
+ const raw =
80
+ typeof init?.body === "string"
81
+ ? init.body
82
+ : input instanceof Request
83
+ ? await input.clone().text()
84
+ : undefined;
85
+ if (!raw) return undefined;
86
+ return JSON.parse(raw) as unknown;
87
+ }
88
+
89
+ /**
90
+ * Build an injected `fetch` that replays the interactions of one recorded fixture
91
+ * scenario in order, throwing if a request deviates from the recording (method,
92
+ * path, query or JSON body). Requests to the token endpoint are routed to
93
+ * `tokenExchange` instead of the recorded fixtures.
94
+ */
95
+ export function recordedFixtureFetch(
96
+ scenario: string,
97
+ opts: { tokenExchange?: FakeAnthropicTokenExchange } = {},
98
+ ): RecordedFixtureFetch {
99
+ const interactions = loadRecordedFixtures().scenarios[scenario];
100
+ if (!interactions)
101
+ throw new Error(`recorded fixture scenario not found: ${scenario}`);
102
+ const seen: SeenRequest[] = [];
103
+ let cursor = 0;
104
+
105
+ const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
106
+ const url = new URL(
107
+ input instanceof Request ? input.url : input.toString(),
108
+ );
109
+ const headers = new Headers(
110
+ init?.headers ?? (input instanceof Request ? input.headers : undefined),
111
+ );
112
+ const method = (
113
+ init?.method ?? (input instanceof Request ? input.method : "GET")
114
+ ).toUpperCase();
115
+ const body = await readBody(input, init);
116
+ const request: SeenRequest = { method, url, headers, body };
117
+ seen.push(request);
118
+
119
+ if (url.pathname === TOKEN_EXCHANGE_PATH) {
120
+ if (!opts.tokenExchange) {
121
+ throw new Error("unexpected token exchange: no tokenExchange double");
122
+ }
123
+ return opts.tokenExchange.handle(request);
124
+ }
125
+
126
+ const next = interactions[cursor++];
127
+ if (!next) {
128
+ throw new Error(
129
+ `recorded fixture ${scenario}: unexpected extra request ${method} ${url.pathname}`,
130
+ );
131
+ }
132
+ const expected = next.request;
133
+ if (method !== expected.method || url.pathname !== expected.path) {
134
+ throw new Error(
135
+ `recorded fixture ${scenario}: expected ${expected.method} ${expected.path}, got ${method} ${url.pathname}`,
136
+ );
137
+ }
138
+ const query = Object.fromEntries(url.searchParams.entries());
139
+ if (!Bun.deepEquals(query, expected.query ?? {})) {
140
+ throw new Error(
141
+ `recorded fixture ${scenario}: query mismatch, expected ${JSON.stringify(expected.query ?? {})}, got ${JSON.stringify(query)}`,
142
+ );
143
+ }
144
+ if (!Bun.deepEquals(body, expected.body)) {
145
+ throw new Error(
146
+ `recorded fixture ${scenario}: body mismatch for ${method} ${url.pathname}`,
147
+ );
148
+ }
149
+ return new Response(JSON.stringify(next.response.body), {
150
+ status: next.response.status,
151
+ headers: { "Content-Type": "application/json" },
152
+ });
153
+ }) as typeof fetch;
154
+
155
+ return {
156
+ fetchFn,
157
+ seen,
158
+ apiRequests: () =>
159
+ seen.filter((r) => r.url.pathname !== TOKEN_EXCHANGE_PATH),
160
+ };
161
+ }
162
+
163
+ // ─── Fake token exchange ────────────────────────────────────────────────────
164
+
165
+ export interface FakeTokenExchangeOptions {
166
+ /** Access token handed out; `{n}` is replaced by the 1-based call number. */
167
+ accessTokenTemplate?: string;
168
+ expiresIn?: number;
169
+ /** When set, every exchange answers with this status and body instead. */
170
+ failWith?: { status: number; body: unknown };
171
+ /** Override the success body (e.g. to test malformed responses). */
172
+ successBody?: (callNumber: number) => unknown;
173
+ }
174
+
175
+ /**
176
+ * Test double for POST /v1/oauth/token. Records the assertion it was given so
177
+ * tests can prove the projected token was re-read, and answers with
178
+ * synthetic, obviously-fake tokens.
179
+ */
180
+ export class FakeAnthropicTokenExchange {
181
+ readonly assertions: string[] = [];
182
+ readonly requests: SeenRequest[] = [];
183
+
184
+ constructor(private readonly opts: FakeTokenExchangeOptions = {}) {}
185
+
186
+ get calls(): number {
187
+ return this.requests.length;
188
+ }
189
+
190
+ /** The access token the n-th (1-based) exchange returns. */
191
+ accessTokenFor(callNumber: number): string {
192
+ return (this.opts.accessTokenTemplate ?? "fake-access-token-{n}").replace(
193
+ "{n}",
194
+ String(callNumber),
195
+ );
196
+ }
197
+
198
+ handle(request: SeenRequest): Response {
199
+ this.requests.push(request);
200
+ const body = request.body as { assertion?: string } | undefined;
201
+ this.assertions.push(body?.assertion ?? "");
202
+ if (this.opts.failWith) {
203
+ return new Response(JSON.stringify(this.opts.failWith.body), {
204
+ status: this.opts.failWith.status,
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ "Request-Id": "req_fake",
208
+ },
209
+ });
210
+ }
211
+ const n = this.requests.length;
212
+ const payload = this.opts.successBody?.(n) ?? {
213
+ access_token: this.accessTokenFor(n),
214
+ token_type: "Bearer",
215
+ expires_in: this.opts.expiresIn ?? 3600,
216
+ };
217
+ return new Response(JSON.stringify(payload), {
218
+ status: 200,
219
+ headers: { "Content-Type": "application/json" },
220
+ });
221
+ }
222
+
223
+ /** A standalone fetch for tests that exercise the token provider alone. */
224
+ asFetch(): typeof fetch {
225
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
226
+ const url = new URL(
227
+ input instanceof Request ? input.url : input.toString(),
228
+ );
229
+ return this.handle({
230
+ method: (init?.method ?? "GET").toUpperCase(),
231
+ url,
232
+ headers: new Headers(init?.headers),
233
+ body: await readBody(input, init),
234
+ });
235
+ }) as typeof fetch;
236
+ }
237
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * admin/src/anthropic-identity-client.integration.test.ts
3
+ * HttpAnthropicIdentityClient against recorded fixture doubles
4
+ * (fixtures/anthropic-identity-recorded-fixtures.json) replayed through an INJECTED
5
+ * fetch — no live API, no global.fetch override, no mock.module().
6
+ *
7
+ * The recorded fixtures are validated against the pinned Anthropic OpenAPI spec by
8
+ * anthropic-identity-fixture-spec.integration.test.ts. The projected-token
9
+ * exchange (POST /v1/oauth/token, not in the spec) is the
10
+ * FakeAnthropicTokenExchange test double.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { FederatedTokenProvider } from "./anthropic-federated-token-provider.ts";
18
+ import {
19
+ asFetch,
20
+ FakeAnthropicTokenExchange,
21
+ recordedFixtureFetch,
22
+ TOKEN_EXCHANGE_PATH,
23
+ } from "./anthropic-identity-client.fixtures.ts";
24
+ import {
25
+ AnthropicApiError,
26
+ AnthropicAuthError,
27
+ AnthropicConflictError,
28
+ AnthropicResponseValidationError,
29
+ HttpAnthropicIdentityClient,
30
+ } from "./anthropic-identity-client.ts";
31
+ import { FixedClock } from "./clock.ts";
32
+
33
+ let dir: string;
34
+ let tokenFile: string;
35
+
36
+ beforeEach(() => {
37
+ dir = mkdtempSync(join(tmpdir(), "anthropic-client-"));
38
+ tokenFile = join(dir, "token");
39
+ writeFileSync(tokenFile, "projected-jwt-secret\n");
40
+ });
41
+
42
+ afterEach(() => {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ function withFetch(fetchFn: typeof fetch) {
47
+ const tokenProvider = new FederatedTokenProvider({
48
+ fetchFn,
49
+ tokenFilePath: tokenFile,
50
+ federationRuleId: "fdrl_01Admin",
51
+ organizationId: "org-1234",
52
+ clock: FixedClock(new Date("2026-10-01T12:00:00Z")),
53
+ });
54
+ return new HttpAnthropicIdentityClient({ fetchFn, tokenProvider });
55
+ }
56
+
57
+ function setup(scenario: string) {
58
+ const exchange = new FakeAnthropicTokenExchange();
59
+ const replay = recordedFixtureFetch(scenario, { tokenExchange: exchange });
60
+ return { client: withFetch(replay.fetchFn), replay, exchange };
61
+ }
62
+
63
+ /** Route token-exchange calls to the fake, everything else to `apiFetch`. */
64
+ function inline(apiFetch: typeof fetch): typeof fetch {
65
+ const exchange = new FakeAnthropicTokenExchange();
66
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
67
+ const url = new URL(
68
+ input instanceof Request ? input.url : input.toString(),
69
+ );
70
+ if (url.pathname === TOKEN_EXCHANGE_PATH) {
71
+ return exchange.asFetch()(input, init);
72
+ }
73
+ return apiFetch(input, init);
74
+ }) as typeof fetch;
75
+ }
76
+
77
+ function jsonFetch(body: unknown, status = 200): typeof fetch {
78
+ return asFetch(
79
+ async () =>
80
+ new Response(typeof body === "string" ? body : JSON.stringify(body), {
81
+ status,
82
+ headers: { "Content-Type": "application/json" },
83
+ }),
84
+ );
85
+ }
86
+
87
+ const SECRETS = [
88
+ "projected-jwt-secret",
89
+ "fake-access-token-1",
90
+ "fake-access-token-2",
91
+ "Bearer ",
92
+ ];
93
+
94
+ function expectNoSecrets(err: Error) {
95
+ const text = `${err.message} ${err.stack ?? ""} ${JSON.stringify(err)}`;
96
+ for (const s of SECRETS) expect(text).not.toContain(s);
97
+ }
98
+
99
+ const SA_INPUT = { name: "agent-alpha", description: "Shipwright agent alpha" };
100
+
101
+ describe("createServiceAccount", () => {
102
+ it("POSTs the service account with a federated bearer token and parses the response", async () => {
103
+ const { client, replay } = setup("createServiceAccount_success");
104
+ const sa = await client.createServiceAccount(SA_INPUT);
105
+ expect(sa).toEqual({
106
+ id: "svac_01Alpha",
107
+ name: "agent-alpha",
108
+ archivedAt: null,
109
+ });
110
+ const req = replay.apiRequests()[0];
111
+ expect(req.url.origin).toBe("https://api.anthropic.com");
112
+ expect(req.headers.get("authorization")).toBe("Bearer fake-access-token-1");
113
+ expect(req.headers.get("anthropic-beta")).toContain("oauth-2025-04-20");
114
+ });
115
+
116
+ it("surfaces a 409 duplicate name as a typed conflict error (no retry)", async () => {
117
+ const { client, replay } = setup("createServiceAccount_duplicate_409");
118
+ const err = await client.createServiceAccount(SA_INPUT).catch((e) => e);
119
+ expect(err).toBeInstanceOf(AnthropicConflictError);
120
+ expect(err).toBeInstanceOf(AnthropicApiError);
121
+ expect(err.status).toBe(409);
122
+ expect(err.operation).toBe("createServiceAccount");
123
+ expect(replay.apiRequests()).toHaveLength(1);
124
+ expectNoSecrets(err);
125
+ });
126
+
127
+ it("re-exchanges the token once after a 401 and retries", async () => {
128
+ const { client, exchange, replay } = setup(
129
+ "createServiceAccount_unauthorized_then_success",
130
+ );
131
+ const sa = await client.createServiceAccount(SA_INPUT);
132
+ expect(sa.id).toBe("svac_01Alpha");
133
+ expect(exchange.calls).toBe(2);
134
+ const [first, second] = replay.apiRequests();
135
+ expect(first.headers.get("authorization")).toBe(
136
+ "Bearer fake-access-token-1",
137
+ );
138
+ expect(second.headers.get("authorization")).toBe(
139
+ "Bearer fake-access-token-2",
140
+ );
141
+ });
142
+
143
+ it("throws a typed auth error after a second 401, without leaking tokens", async () => {
144
+ const { client, exchange, replay } = setup(
145
+ "createServiceAccount_unauthorized_401",
146
+ );
147
+ const err = await client.createServiceAccount(SA_INPUT).catch((e) => e);
148
+ expect(err).toBeInstanceOf(AnthropicAuthError);
149
+ expect(err.status).toBe(401);
150
+ expect(exchange.calls).toBe(2);
151
+ expect(replay.apiRequests()).toHaveLength(2);
152
+ expectNoSecrets(err);
153
+ });
154
+
155
+ it("rejects a response that fails schema validation without echoing it", async () => {
156
+ const client = withFetch(
157
+ inline(jsonFetch({ id: 42, secret: "do-not-print" })),
158
+ );
159
+ const err = await client
160
+ .createServiceAccount({ name: "x" })
161
+ .catch((e) => e);
162
+ expect(err).toBeInstanceOf(AnthropicResponseValidationError);
163
+ expect(err.message).not.toContain("do-not-print");
164
+ expectNoSecrets(err);
165
+ });
166
+
167
+ it("rejects a non-JSON 200 body", async () => {
168
+ const client = withFetch(inline(jsonFetch("<html>gateway</html>")));
169
+ await expect(
170
+ client.createServiceAccount({ name: "x" }),
171
+ ).rejects.toBeInstanceOf(AnthropicResponseValidationError);
172
+ });
173
+ });
174
+
175
+ describe("addServiceAccountToWorkspace", () => {
176
+ it("POSTs the workspace membership and parses the member", async () => {
177
+ const { client } = setup("addServiceAccountToWorkspace_success");
178
+ const member = await client.addServiceAccountToWorkspace({
179
+ serviceAccountId: "svac_01Alpha",
180
+ workspaceId: "wrkspc_01Work",
181
+ });
182
+ expect(member).toEqual({
183
+ serviceAccountId: "svac_01Alpha",
184
+ workspaceId: "wrkspc_01Work",
185
+ workspaceRole: "workspace_developer",
186
+ });
187
+ });
188
+
189
+ it("maps a 404 to an API error carrying the error type and request id", async () => {
190
+ const { client } = setup("addServiceAccountToWorkspace_not_found_404");
191
+ const err = await client
192
+ .addServiceAccountToWorkspace({
193
+ serviceAccountId: "svac_01Missing",
194
+ workspaceId: "wrkspc_01Work",
195
+ })
196
+ .catch((e) => e);
197
+ expect(err).toBeInstanceOf(AnthropicApiError);
198
+ expect(err).not.toBeInstanceOf(AnthropicConflictError);
199
+ expect(err.status).toBe(404);
200
+ expect(err.errorType).toBe("not_found_error");
201
+ expect(err.requestId).toBe("req_01Fake");
202
+ });
203
+ });
204
+
205
+ describe("federation rules", () => {
206
+ const input = {
207
+ name: "agent-alpha",
208
+ description: "Shipwright agent alpha",
209
+ issuerId: "fdis_01Issuer",
210
+ serviceAccountId: "svac_01Alpha",
211
+ workspaceId: "wrkspc_01Work",
212
+ match: { subjectPrefix: "system:serviceaccount:agents:agent-alpha" },
213
+ };
214
+
215
+ it("createFederationRule POSTs the rule and parses it", async () => {
216
+ const { client } = setup("createFederationRule_success");
217
+ const rule = await client.createFederationRule(input);
218
+ expect(rule).toEqual({
219
+ id: "fdrl_01Alpha",
220
+ name: "agent-alpha",
221
+ archivedAt: null,
222
+ });
223
+ });
224
+
225
+ it("createFederationRule surfaces a 409 as a conflict error", async () => {
226
+ const { client } = setup("createFederationRule_duplicate_409");
227
+ await expect(client.createFederationRule(input)).rejects.toBeInstanceOf(
228
+ AnthropicConflictError,
229
+ );
230
+ });
231
+
232
+ it("archiveFederationRule POSTs to the archive endpoint", async () => {
233
+ const { client } = setup("archiveFederationRule_success");
234
+ const rule = await client.archiveFederationRule("fdrl_01Alpha");
235
+ expect(rule.archivedAt).toBe("2026-10-02T09:30:00Z");
236
+ });
237
+ });
238
+
239
+ describe("archiveServiceAccount", () => {
240
+ it("POSTs to the archive endpoint", async () => {
241
+ const { client } = setup("archiveServiceAccount_success");
242
+ const sa = await client.archiveServiceAccount("svac_01Alpha");
243
+ expect(sa.archivedAt).toBe("2026-10-02T09:31:00Z");
244
+ });
245
+
246
+ it("raises an auth error on a persistent 401", async () => {
247
+ const { client } = setup("archiveServiceAccount_unauthorized_401");
248
+ await expect(
249
+ client.archiveServiceAccount("svac_01Alpha"),
250
+ ).rejects.toBeInstanceOf(AnthropicAuthError);
251
+ });
252
+ });
253
+
254
+ describe("findServiceAccountByName", () => {
255
+ it("follows next_page across pages until the name is found", async () => {
256
+ const { client, replay } = setup(
257
+ "findServiceAccountByName_found_on_second_page",
258
+ );
259
+ const sa = await client.findServiceAccountByName("agent-alpha");
260
+ expect(sa?.id).toBe("svac_01Alpha");
261
+ const reqs = replay.apiRequests();
262
+ expect(reqs).toHaveLength(2);
263
+ expect(reqs[0].url.searchParams.get("page")).toBeNull();
264
+ expect(reqs[1].url.searchParams.get("page")).toBe("cursor-page-2");
265
+ });
266
+
267
+ it("pages through every result before concluding the name is absent", async () => {
268
+ const { client, replay } = setup(
269
+ "findServiceAccountByName_not_found_exhausted",
270
+ );
271
+ expect(await client.findServiceAccountByName("agent-alpha")).toBeNull();
272
+ expect(replay.apiRequests()).toHaveLength(3);
273
+ });
274
+
275
+ it("returns null for an empty list", async () => {
276
+ const { client } = setup("findServiceAccountByName_empty");
277
+ expect(await client.findServiceAccountByName("agent-alpha")).toBeNull();
278
+ });
279
+
280
+ it("raises an auth error on a persistent 401", async () => {
281
+ const { client } = setup("findServiceAccountByName_unauthorized_401");
282
+ await expect(client.findServiceAccountByName("a")).rejects.toBeInstanceOf(
283
+ AnthropicAuthError,
284
+ );
285
+ });
286
+
287
+ it("skips archived entries and ignores near-miss names", async () => {
288
+ const sa = (id: string, name: string, archived: string | null) => ({
289
+ type: "service_account",
290
+ id,
291
+ name,
292
+ description: null,
293
+ organization_role: "developer",
294
+ archived_at: archived,
295
+ archived_by_actor_id: null,
296
+ created_at: "2026-10-01T12:00:00Z",
297
+ created_by_actor_id: null,
298
+ updated_at: "2026-10-01T12:00:00Z",
299
+ updated_by_actor_id: null,
300
+ });
301
+ const client = withFetch(
302
+ inline(
303
+ jsonFetch({
304
+ data: [
305
+ sa("svac_old", "agent-alpha", "2026-09-01T00:00:00Z"),
306
+ sa("svac_near", "agent-alpha-2", null),
307
+ sa("svac_live", "agent-alpha", null),
308
+ ],
309
+ next_page: null,
310
+ }),
311
+ ),
312
+ );
313
+ expect((await client.findServiceAccountByName("agent-alpha"))?.id).toBe(
314
+ "svac_live",
315
+ );
316
+ });
317
+
318
+ it("aborts if the API returns the same cursor twice", async () => {
319
+ const client = withFetch(
320
+ inline(jsonFetch({ data: [], next_page: "same" })),
321
+ );
322
+ await expect(client.findServiceAccountByName("x")).rejects.toThrow(
323
+ /pagination/i,
324
+ );
325
+ });
326
+ });
327
+
328
+ describe("findFederationRuleByName", () => {
329
+ it("pages to the third page to find the rule", async () => {
330
+ const { client, replay } = setup(
331
+ "findFederationRuleByName_found_on_third_page",
332
+ );
333
+ const rule = await client.findFederationRuleByName("agent-alpha");
334
+ expect(rule?.id).toBe("fdrl_01Alpha");
335
+ expect(replay.apiRequests()).toHaveLength(3);
336
+ });
337
+
338
+ it("returns null when no page contains the name", async () => {
339
+ const { client } = setup("findFederationRuleByName_not_found");
340
+ expect(await client.findFederationRuleByName("agent-alpha")).toBeNull();
341
+ });
342
+ });