@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,96 @@
1
+ /**
2
+ * admin/src/admin-spec.smoke.test.ts
3
+ *
4
+ * Smoke tests for the platform OpenAPI document served at GET /doc.
5
+ * The route is flag-gated: SHIPWRIGHT_ADMIN_DOC_ENABLED="enabled" mounts it
6
+ * (see platform-doc-route.ts, wired in admin-boot.ts) behind the admin auth
7
+ * middleware; with the flag off the route is not mounted at all (404).
8
+ * Uses app.request() — no real server, no real DB.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { Hono } from "hono";
13
+ import { buildPlatformAdminSpec } from "./admin-spec.ts";
14
+ import { createAdminAuthMiddleware, parseAdminApiKeys } from "./api-auth.ts";
15
+ import { mountPlatformDoc } from "./platform-doc-route.ts";
16
+
17
+ function buildApp(env: Record<string, string | undefined>) {
18
+ const root = new Hono();
19
+ root.get("/health", (c) => c.json({ status: "ok" }));
20
+ mountPlatformDoc(root, {
21
+ env,
22
+ auth: createAdminAuthMiddleware({
23
+ sessionSecret: "test-secret-32chars-for-doc-route!",
24
+ agentTokenService: { validate: async () => null },
25
+ adminApiKeys: parseAdminApiKeys(
26
+ "root:tok-admin:*,ro:tok-read:platform.read,ops:tok-ops:platform.ops,bot:tok-agent:agent-1",
27
+ ),
28
+ }),
29
+ });
30
+ return root;
31
+ }
32
+
33
+ // The auth middleware and the handler both register under /doc; dedupe.
34
+ const routes = (app: Hono) => [
35
+ ...new Set(app.routes.map((r) => `${r.method} ${r.path}`)),
36
+ ];
37
+ const bearer = (t: string) => ({ headers: { Authorization: `Bearer ${t}` } });
38
+
39
+ describe("GET /doc", () => {
40
+ it("flag off: route is not mounted and returns 404", async () => {
41
+ const off = buildApp({});
42
+ expect((await off.request("/doc")).status).toBe(404);
43
+ expect((await off.request("/doc", bearer("tok-admin"))).status).toBe(404);
44
+ expect(routes(off)).toEqual(
45
+ routes(buildApp({ SHIPWRIGHT_ADMIN_DOC_ENABLED: "disabled" })),
46
+ );
47
+ expect(routes(off).some((r) => r.endsWith("/doc"))).toBe(false);
48
+ });
49
+
50
+ it("flag on adds exactly one route", () => {
51
+ const on = routes(buildApp({ SHIPWRIGHT_ADMIN_DOC_ENABLED: "enabled" }));
52
+ expect(on.filter((r) => !routes(buildApp({})).includes(r))).toEqual([
53
+ "GET /doc",
54
+ ]);
55
+ });
56
+
57
+ it("flag on: no credential -> 401", async () => {
58
+ const app = buildApp({ SHIPWRIGHT_ADMIN_DOC_ENABLED: "enabled" });
59
+ expect((await app.request("/doc")).status).toBe(401);
60
+ expect((await app.request("/doc", bearer("nope"))).status).toBe(401);
61
+ });
62
+
63
+ it("flag on: platform.read serves the platform spec byte-for-byte", async () => {
64
+ const app = buildApp({ SHIPWRIGHT_ADMIN_DOC_ENABLED: "enabled" });
65
+ const res = await app.request("/doc", bearer("tok-read"));
66
+ expect(res.status).toBe(200);
67
+ expect(res.headers.get("Cache-Control")).toBe("no-store");
68
+ expect(res.headers.get("Content-Type")).toContain("application/json");
69
+ const text = await res.text();
70
+ expect(text).toBe(`${JSON.stringify(buildPlatformAdminSpec(), null, 2)}\n`);
71
+ const doc = JSON.parse(text) as {
72
+ openapi: string;
73
+ paths: Record<string, unknown>;
74
+ };
75
+ expect(doc.openapi.startsWith("3.1")).toBe(true);
76
+ expect(Object.keys(doc.paths).some((p) => p.startsWith("/coding"))).toBe(
77
+ false,
78
+ );
79
+ });
80
+
81
+ it("flag on: *, platform.ops and agent keys succeed", async () => {
82
+ const app = buildApp({ SHIPWRIGHT_ADMIN_DOC_ENABLED: "enabled" });
83
+ for (const t of ["tok-admin", "tok-ops", "tok-agent"]) {
84
+ expect((await app.request("/doc", bearer(t))).status).toBe(200);
85
+ }
86
+ });
87
+
88
+ it("matches the committed platform spec file", async () => {
89
+ const app = buildApp({ SHIPWRIGHT_ADMIN_DOC_ENABLED: "enabled" });
90
+ const res = await app.request("/doc", bearer("tok-read"));
91
+ const committed = await Bun.file(
92
+ new URL("../openapi.platform.json", import.meta.url),
93
+ ).text();
94
+ expect(await res.text()).toBe(committed);
95
+ });
96
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * admin/src/admin-spec.ts
3
+ * Builds the platform-only OpenAPI 3.1 document of the admin service.
4
+ *
5
+ * Instantiates the admin CRUD app and the agent runtime app in-process with
6
+ * minimal stubs (no real DB, no real services — only route definitions matter),
7
+ * then:
8
+ * 1. Calls getOpenAPI31Document() on each sub-app independently.
9
+ * 2. Rewrites the runtime app's Hono-style path params (`:id`) to OpenAPI
10
+ * style (`{id}`) and prefixes them with `/agents`.
11
+ * 3. Merges both specs' paths and component schemas into a single document.
12
+ *
13
+ * The result is committed as admin/openapi.platform.json and feeds the
14
+ * platform half of the generated admin types (lib/admin-types.ts). Consumers
15
+ * that compose admin extensions fold the extensions' OpenAPI contributions in
16
+ * on top (applyOpenApiContributions) — Shipwright does so in
17
+ * scripts/generate-admin-spec.ts to produce the composed admin/openapi.json.
18
+ */
19
+
20
+ import { NoopAgentProvisioner } from "./agent-provisioner.ts";
21
+ import {
22
+ type AdminDeps,
23
+ createAdminApp,
24
+ parseAdminApiKeys,
25
+ } from "./agents-api.ts";
26
+ import { createAgentRuntimeApp } from "./api.ts";
27
+
28
+ /** The admin OpenAPI document shape the spec builders and generators share. */
29
+ export interface AdminOpenApiDocument {
30
+ openapi: string;
31
+ info: { title: string; version: string; description?: string };
32
+ paths: Record<string, Record<string, unknown>>;
33
+ components?: { schemas?: Record<string, unknown>; [key: string]: unknown };
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ /** Builds the platform-only admin OpenAPI document (no extension routes). */
38
+ export function buildPlatformAdminSpec(): AdminOpenApiDocument {
39
+ // Stub deps — only route definitions matter for spec generation.
40
+ const SESSION_SECRET = "dummy-secret-32chars-for-spec-gen";
41
+ const adminApiKeys = parseAdminApiKeys("codegen:codegen-key:*");
42
+
43
+ const runtimeApp = createAgentRuntimeApp({
44
+ agentEnvService: {
45
+ async getConfigBundle() {
46
+ return null;
47
+ },
48
+ },
49
+ agentCronJobService: {
50
+ async list() {
51
+ return [];
52
+ },
53
+ },
54
+ agentService: {
55
+ async getById() {
56
+ return null;
57
+ },
58
+ },
59
+ agentPluginService: {
60
+ async listEnabled() {
61
+ return [];
62
+ },
63
+ },
64
+ adminApiKeys,
65
+ agentTokenService: { validate: async () => null },
66
+ sessionSecret: SESSION_SECRET,
67
+ });
68
+
69
+ const adminApp = createAdminApp({
70
+ agentEnvService: {
71
+ upsert: async () => {},
72
+ patch: async () => {},
73
+ getByAgentId: async () => null,
74
+ deleteKey: async () => {},
75
+ },
76
+ agentCronJobService: {
77
+ list: async () => [],
78
+ listWithRunSummary: async () => [],
79
+ create: async () => ({}) as never,
80
+ update: async () => ({}) as never,
81
+ delete: async () => {},
82
+ get: async () => ({}) as never,
83
+ setEnabled: async () => ({}) as never,
84
+ updatePreCheck: async () => ({}) as never,
85
+ reconcileSystemCrons: async () => ({
86
+ created: 0,
87
+ updated: 0,
88
+ deleted: 0,
89
+ }),
90
+ },
91
+ agentToolService: {
92
+ list: async () => [],
93
+ add: async () => ({}) as never,
94
+ remove: async () => {},
95
+ toggle: async () => ({}) as never,
96
+ },
97
+ agentTokenService: {
98
+ create: async () => ({}) as never,
99
+ listForAgent: async () => [],
100
+ revoke: async () => ({}) as never,
101
+ validate: async () => null,
102
+ },
103
+ agentPluginService: {
104
+ list: async () => [],
105
+ add: async () => ({}) as never,
106
+ remove: async () => {},
107
+ removeByName: async () => {},
108
+ },
109
+ prisma: {
110
+ agent: {
111
+ create: async () => ({}) as never,
112
+ findUnique: async () => null,
113
+ findMany: async () => [],
114
+ delete: async () => ({}) as never,
115
+ },
116
+ } as never,
117
+ provisioner: new NoopAgentProvisioner(),
118
+ sessionSecret: SESSION_SECRET,
119
+ adminApiKeys,
120
+ // Partial on purpose: route registration never calls the services, so only
121
+ // the deps a factory reads at construction time are stubbed.
122
+ } as unknown as AdminDeps);
123
+
124
+ const docInfo = {
125
+ openapi: "3.1.0" as const,
126
+ info: {
127
+ title: "Shipwright Admin API",
128
+ version: "0.1.0",
129
+ description:
130
+ "REST API for the Shipwright admin service — CRUD for agents, env vars, cron jobs, tokens, tools, and plugins, plus the agent runtime polling endpoints.",
131
+ },
132
+ servers: [{ url: "http://localhost:3457", description: "Local dev" }],
133
+ };
134
+
135
+ // Admin app spec (uses OpenAPI {id} path notation)
136
+ const adminSpec = adminApp.getOpenAPI31Document(docInfo);
137
+
138
+ // Runtime app spec (uses Hono :id notation — paths are relative to sub-app root)
139
+ const runtimeSpec = runtimeApp.getOpenAPI31Document({
140
+ openapi: "3.1.0",
141
+ info: { title: "runtime (internal)", version: "0.1.0" },
142
+ });
143
+
144
+ // The runtime app's routes are defined as `/:id/config` and `/:id/crons`.
145
+ // When the spec is generated standalone, these appear as `/:id/config` etc.
146
+ // We rewrite Hono colon params to OpenAPI curly brace params, then prefix
147
+ // with `/agents` to match the root-mounted path.
148
+
149
+ // Build runtime paths with OpenAPI notation and /agents prefix.
150
+ // Merge at the method level so paths shared between admin and runtime
151
+ // (e.g. /agents/{id}/crons has both POST from admin and GET from runtime)
152
+ // keep all their HTTP methods.
153
+ const adminPaths = (adminSpec.paths ?? {}) as Record<
154
+ string,
155
+ Record<string, unknown>
156
+ >;
157
+ const mergedPaths: Record<string, Record<string, unknown>> = {
158
+ ...adminPaths,
159
+ };
160
+
161
+ for (const [path, def] of Object.entries(runtimeSpec.paths ?? {})) {
162
+ const openApiPath = `/agents${path.replace(/:(\w+)/g, "{$1}")}`;
163
+ if (mergedPaths[openApiPath]) {
164
+ // Merge methods — runtime GET on /agents/{id}/crons must coexist with
165
+ // admin POST on the same path.
166
+ mergedPaths[openApiPath] = {
167
+ ...mergedPaths[openApiPath],
168
+ ...(def as Record<string, unknown>),
169
+ };
170
+ } else {
171
+ mergedPaths[openApiPath] = def as Record<string, unknown>;
172
+ }
173
+ }
174
+
175
+ return {
176
+ ...adminSpec,
177
+ paths: mergedPaths,
178
+ components: {
179
+ ...(adminSpec.components ?? {}),
180
+ schemas: {
181
+ ...(adminSpec.components?.schemas ?? {}),
182
+ ...(runtimeSpec.components?.schemas ?? {}),
183
+ },
184
+ },
185
+ };
186
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * admin/src/admin-ui-account-nav.ts
3
+ * Per-request "show the Account nav entry" flag (SSP-3.2). renderAdminToolbar()
4
+ * is called from dozens of page renderers; rather than thread a parameter
5
+ * through all of them, an admin-ui middleware wraps each /admin/* request in
6
+ * runWithAccountNav() and the toolbar reads the flag back. Outside any
7
+ * context (tests, other services) the entry is hidden.
8
+ */
9
+
10
+ import { AsyncLocalStorage } from "node:async_hooks";
11
+
12
+ const storage = new AsyncLocalStorage<{ showAccount: boolean }>();
13
+
14
+ export function runWithAccountNav<T>(showAccount: boolean, fn: () => T): T {
15
+ return storage.run({ showAccount }, fn);
16
+ }
17
+
18
+ export function shouldShowAccountNav(): boolean {
19
+ return storage.getStore()?.showAccount === true;
20
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * admin/src/admin-ui-account-pages.ts
3
+ * Render functions for the self-serve account page and the zero-quota notice
4
+ * (SSP-3.2). Pure string -> string; routes live in admin-ui-account.ts.
5
+ */
6
+
7
+ import type { AccountInvite } from "./account-invites.ts";
8
+ import type { AccountMember } from "./account-members.ts";
9
+ import type { Account } from "./accounts.ts";
10
+ import { renderAdminPage } from "./admin-ui-layout.ts";
11
+ import { escapeHtml, renderAdminToolbar } from "./admin-ui-styles.ts";
12
+
13
+ export const ACCOUNT_PATH = "/admin/account";
14
+
15
+ interface AccountPageOpts {
16
+ userEmail: string;
17
+ account: Account;
18
+ agentCount: number;
19
+ members: AccountMember[];
20
+ invites: AccountInvite[];
21
+ /** True when the viewer is an owner; members get a read-only page. */
22
+ isOwner: boolean;
23
+ error?: string;
24
+ }
25
+
26
+ function emailForm(
27
+ action: string,
28
+ email: string,
29
+ label: string,
30
+ danger = false,
31
+ ): string {
32
+ return `<form method="POST" action="${ACCOUNT_PATH}/${action}" style="display:inline;margin:0">
33
+ <input type="hidden" name="email" value="${escapeHtml(email)}" />
34
+ <button type="submit" class="btn ${danger ? "btn-danger" : "btn-secondary"}" style="font-size:12px;padding:4px 10px">${label}</button>
35
+ </form>`;
36
+ }
37
+
38
+ function renderMemberRow(m: AccountMember, isOwner: boolean): string {
39
+ const actions = isOwner
40
+ ? [
41
+ m.role === "owner"
42
+ ? emailForm("members/demote", m.email, "Demote")
43
+ : emailForm("members/promote", m.email, "Make owner"),
44
+ emailForm("members/remove", m.email, "Remove", true),
45
+ ].join(" ")
46
+ : "";
47
+ return `<tr>
48
+ <td>${escapeHtml(m.email)}</td>
49
+ <td><span class="badge ${m.role === "owner" ? "badge-purple" : "badge-gray"}">${escapeHtml(m.role)}</span></td>
50
+ ${isOwner ? `<td style="text-align:right">${actions}</td>` : ""}
51
+ </tr>`;
52
+ }
53
+
54
+ function renderInviteRow(i: AccountInvite, isOwner: boolean): string {
55
+ return `<tr>
56
+ <td>${escapeHtml(i.email)}</td>
57
+ <td>${escapeHtml(i.invitedBy)}</td>
58
+ ${isOwner ? `<td style="text-align:right">${emailForm("invites/revoke", i.email, "Revoke", true)}</td>` : ""}
59
+ </tr>`;
60
+ }
61
+
62
+ export function renderAccountPage(opts: AccountPageOpts): string {
63
+ const { account, isOwner } = opts;
64
+ const errorHtml = opts.error
65
+ ? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
66
+ : "";
67
+ const actionTh = isOwner ? "<th></th>" : "";
68
+
69
+ const memberRows = opts.members
70
+ .map((m) => renderMemberRow(m, isOwner))
71
+ .join("\n");
72
+ const inviteRows =
73
+ opts.invites.length === 0
74
+ ? `<tr><td colspan="${isOwner ? 3 : 2}" class="empty-state">No pending invites.</td></tr>`
75
+ : opts.invites.map((i) => renderInviteRow(i, isOwner)).join("\n");
76
+
77
+ const renameForm = isOwner
78
+ ? `<form method="POST" action="${ACCOUNT_PATH}/rename" style="display:flex;gap:8px;margin-top:12px">
79
+ <input name="name" class="form-input" maxlength="100" required value="${escapeHtml(account.name)}" style="max-width:280px" />
80
+ <button type="submit" class="btn btn-secondary">Rename</button>
81
+ </form>`
82
+ : "";
83
+ const inviteForm = isOwner
84
+ ? `<form method="POST" action="${ACCOUNT_PATH}/invite" style="display:flex;gap:8px;margin-top:12px">
85
+ <input name="email" type="email" class="form-input" required placeholder="teammate@example.com" style="max-width:280px" />
86
+ <button type="submit" class="btn btn-primary">Invite</button>
87
+ </form>`
88
+ : "";
89
+
90
+ return renderAdminPage({
91
+ title: "Account — Shipwright Admin",
92
+ body: `${renderAdminToolbar(opts.userEmail, ACCOUNT_PATH)}
93
+ <div class="vos-page">
94
+ <div class="page-header">
95
+ <h1 class="page-title">${escapeHtml(account.name)}</h1>
96
+ </div>
97
+ ${errorHtml}
98
+ <div class="card">
99
+ <div class="card-title">Account</div>
100
+ <p>Agents: <strong>${opts.agentCount} / ${account.maxAgents}</strong></p>
101
+ ${renameForm}
102
+ </div>
103
+ <div class="card">
104
+ <div class="card-title">Members</div>
105
+ <div class="data-table-wrapper">
106
+ <table class="data-table">
107
+ <thead><tr><th>Email</th><th>Role</th>${actionTh}</tr></thead>
108
+ <tbody>${memberRows}</tbody>
109
+ </table>
110
+ </div>
111
+ </div>
112
+ <div class="card">
113
+ <div class="card-title">Pending invites</div>
114
+ <div class="data-table-wrapper">
115
+ <table class="data-table">
116
+ <thead><tr><th>Email</th><th>Invited by</th>${actionTh}</tr></thead>
117
+ <tbody>${inviteRows}</tbody>
118
+ </table>
119
+ </div>
120
+ ${inviteForm}
121
+ </div>
122
+ </div>`,
123
+ });
124
+ }
125
+
126
+ /** Shown on /admin/agents in place of the create button when maxAgents = 0. */
127
+ export function renderZeroQuotaNotice(contactEmail: string): string {
128
+ const email = escapeHtml(contactEmail);
129
+ return `<div class="alert alert-warning" id="zero-quota-notice">Email ${email} to request a trial</div>
130
+ <button type="button" class="btn btn-primary" disabled aria-disabled="true">+ New agent</button>`;
131
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * admin/src/admin-ui-account-pages.unit.test.ts
3
+ * Pure render tests for the account page fragments (SSP-3.2).
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ renderAccountPage,
9
+ renderZeroQuotaNotice,
10
+ } from "./admin-ui-account-pages.ts";
11
+ import { renderAdminToolbar } from "./admin-ui-styles.ts";
12
+ import { runWithAccountNav } from "./admin-ui-account-nav.ts";
13
+
14
+ const d = new Date("2026-01-01T00:00:00Z");
15
+ const account = {
16
+ id: "a1",
17
+ name: "Acme <Co>",
18
+ status: "active",
19
+ maxAgents: 3,
20
+ plan: null,
21
+ trialExpiresAt: null,
22
+ trialExpiryWarnedAt: null,
23
+ createdAt: d,
24
+ updatedAt: d,
25
+ };
26
+ const members = [
27
+ { id: "m1", accountId: "a1", email: "o@x.com", role: "owner", createdAt: d },
28
+ { id: "m2", accountId: "a1", email: "m@x.com", role: "member", createdAt: d },
29
+ ];
30
+ const invites = [
31
+ {
32
+ id: "i1",
33
+ accountId: "a1",
34
+ email: "new@x.com",
35
+ invitedBy: "o@x.com",
36
+ acceptedAt: null,
37
+ createdAt: d,
38
+ },
39
+ ];
40
+
41
+ function render(isOwner: boolean, error?: string) {
42
+ return renderAccountPage({
43
+ userEmail: isOwner ? "o@x.com" : "m@x.com",
44
+ // biome-ignore lint/suspicious/noExplicitAny: partial fixture
45
+ account: account as any,
46
+ agentCount: 2,
47
+ // biome-ignore lint/suspicious/noExplicitAny: partial fixture
48
+ members: members as any,
49
+ // biome-ignore lint/suspicious/noExplicitAny: partial fixture
50
+ invites: invites as any,
51
+ isOwner,
52
+ error,
53
+ });
54
+ }
55
+
56
+ describe("renderAccountPage", () => {
57
+ test("shows escaped name, quota used/max, members and pending invites", () => {
58
+ const html = render(true);
59
+ expect(html).toContain("Acme &lt;Co&gt;");
60
+ expect(html).not.toContain("Acme <Co>");
61
+ expect(html).toContain("2 / 3");
62
+ expect(html).toContain("o@x.com");
63
+ expect(html).toContain("new@x.com");
64
+ });
65
+
66
+ test("owner sees management forms", () => {
67
+ const html = render(true);
68
+ expect(html).toContain('action="/admin/account/invite"');
69
+ expect(html).toContain('action="/admin/account/invites/revoke"');
70
+ expect(html).toContain('action="/admin/account/members/remove"');
71
+ expect(html).toContain('action="/admin/account/members/promote"');
72
+ expect(html).toContain('action="/admin/account/members/demote"');
73
+ expect(html).toContain('action="/admin/account/rename"');
74
+ });
75
+
76
+ test("member sees read-only page", () => {
77
+ const html = render(false);
78
+ expect(html).not.toContain('action="/admin/account/');
79
+ expect(html).toContain("m@x.com");
80
+ });
81
+
82
+ test("renders an escaped error", () => {
83
+ expect(render(true, "bad <b>")).toContain("bad &lt;b&gt;");
84
+ });
85
+ });
86
+
87
+ describe("renderZeroQuotaNotice", () => {
88
+ test("shows request-a-trial message with escaped contact address", () => {
89
+ const html = renderZeroQuotaNotice("dan@app-vitals.com");
90
+ expect(html).toContain("Email dan@app-vitals.com to request a trial");
91
+ });
92
+ });
93
+
94
+ describe("renderAdminToolbar account nav", () => {
95
+ test("no Account entry outside the account-nav context", () => {
96
+ expect(renderAdminToolbar("u", "/admin/agents")).not.toContain(
97
+ "/admin/account",
98
+ );
99
+ });
100
+ test("Account entry inside the context", () => {
101
+ const html = runWithAccountNav(true, () =>
102
+ renderAdminToolbar("u", "/admin/agents"),
103
+ );
104
+ expect(html).toContain('href="/admin/account"');
105
+ });
106
+ test("no entry when context says false", () => {
107
+ const html = runWithAccountNav(false, () =>
108
+ renderAdminToolbar("u", "/admin/agents"),
109
+ );
110
+ expect(html).not.toContain("/admin/account");
111
+ });
112
+ });