@app-vitals/agent-admin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +64 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * admin/src/anthropic-openapi-contract.fixtures.ts
3
+ * Offline contract checking of recorded Anthropic fixture doubles against the
4
+ * Anthropic OpenAPI 3.1 spec copied into the repo (test support only).
5
+ *
6
+ * The spec is anthropics/anthropic-sdk-typescript scripts/mock-spec.json.gz at
7
+ * the tag matching the pinned @anthropic-ai/sdk version; see
8
+ * fixtures/anthropic-openapi/SDK_VERSION and
9
+ * scripts/refresh-anthropic-openapi-spec.ts.
10
+ */
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { gunzipSync } from "node:zlib";
14
+ import Ajv2020 from "ajv/dist/2020.js";
15
+ import addFormats from "ajv-formats";
16
+ import type { AnthropicRecordedFixtures } from "./anthropic-identity-client.fixtures.ts";
17
+
18
+ const SPEC_DIR = new URL("./fixtures/anthropic-openapi/", import.meta.url)
19
+ .pathname;
20
+
21
+ export const SPEC_GZ_PATH = `${SPEC_DIR}mock-spec.json.gz`;
22
+ export const SPEC_VERSION_PATH = `${SPEC_DIR}SDK_VERSION`;
23
+
24
+ // biome-ignore lint/suspicious/noExplicitAny: OpenAPI documents are arbitrary JSON
25
+ export type OpenApiSpec = any;
26
+
27
+ export function loadSpec(): OpenApiSpec {
28
+ return JSON.parse(gunzipSync(readFileSync(SPEC_GZ_PATH)).toString("utf-8"));
29
+ }
30
+
31
+ export function readSpecSdkVersion(): string {
32
+ return readFileSync(SPEC_VERSION_PATH, "utf-8").trim();
33
+ }
34
+
35
+ export interface MatchedOperation {
36
+ operationId: string;
37
+ method: string;
38
+ template: string;
39
+ }
40
+
41
+ interface Interaction {
42
+ scenario: string;
43
+ index: number;
44
+ request: AnthropicRecordedFixtures["scenarios"][string][number]["request"];
45
+ response: AnthropicRecordedFixtures["scenarios"][string][number]["response"];
46
+ }
47
+
48
+ const SPEC_URI = "https://spec.invalid/anthropic-openapi";
49
+
50
+ /** Deep-copy a schema, pointing local "#/components/" refs at SPEC_URI. */
51
+ function rebaseRefs(node: unknown): unknown {
52
+ if (Array.isArray(node)) return node.map(rebaseRefs);
53
+ if (node && typeof node === "object") {
54
+ return Object.fromEntries(
55
+ Object.entries(node).map(([k, v]) => [
56
+ k,
57
+ k === "$ref" && typeof v === "string" && v.startsWith("#/")
58
+ ? SPEC_URI + v
59
+ : rebaseRefs(v),
60
+ ]),
61
+ );
62
+ }
63
+ return node;
64
+ }
65
+
66
+ function templateToRegExp(template: string): RegExp {
67
+ const pattern = template
68
+ .split(/(\{[^}]+\})/)
69
+ .map((part) =>
70
+ part.startsWith("{")
71
+ ? "[^/]+"
72
+ : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
73
+ )
74
+ .join("");
75
+ return new RegExp(`^${pattern}$`);
76
+ }
77
+
78
+ export interface ContractResult {
79
+ problems: string[];
80
+ operations: MatchedOperation[];
81
+ }
82
+
83
+ /**
84
+ * Validate every interaction of every scenario: the method+path must match a
85
+ * spec operation, the request query names/values and JSON body must satisfy
86
+ * the operation's schemas, and the response body must satisfy the schema of the
87
+ * documented response for its status code.
88
+ */
89
+ export function validateRecordedFixturesAgainstSpec(
90
+ fixtures: AnthropicRecordedFixtures,
91
+ spec: OpenApiSpec = loadSpec(),
92
+ ): ContractResult {
93
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
94
+ addFormats(ajv);
95
+ const problems: string[] = [];
96
+ const operations = new Map<string, MatchedOperation>();
97
+
98
+ // Beta duplicates carry a "?beta=true" suffix in their key; the SDK's
99
+ // typed resources call the plain paths.
100
+ const templates = Object.keys(spec.paths)
101
+ .filter((p) => !p.includes("?"))
102
+ .map((template) => ({ template, re: templateToRegExp(template) }));
103
+
104
+ // Register the components once; operation schemas reference them through
105
+ // an absolute URI so Ajv never re-walks the multi-megabyte document.
106
+ ajv.addSchema({ components: spec.components }, SPEC_URI);
107
+ const validate = (schema: unknown, data: unknown) => {
108
+ const fn = ajv.compile(rebaseRefs(schema) as object);
109
+ return fn(data)
110
+ ? []
111
+ : (fn.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message}`);
112
+ };
113
+
114
+ const interactions: Interaction[] = Object.entries(
115
+ fixtures.scenarios,
116
+ ).flatMap(([scenario, list]) =>
117
+ list.map((i, index) => ({ scenario, index, ...i })),
118
+ );
119
+
120
+ for (const it of interactions) {
121
+ const where = `${it.scenario}[${it.index}] ${it.request.method} ${it.request.path}`;
122
+ const match = templates.find((t) => t.re.test(it.request.path));
123
+ if (!match) {
124
+ problems.push(`${where}: path is not in the spec`);
125
+ continue;
126
+ }
127
+ const op = spec.paths[match.template][it.request.method.toLowerCase()];
128
+ if (!op || typeof op !== "object") {
129
+ problems.push(`${where}: method not defined for ${match.template}`);
130
+ continue;
131
+ }
132
+ operations.set(op.operationId, {
133
+ operationId: op.operationId,
134
+ method: it.request.method,
135
+ template: match.template,
136
+ });
137
+
138
+ // Query parameters
139
+ // biome-ignore lint/suspicious/noExplicitAny: spec parameter objects
140
+ const params: any[] = op.parameters ?? [];
141
+ for (const [name, value] of Object.entries(it.request.query ?? {})) {
142
+ const p = params.find((x) => x.in === "query" && x.name === name);
143
+ if (!p) {
144
+ problems.push(`${where}: query parameter "${name}" is not in the spec`);
145
+ continue;
146
+ }
147
+ const coerced = /integer|number/.test(JSON.stringify(p.schema))
148
+ ? Number(value)
149
+ : value === "true" || value === "false"
150
+ ? value === "true"
151
+ : value;
152
+ for (const e of validate(p.schema, coerced)) {
153
+ problems.push(`${where}: query "${name}" ${e}`);
154
+ }
155
+ }
156
+
157
+ // Request body
158
+ const reqSchema = op.requestBody?.content?.["application/json"]?.schema;
159
+ if (it.request.body !== undefined) {
160
+ if (!reqSchema) {
161
+ problems.push(`${where}: spec defines no JSON request body`);
162
+ } else {
163
+ for (const e of validate(reqSchema, it.request.body)) {
164
+ problems.push(`${where}: request ${e}`);
165
+ }
166
+ }
167
+ } else if (op.requestBody?.required) {
168
+ problems.push(`${where}: spec requires a request body`);
169
+ }
170
+
171
+ // Response body
172
+ const res = op.responses?.[String(it.response.status)];
173
+ if (!res) {
174
+ problems.push(
175
+ `${where}: status ${it.response.status} is not documented in the spec`,
176
+ );
177
+ continue;
178
+ }
179
+ const resSchema = res.content?.["application/json"]?.schema;
180
+ if (!resSchema) {
181
+ problems.push(
182
+ `${where}: spec defines no JSON body for ${it.response.status}`,
183
+ );
184
+ continue;
185
+ }
186
+ for (const e of validate(resSchema, it.response.body)) {
187
+ problems.push(`${where}: response ${it.response.status} ${e}`);
188
+ }
189
+ }
190
+
191
+ return { problems, operations: [...operations.values()] };
192
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * admin/src/api-auth.ts
3
+ * Combined admin auth middleware — accepts either a session cookie OR a bearer token.
4
+ *
5
+ * Bearer check runs first (when Authorization header is present).
6
+ * If Authorization header is absent, falls through to session cookie check.
7
+ * If Authorization header is present but invalid, rejects immediately (401) —
8
+ * does NOT fall through to the cookie path.
9
+ */
10
+
11
+ import type { Caller } from "@app-vitals/agent-lib/request-context";
12
+ import type { MiddlewareHandler } from "hono";
13
+ import { getCookie } from "hono/cookie";
14
+ import { verify } from "hono/jwt";
15
+ import type { AgentMemberService } from "./agent-members.ts";
16
+ import type { AgentTokenService } from "./agent-tokens.ts";
17
+ import {
18
+ type CallerScopeResolver,
19
+ memberOnlyCallerScopeResolver,
20
+ scopeIncludesAgent,
21
+ } from "./caller-scope.ts";
22
+ import { ForbiddenError } from "./errors.ts";
23
+
24
+ // ─── Types ────────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * What kind of credential authenticated the request.
28
+ * admin → `*` key; agent → `<agentId>` key or DB agent token;
29
+ * platform-read / platform-ops → `platform.read` / `platform.ops` keys.
30
+ * Not set for session cookies (they are described by isAdmin/callerEmail).
31
+ */
32
+ export type AdminScopeKind =
33
+ | "admin"
34
+ | "agent"
35
+ | "platform-read"
36
+ | "platform-ops";
37
+
38
+ export interface AdminScope {
39
+ kind: AdminScopeKind;
40
+ agentId?: string;
41
+ }
42
+
43
+ export type AdminAuthEnv = {
44
+ Variables: {
45
+ isAdmin: boolean;
46
+ caller: Caller;
47
+ scope?: AdminScope;
48
+ /**
49
+ * Lowercased session email. Set only for session-cookie callers (admin or
50
+ * member) — never for bearer callers. For a non-admin cookie, the
51
+ * middleware has already verified AgentMember access to the route's agent.
52
+ */
53
+ callerEmail?: string;
54
+ };
55
+ };
56
+
57
+ /**
58
+ * No-op middleware for routes declared with policy kind "public".
59
+ * Allows unauthenticated requests to pass through without a 401.
60
+ */
61
+ export const publicNoAuthMiddleware: MiddlewareHandler<AdminAuthEnv> = async (
62
+ _c,
63
+ next,
64
+ ) => {
65
+ await next();
66
+ };
67
+
68
+ // ─── Constants ────────────────────────────────────────────────────────────────
69
+
70
+ const SESSION_COOKIE = "admin_session";
71
+
72
+ // Matches /agents/{agentId}/... routes — used for per-agent scope enforcement.
73
+ const AGENT_ROUTE_RE = /^\/agents\/([^/]+)/;
74
+
75
+ /**
76
+ * Extract the agent id from an /agents/{id}/... path, or null if the path has
77
+ * no agent segment. Static segments (reconcile, all, chat-tokens) are returned
78
+ * as-is; they never match an AgentMember row, so scoped callers are denied.
79
+ */
80
+ export function extractAgentId(path: string): string | null {
81
+ return AGENT_ROUTE_RE.exec(path)?.[1] ?? null;
82
+ }
83
+
84
+ // ─── Platform scopes ──────────────────────────────────────────────────────────
85
+
86
+ export const PLATFORM_READ_SCOPE = "platform.read";
87
+ export const PLATFORM_OPS_SCOPE = "platform.ops";
88
+
89
+ export interface PlatformOpsRoute {
90
+ method: string;
91
+ /** Path template; `{name}` matches exactly one segment. */
92
+ path: string;
93
+ /** Also match any deeper path below `path` (e.g. /agents/{id}/repos/{repo}). */
94
+ subpaths?: boolean;
95
+ }
96
+
97
+ /**
98
+ * The only mutating routes a `platform.ops` key may call (everything else
99
+ * mutating is 403, as is every mutating method for `platform.read`).
100
+ * PATCH /agents/{id} additionally refuses `accountId` in its handler.
101
+ * Entries whose routes are not mounted yet are wired by their own tasks.
102
+ */
103
+ export const PLATFORM_OPS_ROUTES: readonly PlatformOpsRoute[] = [
104
+ { method: "PATCH", path: "/agents/{id}" },
105
+ { method: "POST", path: "/agents/{id}/repos", subpaths: true },
106
+ { method: "DELETE", path: "/agents/{id}/repos", subpaths: true },
107
+ { method: "PATCH", path: "/agents/{id}/crons/{cronId}" },
108
+ { method: "POST", path: "/agents/{id}/ask" },
109
+ { method: "POST", path: "/agents/{id}/chat-credentials", subpaths: true },
110
+ { method: "DELETE", path: "/agents/{id}/chat-credentials", subpaths: true },
111
+ ];
112
+
113
+ /**
114
+ * Routes an agent-scoped key may call on ANOTHER agent's path. The middleware
115
+ * skips its path-agent check for them; the handler binds the caller to a body
116
+ * field instead (POST /agents/{id}/ask: `from` must equal the key's agent, so
117
+ * an agent can only ask as itself — never impersonate another).
118
+ */
119
+ export const AGENT_CROSS_ROUTES: readonly PlatformOpsRoute[] = [
120
+ { method: "POST", path: "/agents/{id}/ask" },
121
+ ];
122
+
123
+ function routeMatchers(routes: readonly PlatformOpsRoute[]) {
124
+ const matchers = routes.map((r) => ({
125
+ method: r.method,
126
+ re: new RegExp(
127
+ `^${r.path.replace(/\{[^}]+\}/g, "[^/]+")}${r.subpaths ? "(?:/.*)?" : ""}/?$`,
128
+ ),
129
+ }));
130
+ return (method: string, path: string): boolean =>
131
+ matchers.some((m) => m.method === method && m.re.test(path));
132
+ }
133
+
134
+ const isPlatformOpsRoute = routeMatchers(PLATFORM_OPS_ROUTES);
135
+ const isAgentCrossRoute = routeMatchers(AGENT_CROSS_ROUTES);
136
+
137
+ /**
138
+ * Handler-side guard for routes on PLATFORM_OPS_ROUTES. The middleware already
139
+ * 403s other callers' methods; this keeps a `platform.read` caller out even if
140
+ * a handler is mounted behind a different method. Admin, agent and session
141
+ * callers are unaffected.
142
+ */
143
+ export function requireScope(
144
+ c: { get: (k: "scope") => unknown },
145
+ need: "ops",
146
+ ): void {
147
+ const kind = (c.get("scope") as AdminScope | undefined)?.kind;
148
+ if (need === "ops" && kind === "platform-read") {
149
+ throw new ForbiddenError("Platform ops scope required");
150
+ }
151
+ }
152
+
153
+ // ─── Admin API key parsing ────────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Represents a parsed admin API key entry.
157
+ * scope === "*" → admin (bypasses all per-agent scope checks)
158
+ * scope === "<agentId>" → scoped to a single agent
159
+ * scope === "platform.read" | "platform.ops" → platform reader / operator
160
+ */
161
+ export interface AdminApiKey {
162
+ name: string;
163
+ scope: string;
164
+ }
165
+
166
+ /**
167
+ * Parse the SHIPWRIGHT_ADMIN_API_KEYS env var into a Map<token, AdminApiKey>.
168
+ * Format: comma-separated "name:token:scope" tuples.
169
+ * Example: "bodhi:sk_bodhi_abc:*,svc:sk_svc_xyz:agent-id-123,ro:tok:platform.read"
170
+ *
171
+ * Scope names must not contain a colon: the last colon-separated segment IS
172
+ * the scope, so a colon would corrupt the token. Hence the dot-spelled
173
+ * `platform.read` / `platform.ops`.
174
+ *
175
+ * Tokens may contain embedded colons — first segment is name, last is scope,
176
+ * everything in between is the token.
177
+ *
178
+ * Returns an empty map if the env var is missing or empty.
179
+ */
180
+ export function parseAdminApiKeys(
181
+ envStr: string | undefined,
182
+ ): Map<string, AdminApiKey> {
183
+ const map = new Map<string, AdminApiKey>();
184
+ if (!envStr) return map;
185
+
186
+ for (const entry of envStr.split(",")) {
187
+ const trimmed = entry.trim();
188
+ if (!trimmed) continue;
189
+ const parts = trimmed.split(":");
190
+ // parts[0]=name, parts[1..n-1]=token (may contain colons), parts[n]=scope
191
+ if (parts.length < 3) continue;
192
+ const name = parts[0];
193
+ const scope = parts[parts.length - 1];
194
+ const token = parts.slice(1, parts.length - 1).join(":");
195
+ if (name && token && scope) {
196
+ map.set(token, { name, scope });
197
+ }
198
+ }
199
+
200
+ return map;
201
+ }
202
+
203
+ // ─── Middleware factory ───────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Returns a Hono middleware that accepts either:
207
+ * (a) a valid session cookie (JWT in admin_session cookie), OR
208
+ * (b) a valid bearer token — checked in this order:
209
+ * 1. Matches a SHIPWRIGHT_ADMIN_API_KEYS env key with scope=* → isAdmin, bypass
210
+ * 2. Matches a SHIPWRIGHT_ADMIN_API_KEYS env key with scope=<agentId> → enforce agentId
211
+ * 3. Validates via agentTokenService.validate() (DB token path)
212
+ *
213
+ * Decision tree:
214
+ * 1. Authorization header present?
215
+ * - Yes, starts with "Bearer " → check env keys, then DB token → pass or 401
216
+ * - Yes, malformed → 401 (no cookie fallback)
217
+ * - No → try session cookie
218
+ * 2. Cookie present?
219
+ * - Yes → verify JWT → pass or 401
220
+ * - No → 401
221
+ */
222
+ export function createAdminAuthMiddleware(deps: {
223
+ sessionSecret: string;
224
+ agentTokenService: Pick<AgentTokenService, "validate">;
225
+ adminApiKeys?: Map<string, AdminApiKey>;
226
+ /** Required to authorize non-admin cookie sessions; absent → they are denied on agent routes. */
227
+ agentMemberService?: Pick<AgentMemberService, "listByEmail">;
228
+ /**
229
+ * Account-aware caller-scope resolver (SSP-2.1). Defaults to the
230
+ * AgentMember-only resolver built from agentMemberService.
231
+ */
232
+ callerScopeResolver?: CallerScopeResolver;
233
+ }): MiddlewareHandler<AdminAuthEnv> {
234
+ const { sessionSecret, agentTokenService, adminApiKeys, agentMemberService } =
235
+ deps;
236
+ const callerScopeResolver =
237
+ deps.callerScopeResolver ??
238
+ (agentMemberService
239
+ ? memberOnlyCallerScopeResolver(agentMemberService)
240
+ : undefined);
241
+
242
+ async function isSessionRouteAllowed(
243
+ method: string,
244
+ path: string,
245
+ email: string,
246
+ ): Promise<boolean> {
247
+ // POST /agents (SSP-4.2): the handler resolves the caller's account and
248
+ // denies callers without one, so the middleware only lets it through.
249
+ if (path === "/agents" || path === "/agents/") {
250
+ return method === "GET" || method === "POST";
251
+ }
252
+ const agentId = extractAgentId(path);
253
+ if (!agentId || !callerScopeResolver) return false;
254
+ return scopeIncludesAgent(await callerScopeResolver(email, false), agentId);
255
+ }
256
+
257
+ return async (c, next) => {
258
+ const authHeader = c.req.header("Authorization");
259
+
260
+ if (authHeader !== undefined) {
261
+ // Authorization header is present — bearer token path.
262
+ // Do NOT fall through to cookie check on failure.
263
+ if (!authHeader.startsWith("Bearer ")) {
264
+ return c.json({ error: "Unauthorized" }, 401, {
265
+ "WWW-Authenticate": "Bearer",
266
+ });
267
+ }
268
+ const raw = authHeader.slice(7);
269
+
270
+ // (1) Check admin API keys first (env-based, no DB round-trip).
271
+ if (adminApiKeys?.size) {
272
+ const envKey = adminApiKeys.get(raw);
273
+ if (envKey) {
274
+ if (envKey.scope === "*") {
275
+ // Admin key — bypass all scope checks.
276
+ c.set("isAdmin", true);
277
+ c.set("caller", { name: envKey.name, scope: "*" });
278
+ c.set("scope", { kind: "admin" });
279
+ return next();
280
+ }
281
+ if (
282
+ envKey.scope === PLATFORM_READ_SCOPE ||
283
+ envKey.scope === PLATFORM_OPS_SCOPE
284
+ ) {
285
+ const method = c.req.method;
286
+ const read = method === "GET" || method === "HEAD";
287
+ const ops =
288
+ envKey.scope === PLATFORM_OPS_SCOPE &&
289
+ isPlatformOpsRoute(method, c.req.path);
290
+ if (!read && !ops) return c.json({ error: "Forbidden" }, 403);
291
+ c.set("isAdmin", false);
292
+ c.set("caller", { name: envKey.name, scope: envKey.scope });
293
+ c.set("scope", {
294
+ kind:
295
+ envKey.scope === PLATFORM_OPS_SCOPE
296
+ ? "platform-ops"
297
+ : "platform-read",
298
+ });
299
+ return next();
300
+ }
301
+ // Scoped key — enforce route agentId matches key scope.
302
+ const match = AGENT_ROUTE_RE.exec(c.req.path);
303
+ if (
304
+ match &&
305
+ envKey.scope !== match[1] &&
306
+ !isAgentCrossRoute(c.req.method, c.req.path)
307
+ ) {
308
+ return c.json({ error: "Forbidden" }, 403);
309
+ }
310
+ // Non-agent routes: scoped keys are permitted (no agentId to enforce against).
311
+ // All current /agents/* routes match AGENT_ROUTE_RE — revisit if that changes.
312
+ c.set("isAdmin", false);
313
+ c.set("caller", { name: envKey.name, scope: envKey.scope });
314
+ c.set("scope", { kind: "agent", agentId: envKey.scope });
315
+ return next();
316
+ }
317
+ }
318
+
319
+ // (2) Fall through to DB token path (AgentTokenService).
320
+ const result = await agentTokenService.validate(raw);
321
+ if (!result) {
322
+ return c.json({ error: "Unauthorized" }, 401, {
323
+ "WWW-Authenticate": 'Bearer error="invalid_token"',
324
+ });
325
+ }
326
+ // Enforce per-agent scope: token must belong to the agent being accessed.
327
+ // c.req.param() is not available in global middleware (params resolve only at
328
+ // handler dispatch), so we extract the agent ID directly from the URL path.
329
+ const match = AGENT_ROUTE_RE.exec(c.req.path);
330
+ if (
331
+ match &&
332
+ result.agentId !== match[1] &&
333
+ !isAgentCrossRoute(c.req.method, c.req.path)
334
+ ) {
335
+ return c.json({ error: "Forbidden" }, 403);
336
+ }
337
+ // DB token path — per-agent bearer, not an admin.
338
+ c.set("isAdmin", false);
339
+ c.set("caller", { name: result.agentId, scope: result.agentId });
340
+ c.set("scope", { kind: "agent", agentId: result.agentId });
341
+ return next();
342
+ }
343
+
344
+ // No Authorization header — try session cookie.
345
+ const sessionToken = getCookie(c, SESSION_COOKIE);
346
+ if (!sessionToken) {
347
+ return c.json({ error: "Unauthorized" }, 401);
348
+ }
349
+ let email: string;
350
+ let sessionIsAdmin: boolean;
351
+ try {
352
+ const payload = (await verify(
353
+ sessionToken,
354
+ sessionSecret,
355
+ "HS256",
356
+ )) as Record<string, unknown>;
357
+ if (
358
+ typeof payload.userId !== "string" ||
359
+ !payload.userId ||
360
+ typeof payload.email !== "string" ||
361
+ !payload.email
362
+ ) {
363
+ return c.json({ error: "Unauthorized" }, 401);
364
+ }
365
+ email = payload.email.toLowerCase();
366
+ // Strict: a missing/non-true claim (e.g. a pre-claim cookie) is non-admin.
367
+ sessionIsAdmin = payload.isAdmin === true;
368
+ } catch {
369
+ return c.json({ error: "Unauthorized" }, 401);
370
+ }
371
+ c.set("isAdmin", sessionIsAdmin);
372
+ c.set("caller", { name: email, scope: "session" });
373
+ c.set("callerEmail", email);
374
+ if (sessionIsAdmin) return next();
375
+
376
+ // Non-admin cookie: GET /agents (handler filters to memberships) and
377
+ // /agents/:id/** for member agents only. Everything else is 403.
378
+ if (!(await isSessionRouteAllowed(c.req.method, c.req.path, email))) {
379
+ return c.json({ error: "Forbidden" }, 403);
380
+ }
381
+ return next();
382
+ };
383
+ }