@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
package/src/clock.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * agent/src/runtime/clock.ts
3
+ * Clock interface and production implementation for injectable time.
4
+ *
5
+ * Any code that needs the current time should accept a `Clock` via dependency
6
+ * injection rather than calling `new Date()` or `Date.now()` directly.
7
+ * This makes time-dependent logic trivially testable and deterministic.
8
+ */
9
+
10
+ // ─── Interface ────────────────────────────────────────────────────────────────
11
+
12
+ export interface Clock {
13
+ /** Returns the current time as a Date. */
14
+ now(): Date;
15
+ }
16
+
17
+ // ─── SystemClock ──────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Production clock — delegates to the real system time.
21
+ *
22
+ * @example
23
+ * const clock = SystemClock();
24
+ * clock.now(); // → current wall-clock time
25
+ */
26
+ export function SystemClock(): Clock {
27
+ return {
28
+ now(): Date {
29
+ return new Date();
30
+ },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Fixed-time clock for deterministic testing.
36
+ *
37
+ * @example
38
+ * const clock = FixedClock(new Date("2024-01-01T00:00:00Z"));
39
+ * clock.now(); // → always 2024-01-01T00:00:00.000Z
40
+ */
41
+ export function FixedClock(date: Date): Clock {
42
+ return {
43
+ now(): Date {
44
+ return new Date(date);
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * admin/src/coding-task-store-provisioning-client.integration.test.ts
3
+ * Integration tests for HttpTaskStoreProvisioningClient against recorded
4
+ * task-store token API fixtures.
5
+ *
6
+ * Drives the client through an INJECTED fetchFn that replays canned Responses
7
+ * from a cassette keyed by scenario — no live API server, no global.fetch
8
+ * override, no mock.module(). Mirrors the pattern in
9
+ * kubernetes-client.integration.test.ts.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import { readFileSync } from "node:fs";
14
+ import {
15
+ HttpTaskStoreProvisioningClient,
16
+ NoopTaskStoreProvisioningClient,
17
+ } from "./coding-task-store-provisioning-client.ts";
18
+
19
+ // ─── Cassette ───────────────────────────────────────────────────────────────
20
+
21
+ interface CassetteEntry {
22
+ status: number;
23
+ body: unknown;
24
+ }
25
+
26
+ const CASSETTE_PATH = new URL(
27
+ "./fixtures/task-store-provisioning-cassette.json",
28
+ import.meta.url,
29
+ ).pathname;
30
+
31
+ const cassette: Record<string, CassetteEntry> = JSON.parse(
32
+ readFileSync(CASSETTE_PATH, "utf-8"),
33
+ );
34
+
35
+ interface RecordedRequest {
36
+ url: string;
37
+ method: string;
38
+ headers: Headers;
39
+ body?: string;
40
+ }
41
+
42
+ /**
43
+ * Build an injected fetchFn that returns the cassette entry for `key`.
44
+ * Records the last request so tests can assert URL/method/headers/body.
45
+ */
46
+ function cassetteFetch(key: string): {
47
+ fetchFn: typeof fetch;
48
+ lastRequest: () => RecordedRequest;
49
+ } {
50
+ let last: RecordedRequest | undefined;
51
+ const entry = cassette[key];
52
+ if (!entry) throw new Error(`cassette key not found: ${key}`);
53
+
54
+ const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
55
+ const url = typeof input === "string" ? input : input.toString();
56
+ last = {
57
+ url,
58
+ method: init?.method ?? "GET",
59
+ headers: new Headers(init?.headers),
60
+ body: typeof init?.body === "string" ? init.body : undefined,
61
+ };
62
+ return new Response(JSON.stringify(entry.body), {
63
+ status: entry.status,
64
+ statusText: `status-${entry.status}`,
65
+ headers: { "Content-Type": "application/json" },
66
+ });
67
+ }) as typeof fetch;
68
+
69
+ return {
70
+ fetchFn,
71
+ lastRequest: () => {
72
+ if (!last) throw new Error("fetchFn was not called");
73
+ return last;
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Build an injected fetchFn that resolves each request from `cassette[key]`
80
+ * based on the request's method + URL (a simple router), and records every
81
+ * request made — needed for tests that drive multiple calls (e.g. list then
82
+ * revoke several matches) against a single client instance.
83
+ *
84
+ * `routes` maps "METHOD path" (path relative to baseUrl, e.g. "GET /tokens"
85
+ * or "DELETE /tokens/tok_abc123") to a cassette key.
86
+ */
87
+ function multiCassetteFetch(routes: Record<string, string>): {
88
+ fetchFn: typeof fetch;
89
+ requests: () => RecordedRequest[];
90
+ } {
91
+ const requests: RecordedRequest[] = [];
92
+
93
+ const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
94
+ const url = typeof input === "string" ? input : input.toString();
95
+ const method = init?.method ?? "GET";
96
+ const path = url.replace("https://task-store.example.com", "");
97
+ const routeKey = `${method} ${path}`;
98
+ const cassetteKey = routes[routeKey];
99
+ if (!cassetteKey) {
100
+ throw new Error(`no route configured for: ${routeKey}`);
101
+ }
102
+ const entry = cassette[cassetteKey];
103
+ if (!entry) throw new Error(`cassette key not found: ${cassetteKey}`);
104
+
105
+ requests.push({
106
+ url,
107
+ method,
108
+ headers: new Headers(init?.headers),
109
+ body: typeof init?.body === "string" ? init.body : undefined,
110
+ });
111
+
112
+ return new Response(JSON.stringify(entry.body), {
113
+ status: entry.status,
114
+ statusText: `status-${entry.status}`,
115
+ headers: { "Content-Type": "application/json" },
116
+ });
117
+ }) as typeof fetch;
118
+
119
+ return { fetchFn, requests: () => requests };
120
+ }
121
+
122
+ function makeMultiClient(routes: Record<string, string>): {
123
+ client: HttpTaskStoreProvisioningClient;
124
+ requests: () => RecordedRequest[];
125
+ } {
126
+ const { fetchFn, requests } = multiCassetteFetch(routes);
127
+ const client = new HttpTaskStoreProvisioningClient(
128
+ "https://task-store.example.com",
129
+ "admin-token-xyz",
130
+ { fetchFn },
131
+ );
132
+ return { client, requests };
133
+ }
134
+
135
+ function makeClient(key: string): {
136
+ client: HttpTaskStoreProvisioningClient;
137
+ lastRequest: () => RecordedRequest;
138
+ } {
139
+ const { fetchFn, lastRequest } = cassetteFetch(key);
140
+ const client = new HttpTaskStoreProvisioningClient(
141
+ "https://task-store.example.com",
142
+ "admin-token-xyz",
143
+ { fetchFn },
144
+ );
145
+ return { client, lastRequest };
146
+ }
147
+
148
+ // ─── mintToken ───────────────────────────────────────────────────────────────
149
+
150
+ describe("HttpTaskStoreProvisioningClient — mintToken", () => {
151
+ it("POSTs to /tokens with Bearer auth and returns id/rawToken", async () => {
152
+ const { client, lastRequest } = makeClient("mintToken_success");
153
+ const result = await client.mintToken("agent-abc label");
154
+
155
+ const req = lastRequest();
156
+ expect(req.method).toBe("POST");
157
+ expect(req.url).toBe("https://task-store.example.com/tokens");
158
+ expect(req.headers.get("authorization")).toBe("Bearer admin-token-xyz");
159
+ expect(req.headers.get("content-type")).toBe("application/json");
160
+ expect(JSON.parse(req.body ?? "{}")).toEqual({ label: "agent-abc label" });
161
+ expect(result).toEqual({
162
+ id: "tok_abc123",
163
+ rawToken: "sts_raw_test_token_value",
164
+ });
165
+ });
166
+
167
+ it("includes agentId in the body when provided", async () => {
168
+ const { client, lastRequest } = makeClient(
169
+ "mintToken_success_with_agentId",
170
+ );
171
+ const result = await client.mintToken("agent-scoped label", "agent-123");
172
+
173
+ const req = lastRequest();
174
+ expect(JSON.parse(req.body ?? "{}")).toEqual({
175
+ label: "agent-scoped label",
176
+ agentId: "agent-123",
177
+ });
178
+ expect(result.id).toBe("tok_def456");
179
+ });
180
+
181
+ it("throws on a non-ok response", async () => {
182
+ const { client } = makeClient("mintToken_500");
183
+ await expect(client.mintToken("label")).rejects.toThrow(
184
+ /task-store POST \/tokens failed: 500/,
185
+ );
186
+ });
187
+ });
188
+
189
+ // ─── revokeToken ─────────────────────────────────────────────────────────────
190
+
191
+ describe("HttpTaskStoreProvisioningClient — revokeToken", () => {
192
+ it("DELETEs /tokens/:id with Bearer auth", async () => {
193
+ const { client, lastRequest } = makeClient("revokeToken_success");
194
+ await client.revokeToken("tok_abc123");
195
+
196
+ const req = lastRequest();
197
+ expect(req.method).toBe("DELETE");
198
+ expect(req.url).toBe("https://task-store.example.com/tokens/tok_abc123");
199
+ expect(req.headers.get("authorization")).toBe("Bearer admin-token-xyz");
200
+ });
201
+
202
+ it("treats 404 as success (already revoked / not found is not an error)", async () => {
203
+ const { client } = makeClient("revokeToken_404");
204
+ await expect(client.revokeToken("tok_missing")).resolves.toBeUndefined();
205
+ });
206
+
207
+ it("throws on a non-ok, non-404 response", async () => {
208
+ const { client } = makeClient("revokeToken_500");
209
+ await expect(client.revokeToken("tok_abc123")).rejects.toThrow(
210
+ /task-store DELETE \/tokens\/tok_abc123 failed: 500/,
211
+ );
212
+ });
213
+ });
214
+
215
+ // ─── listTokensForAgent ────────────────────────────────────────────────────────
216
+
217
+ describe("HttpTaskStoreProvisioningClient — listTokensForAgent", () => {
218
+ it("GETs /tokens with Bearer auth and filters client-side by agentId", async () => {
219
+ const { client, lastRequest } = makeClient("listTokens_mixedAgents");
220
+ const result = await client.listTokensForAgent("agent-1");
221
+
222
+ const req = lastRequest();
223
+ expect(req.method).toBe("GET");
224
+ expect(req.url).toBe("https://task-store.example.com/tokens");
225
+ expect(req.headers.get("authorization")).toBe("Bearer admin-token-xyz");
226
+
227
+ // Only tokens with agentId === "agent-1" — includes the already-revoked
228
+ // one (revocation state is irrelevant to listing/filtering).
229
+ expect(result).toEqual([
230
+ { id: "tok_agent1_a" },
231
+ { id: "tok_agent1_b" },
232
+ { id: "tok_agent1_revoked" },
233
+ ]);
234
+ });
235
+
236
+ it("excludes tokens with a different agentId and tokens with no agentId", async () => {
237
+ const { client } = makeClient("listTokens_mixedAgents");
238
+ const result = await client.listTokensForAgent("agent-2");
239
+ expect(result).toEqual([{ id: "tok_agent2_a" }]);
240
+ });
241
+
242
+ it("returns an empty array when no tokens match", async () => {
243
+ const { client } = makeClient("listTokens_noMatches");
244
+ const result = await client.listTokensForAgent("agent-1");
245
+ expect(result).toEqual([]);
246
+ });
247
+
248
+ it("returns an empty array when the token list itself is empty", async () => {
249
+ const { client } = makeClient("listTokens_empty");
250
+ const result = await client.listTokensForAgent("agent-1");
251
+ expect(result).toEqual([]);
252
+ });
253
+
254
+ it("throws on a non-ok response", async () => {
255
+ const { client } = makeClient("listTokens_500");
256
+ await expect(client.listTokensForAgent("agent-1")).rejects.toThrow(
257
+ /task-store GET \/tokens failed: 500/,
258
+ );
259
+ });
260
+ });
261
+
262
+ // ─── listTokensForAgent + revokeToken orchestration (caller-driven) ──────────
263
+
264
+ describe("listTokensForAgent + revokeToken — agent token cleanup flow", () => {
265
+ it("revokes every token matching the agent (multiple matches)", async () => {
266
+ const { client, requests } = makeMultiClient({
267
+ "GET /tokens": "listTokens_mixedAgents",
268
+ "DELETE /tokens/tok_agent1_a": "revokeToken_success",
269
+ "DELETE /tokens/tok_agent1_b": "revokeToken_success",
270
+ "DELETE /tokens/tok_agent1_revoked": "revokeToken_success",
271
+ });
272
+
273
+ const matches = await client.listTokensForAgent("agent-1");
274
+ expect(matches).toEqual([
275
+ { id: "tok_agent1_a" },
276
+ { id: "tok_agent1_b" },
277
+ { id: "tok_agent1_revoked" },
278
+ ]);
279
+
280
+ for (const { id } of matches) {
281
+ await client.revokeToken(id);
282
+ }
283
+
284
+ const made = requests();
285
+ expect(made).toHaveLength(4);
286
+ expect(made[0].method).toBe("GET");
287
+ expect(made[0].url).toBe("https://task-store.example.com/tokens");
288
+ expect(made.slice(1).map((r) => r.method)).toEqual([
289
+ "DELETE",
290
+ "DELETE",
291
+ "DELETE",
292
+ ]);
293
+
294
+ // Every request only ever touches /tokens paths — never /tasks. This is
295
+ // the client-side guarantee that no Task/assignee mutation occurs: this
296
+ // client has no method that can call anything but the tokens endpoint.
297
+ for (const r of made) {
298
+ expect(r.url).toMatch(
299
+ /^https:\/\/task-store\.example\.com\/tokens(\/|$)/,
300
+ );
301
+ expect(r.url).not.toContain("/tasks");
302
+ }
303
+ });
304
+
305
+ it("is a no-op when zero tokens match the agent", async () => {
306
+ const { client, requests } = makeMultiClient({
307
+ "GET /tokens": "listTokens_noMatches",
308
+ });
309
+
310
+ const matches = await client.listTokensForAgent("agent-does-not-exist");
311
+ expect(matches).toEqual([]);
312
+
313
+ for (const { id } of matches) {
314
+ await client.revokeToken(id);
315
+ }
316
+
317
+ expect(requests()).toHaveLength(1);
318
+ expect(requests()[0].method).toBe("GET");
319
+ });
320
+
321
+ it("re-revoking an already-revoked token does not throw", async () => {
322
+ const { client, requests } = makeMultiClient({
323
+ "GET /tokens": "listTokens_mixedAgents",
324
+ // tok_agent1_revoked is already revoked server-side; task-store's
325
+ // revoke() is idempotent, so this still returns success (200/204),
326
+ // not a 404 or 500.
327
+ "DELETE /tokens/tok_agent1_revoked": "revokeToken_success",
328
+ "DELETE /tokens/tok_agent1_a": "revokeToken_success",
329
+ "DELETE /tokens/tok_agent1_b": "revokeToken_success",
330
+ });
331
+
332
+ const matches = await client.listTokensForAgent("agent-1");
333
+ const revokedMatch = matches.find((m) => m.id === "tok_agent1_revoked");
334
+ expect(revokedMatch).toBeDefined();
335
+
336
+ await expect(
337
+ client.revokeToken("tok_agent1_revoked"),
338
+ ).resolves.toBeUndefined();
339
+
340
+ const made = requests();
341
+ expect(made.some((r) => r.url.endsWith("/tokens/tok_agent1_revoked"))).toBe(
342
+ true,
343
+ );
344
+ });
345
+ });
346
+
347
+ // ─── NoopTaskStoreProvisioningClient ─────────────────────────────────────────
348
+
349
+ describe("NoopTaskStoreProvisioningClient", () => {
350
+ it("mintToken returns empty id/rawToken", async () => {
351
+ const client = new NoopTaskStoreProvisioningClient();
352
+ const result = await client.mintToken("label");
353
+ expect(result).toEqual({ id: "", rawToken: "" });
354
+ });
355
+
356
+ it("mintToken accepts an agentId without erroring", async () => {
357
+ const client = new NoopTaskStoreProvisioningClient();
358
+ const result = await client.mintToken("label", "agent-1");
359
+ expect(result).toEqual({ id: "", rawToken: "" });
360
+ });
361
+
362
+ it("revokeToken resolves without error", async () => {
363
+ const client = new NoopTaskStoreProvisioningClient();
364
+ await expect(client.revokeToken("any-id")).resolves.toBeUndefined();
365
+ });
366
+
367
+ it("listTokensForAgent returns an empty array with no-op behavior", async () => {
368
+ const client = new NoopTaskStoreProvisioningClient();
369
+ const result = await client.listTokensForAgent("agent-1");
370
+ expect(result).toEqual([]);
371
+ });
372
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * admin/src/coding-task-store-provisioning-client.ts
3
+ *
4
+ * Thin interface + implementations for minting/revoking per-agent task-store
5
+ * tokens during agent provisioning.
6
+ *
7
+ * The interface is the DI boundary. Tests inject a stub; production wires in
8
+ * HttpTaskStoreProvisioningClient (or skips task-store wiring entirely by
9
+ * omitting the client from KubernetesAgentProvisionerConfig).
10
+ */
11
+
12
+ import type { components } from "@app-vitals/agent-lib/task-store-types";
13
+
14
+ // ─── Interface ───────────────────────────────────────────────────────────────
15
+
16
+ export interface TaskStoreProvisioningClient {
17
+ /**
18
+ * Mint a new task-store token. Returns the token `id` (needed to revoke it
19
+ * on rollback) and the `rawToken` (stored in the agent Secret and later used
20
+ * by the agent to authenticate with the task store).
21
+ *
22
+ * Pass `agentId` to create a scoped agent token — the task store will scope
23
+ * all reads and writes to tasks assigned to this agent. Omit for admin tokens.
24
+ */
25
+ mintToken(
26
+ label: string,
27
+ agentId?: string,
28
+ ): Promise<{ id: string; rawToken: string }>;
29
+
30
+ /**
31
+ * Revoke a previously-minted task-store token. Called as part of the rollback
32
+ * path when Deployment creation fails after the token was minted, and as
33
+ * part of agent-deletion credential cleanup.
34
+ * Should be best-effort — callers do not propagate errors from this call.
35
+ */
36
+ revokeToken(id: string): Promise<void>;
37
+
38
+ /**
39
+ * List the ids of every task-store token scoped to `agentId`.
40
+ *
41
+ * task-store's GET /tokens has no server-side agentId filter — it returns
42
+ * every token in the store — so this fetches the full list and filters
43
+ * client-side. Intended to be paired with `revokeToken` by the caller (e.g.
44
+ * agent-deletion cleanup): list, then revoke each match.
45
+ */
46
+ listTokensForAgent(agentId: string): Promise<{ id: string }[]>;
47
+ }
48
+
49
+ // ─── HTTP implementation ──────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Real HTTP client for the task-store token API.
53
+ *
54
+ * POST /tokens → { id, rawToken, ... }
55
+ * DELETE /tokens/:id
56
+ *
57
+ * The admin token is passed via Bearer auth and is never stored in the Secret
58
+ * or handed to the agent — only the per-agent rawToken is.
59
+ */
60
+ export class HttpTaskStoreProvisioningClient
61
+ implements TaskStoreProvisioningClient
62
+ {
63
+ private readonly fetchFn: typeof fetch;
64
+
65
+ constructor(
66
+ private readonly baseUrl: string,
67
+ private readonly adminToken: string,
68
+ opts?: { fetchFn?: typeof fetch },
69
+ ) {
70
+ this.fetchFn = opts?.fetchFn ?? fetch;
71
+ }
72
+
73
+ async mintToken(
74
+ label: string,
75
+ agentId?: string,
76
+ ): Promise<{ id: string; rawToken: string }> {
77
+ const tokenBody: components["schemas"]["TokenBody"] = {
78
+ label,
79
+ ...(agentId ? { agentId } : {}),
80
+ };
81
+ const res = await this.fetchFn(`${this.baseUrl}/tokens`, {
82
+ method: "POST",
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ Authorization: `Bearer ${this.adminToken}`,
86
+ },
87
+ body: JSON.stringify(tokenBody),
88
+ });
89
+ if (!res.ok) {
90
+ throw new Error(
91
+ `task-store POST /tokens failed: ${res.status} ${res.statusText}`,
92
+ );
93
+ }
94
+ const body =
95
+ (await res.json()) as components["schemas"]["TaskTokenWithRaw"];
96
+ return { id: body.id, rawToken: body.rawToken };
97
+ }
98
+
99
+ async revokeToken(id: string): Promise<void> {
100
+ const res = await this.fetchFn(`${this.baseUrl}/tokens/${id}`, {
101
+ method: "DELETE",
102
+ headers: {
103
+ Authorization: `Bearer ${this.adminToken}`,
104
+ },
105
+ });
106
+ if (!res.ok && res.status !== 404) {
107
+ throw new Error(
108
+ `task-store DELETE /tokens/${id} failed: ${res.status} ${res.statusText}`,
109
+ );
110
+ }
111
+ }
112
+
113
+ async listTokensForAgent(agentId: string): Promise<{ id: string }[]> {
114
+ const res = await this.fetchFn(`${this.baseUrl}/tokens`, {
115
+ method: "GET",
116
+ headers: {
117
+ Authorization: `Bearer ${this.adminToken}`,
118
+ },
119
+ });
120
+ if (!res.ok) {
121
+ throw new Error(
122
+ `task-store GET /tokens failed: ${res.status} ${res.statusText}`,
123
+ );
124
+ }
125
+ const tokens = (await res.json()) as components["schemas"]["TaskToken"][];
126
+ return tokens
127
+ .filter((token) => token.agentId === agentId)
128
+ .map((token) => ({ id: token.id }));
129
+ }
130
+ }
131
+
132
+ // ─── No-op implementation ─────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * No-op client used when SHIPWRIGHT_TASK_STORE_URL / SHIPWRIGHT_TASK_STORE_ADMIN_TOKEN
136
+ * are not configured. mintToken returns empty strings so callers can gate on
137
+ * the configured path without null-checking the return.
138
+ */
139
+ export class NoopTaskStoreProvisioningClient
140
+ implements TaskStoreProvisioningClient
141
+ {
142
+ async mintToken(
143
+ _label: string,
144
+ _agentId?: string,
145
+ ): Promise<{ id: string; rawToken: string }> {
146
+ return { id: "", rawToken: "" };
147
+ }
148
+
149
+ async revokeToken(_id: string): Promise<void> {
150
+ // intentionally a no-op
151
+ }
152
+
153
+ async listTokensForAgent(_agentId: string): Promise<{ id: string }[]> {
154
+ return [];
155
+ }
156
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * agent/src/runtime/crypto.ts
3
+ * AES-256-GCM encryption for at-rest value storage.
4
+ *
5
+ * Encrypted format: `iv:ciphertext:authTag` — all hex-encoded, colon-separated.
6
+ *
7
+ * Key must be a 32-byte value expressed as a 64-character hex string
8
+ * (e.g. SHIPWRIGHT_ENCRYPTION_KEY env var).
9
+ */
10
+
11
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
12
+
13
+ const ALGORITHM = "aes-256-gcm";
14
+ const IV_BYTES = 12; // 96-bit IV — recommended for GCM
15
+
16
+ /**
17
+ * True when `keyHex` is a 64-char hex string (32 bytes) usable as an AES-256-GCM
18
+ * key. Anything else — wrong length or non-hex characters — is rejected;
19
+ * `createCipheriv` would otherwise throw an opaque "Invalid key length" only at
20
+ * the first encrypt. Callers use this to fail fast with an actionable message.
21
+ */
22
+ export function isValidKeyHex(keyHex: string): boolean {
23
+ return /^[0-9a-fA-F]{64}$/.test(keyHex);
24
+ }
25
+
26
+ /**
27
+ * Encrypts a plaintext string with AES-256-GCM.
28
+ *
29
+ * @param plaintext - The string to encrypt.
30
+ * @param keyHex - 64-char hex string representing a 32-byte key.
31
+ * @returns Hex-encoded `iv:ciphertext:authTag`.
32
+ */
33
+ export function encrypt(plaintext: string, keyHex: string): string {
34
+ const key = Buffer.from(keyHex, "hex");
35
+ const iv = randomBytes(IV_BYTES);
36
+ const cipher = createCipheriv(ALGORITHM, key, iv);
37
+
38
+ const ciphertext = Buffer.concat([
39
+ cipher.update(plaintext, "utf8"),
40
+ cipher.final(),
41
+ ]);
42
+
43
+ const authTag = cipher.getAuthTag();
44
+
45
+ return [
46
+ iv.toString("hex"),
47
+ ciphertext.toString("hex"),
48
+ authTag.toString("hex"),
49
+ ].join(":");
50
+ }
51
+
52
+ /**
53
+ * Decrypts a value produced by `encrypt()`.
54
+ *
55
+ * @param encrypted - Hex-encoded `iv:ciphertext:authTag` string.
56
+ * @param keyHex - 64-char hex string representing a 32-byte key.
57
+ * @returns The original plaintext string.
58
+ * @throws If the format is invalid, the key is wrong, or the auth tag fails.
59
+ */
60
+ export function decrypt(encrypted: string, keyHex: string): string {
61
+ const parts = encrypted.split(":");
62
+ if (parts.length !== 3) {
63
+ throw new Error(
64
+ `Invalid encrypted format: expected "iv:ciphertext:authTag", got ${parts.length} part(s)`,
65
+ );
66
+ }
67
+
68
+ const [ivHex, ciphertextHex, authTagHex] = parts as [string, string, string];
69
+ const key = Buffer.from(keyHex, "hex");
70
+ const iv = Buffer.from(ivHex, "hex");
71
+ const ciphertext = Buffer.from(ciphertextHex, "hex");
72
+ const authTag = Buffer.from(authTagHex, "hex");
73
+
74
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
75
+ decipher.setAuthTag(authTag);
76
+
77
+ const decrypted = Buffer.concat([
78
+ decipher.update(ciphertext),
79
+ decipher.final(),
80
+ ]);
81
+
82
+ return decrypted.toString("utf8");
83
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * admin/src/dev-auth-guard.ts
3
+ *
4
+ * Guard for the dev auto-login route (/admin/dev-login).
5
+ *
6
+ * The dev-login endpoint is an unauthenticated local convenience and must never
7
+ * be reachable in a production deployment. This module exposes a PURE predicate
8
+ * over an injected env object (deterministically unit-testable, no process.env
9
+ * reads). Production is hard-blocked regardless of the ADMIN_DEV_AUTH flag.
10
+ */
11
+
12
+ interface DevAuthGuardEnv {
13
+ ADMIN_DEV_AUTH?: string;
14
+ NODE_ENV?: string;
15
+ }
16
+
17
+ /**
18
+ * Returns true iff dev auto-login is allowed.
19
+ *
20
+ * Allowed iff:
21
+ * - NODE_ENV !== "production" (hard block in prod, ignores ADMIN_DEV_AUTH)
22
+ * - ADMIN_DEV_AUTH === "true"
23
+ */
24
+ export function isDevAuthAllowed(env: DevAuthGuardEnv): boolean {
25
+ if (env.NODE_ENV === "production") {
26
+ return false;
27
+ }
28
+ return env.ADMIN_DEV_AUTH === "true";
29
+ }