@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,135 @@
1
+ /**
2
+ * admin/src/push-toggle.ts
3
+ * The push-notification toggle fragment spliced into the chat thread page.
4
+ *
5
+ * SINGLE EARLY GATE (acceptance criterion 6): when push is disabled server-side
6
+ * (VAPID not fully configured → no public key threaded here), this returns "" —
7
+ * so the thread page degrades to EXACTLY the CFB-3.2 page with no toggle and no
8
+ * partial remnants. Client-side unsupported/denied states are handled inside
9
+ * the fragment's own script, which keeps the button hidden or shows static
10
+ * guidance rather than a broken control.
11
+ *
12
+ * The client script (acceptance criterion 5):
13
+ * - Requests Notification permission ONLY inside the button's click handler,
14
+ * never on load.
15
+ * - If permission is already 'denied', it NEVER calls requestPermission()
16
+ * again — it shows static "enable in browser settings" guidance instead.
17
+ * - On iOS Safari that is NOT standalone, PushManager is undefined not
18
+ * because push is unsupported but because the app isn't installed — so it
19
+ * shows "Add to Home Screen" education, not an error (iOS honesty).
20
+ * - Subscribes via navigator.serviceWorker.ready → pushManager.subscribe.
21
+ */
22
+
23
+ interface PushToggleArgs {
24
+ pushEnabled: boolean;
25
+ vapidPublicKey: string;
26
+ agentId: string;
27
+ threadId: string;
28
+ }
29
+
30
+ export function renderPushToggle(args: PushToggleArgs): string {
31
+ // The single early gate. No public key (partial/absent VAPID) → no toggle.
32
+ if (!args.pushEnabled || !args.vapidPublicKey) return "";
33
+
34
+ const subscribeUrl = `/admin/chat/${encodeURIComponent(
35
+ args.agentId,
36
+ )}/push/subscribe`;
37
+ const unsubscribeUrl = `/admin/chat/${encodeURIComponent(
38
+ args.agentId,
39
+ )}/push/unsubscribe`;
40
+
41
+ return `
42
+ <div class="push-toggle" id="push-toggle" style="margin:8px 0;font-size:13px">
43
+ <button type="button" id="push-toggle-btn" class="btn btn-secondary" style="display:none">Enable notifications</button>
44
+ <span id="push-toggle-status" style="color:#6b7280"></span>
45
+ </div>
46
+ <script>
47
+ (function() {
48
+ var VAPID_PUBLIC_KEY = ${JSON.stringify(args.vapidPublicKey)};
49
+ var SUBSCRIBE_URL = ${JSON.stringify(subscribeUrl)};
50
+ var UNSUBSCRIBE_URL = ${JSON.stringify(unsubscribeUrl)};
51
+ var btn = document.getElementById('push-toggle-btn');
52
+ var status = document.getElementById('push-toggle-status');
53
+ if (!btn || !status) return;
54
+
55
+ function setStatus(text) { status.textContent = text; }
56
+
57
+ function urlBase64ToUint8Array(base64String) {
58
+ var padding = '='.repeat((4 - (base64String.length % 4)) % 4);
59
+ var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
60
+ var raw = atob(base64);
61
+ var out = new Uint8Array(raw.length);
62
+ for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
63
+ return out;
64
+ }
65
+
66
+ var isIOS = /iP(hone|ad|od)/.test(navigator.userAgent);
67
+ var isStandalone = window.matchMedia('(display-mode: standalone)').matches
68
+ || window.navigator.standalone === true;
69
+
70
+ // iOS honesty: PushManager is undefined in a Safari TAB but defined in an
71
+ // installed Home Screen app. Do NOT conflate 'unsupported' with 'not yet
72
+ // installed' — on iOS-and-not-standalone show install education, not an error.
73
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
74
+ if (isIOS && !isStandalone) {
75
+ setStatus('To get notifications, use Share → Add to Home Screen, then open the app.');
76
+ } else {
77
+ setStatus('Notifications are not supported in this browser.');
78
+ }
79
+ return;
80
+ }
81
+
82
+ // Already denied: NEVER re-prompt (AC 5). Show static guidance only.
83
+ if (Notification.permission === 'denied') {
84
+ setStatus('Notifications are blocked. Enable them for this site in your browser settings.');
85
+ return;
86
+ }
87
+
88
+ btn.style.display = 'inline-block';
89
+ setStatus('');
90
+
91
+ // Permission is requested ONLY here, inside the click handler (AC 5).
92
+ btn.addEventListener('click', function() {
93
+ if (Notification.permission === 'denied') {
94
+ setStatus('Notifications are blocked. Enable them for this site in your browser settings.');
95
+ return;
96
+ }
97
+ btn.disabled = true;
98
+ Notification.requestPermission().then(function(perm) {
99
+ if (perm !== 'granted') {
100
+ btn.disabled = false;
101
+ if (perm === 'denied') {
102
+ setStatus('Notifications are blocked. Enable them for this site in your browser settings.');
103
+ } else {
104
+ setStatus('');
105
+ }
106
+ return;
107
+ }
108
+ return navigator.serviceWorker.ready.then(function(reg) {
109
+ return reg.pushManager.subscribe({
110
+ userVisibleOnly: true,
111
+ applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
112
+ });
113
+ }).then(function(subscription) {
114
+ var json = subscription.toJSON();
115
+ return fetch(SUBSCRIBE_URL, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({
119
+ endpoint: subscription.endpoint,
120
+ p256dh: json.keys && json.keys.p256dh,
121
+ auth: json.keys && json.keys.auth,
122
+ }),
123
+ });
124
+ }).then(function() {
125
+ setStatus('Notifications enabled.');
126
+ btn.textContent = 'Notifications on';
127
+ });
128
+ }).catch(function() {
129
+ btn.disabled = false;
130
+ setStatus('');
131
+ });
132
+ });
133
+ })();
134
+ </script>`;
135
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * admin/src/push-toggle.unit.test.ts
3
+ * Unit tests for the push-toggle fragment rendered into the chat thread page.
4
+ *
5
+ * AC 5: the toggle requests permission ONLY inside a click handler; a denied
6
+ * state shows guidance and never re-prompts.
7
+ * AC 6: when push is disabled server-side the fragment is EXACTLY "" — the page
8
+ * degrades to the CFB-3.2 page with no toggle and no remnants.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { renderPushToggle } from "./push-toggle.ts";
13
+
14
+ describe("renderPushToggle — server-side gate (AC 6)", () => {
15
+ it("renders nothing when push is disabled server-side", () => {
16
+ expect(
17
+ renderPushToggle({
18
+ pushEnabled: false,
19
+ vapidPublicKey: "",
20
+ agentId: "a",
21
+ threadId: "t",
22
+ }),
23
+ ).toBe("");
24
+ });
25
+
26
+ it("renders nothing when the vapid public key is missing even if flagged enabled", () => {
27
+ expect(
28
+ renderPushToggle({
29
+ pushEnabled: true,
30
+ vapidPublicKey: "",
31
+ agentId: "a",
32
+ threadId: "t",
33
+ }),
34
+ ).toBe("");
35
+ });
36
+ });
37
+
38
+ describe("renderPushToggle — enabled", () => {
39
+ const html = renderPushToggle({
40
+ pushEnabled: true,
41
+ vapidPublicKey: "BPUBLICKEY",
42
+ agentId: "agt_1",
43
+ threadId: "thr_1",
44
+ });
45
+
46
+ it("embeds the vapid public key for pushManager.subscribe", () => {
47
+ expect(html).toContain("BPUBLICKEY");
48
+ expect(html).toContain("pushManager");
49
+ });
50
+
51
+ it("requests permission ONLY inside a click handler, never on load (AC 5)", () => {
52
+ // requestPermission must be lexically inside an addEventListener('click').
53
+ const clickIdx = html.indexOf("addEventListener('click'");
54
+ const permIdx = html.indexOf("Notification.requestPermission");
55
+ expect(clickIdx).toBeGreaterThan(-1);
56
+ expect(permIdx).toBeGreaterThan(clickIdx);
57
+ // It must NOT be called on page load.
58
+ expect(html).not.toContain("requestPermission();\n"); // no bare top-level call
59
+ });
60
+
61
+ it("guards against re-prompting when permission is already denied (AC 5)", () => {
62
+ expect(html).toContain("'denied'");
63
+ // Guidance text for the denied state.
64
+ expect(html.toLowerCase()).toContain("browser settings");
65
+ });
66
+
67
+ it("distinguishes iOS not-yet-installed from unsupported (iOS honesty)", () => {
68
+ expect(html).toContain("standalone");
69
+ expect(html.toLowerCase()).toContain("add to home screen");
70
+ });
71
+
72
+ it("only touches pushManager after serviceWorker.ready", () => {
73
+ const readyIdx = html.indexOf("serviceWorker.ready");
74
+ const subIdx = html.indexOf("pushManager.subscribe");
75
+ expect(readyIdx).toBeGreaterThan(-1);
76
+ expect(subIdx).toBeGreaterThan(readyIdx);
77
+ });
78
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * admin/src/pwa-https-gating.smoke.test.ts
3
+ * Confirms the manifest <link> and SW-registration <script> tags are
4
+ * emitted site-wide only when appBaseUrl is https — home-lab operators on
5
+ * plain HTTP must not see a broken manifest/SW registration attempt.
6
+ * Exercised via a real rendered page (the login page, which needs no auth)
7
+ * through app.request(), matching the Hono smoke-test convention.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import type { AdminUIDeps } from "./admin-ui.ts";
15
+ import { createAdminUIApp } from "./admin-ui.ts";
16
+ import { PWA_ICONS } from "./pwa.ts";
17
+
18
+ const PNG_1X1 = Buffer.from(
19
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
20
+ "base64",
21
+ );
22
+
23
+ function makeFixtureIconsDir(): string {
24
+ const dir = mkdtempSync(join(tmpdir(), "pwa-icons-gate-"));
25
+ const iconsDir = join(dir, "icons");
26
+ mkdirSync(iconsDir, { recursive: true });
27
+ for (const icon of PWA_ICONS) {
28
+ writeFileSync(join(iconsDir, icon.filename), PNG_1X1);
29
+ }
30
+ return dir;
31
+ }
32
+
33
+ function makeMinimalDeps(overrides: Partial<AdminUIDeps>): AdminUIDeps {
34
+ return {
35
+ prisma: {
36
+ agent: {
37
+ findMany: async () => [],
38
+ findUnique: async () => null,
39
+ create: async () => {
40
+ throw new Error("not implemented");
41
+ },
42
+ update: async () => {
43
+ throw new Error("not implemented");
44
+ },
45
+ delete: async () => {
46
+ throw new Error("not implemented");
47
+ },
48
+ },
49
+ agentEnv: { findMany: async () => [] },
50
+ agentPlugin: { findMany: async () => [] },
51
+ agentMember: {
52
+ findMany: async () => [],
53
+ findUnique: async () => null,
54
+ create: async () => {
55
+ throw new Error("not implemented");
56
+ },
57
+ deleteMany: async () => ({ count: 0 }),
58
+ },
59
+ },
60
+ agentEnvService: {
61
+ getByAgentId: async () => null,
62
+ upsert: async () => {},
63
+ patch: async () => {},
64
+ deleteKey: async () => {},
65
+ getConfigBundle: async () => null,
66
+ },
67
+ agentCronJobService: {
68
+ list: async () => [],
69
+ listWithRunSummary: async () => [],
70
+ get: async () => {
71
+ throw new Error("not implemented");
72
+ },
73
+ create: async () => {
74
+ throw new Error("not implemented");
75
+ },
76
+ setEnabled: async () => {
77
+ throw new Error("not implemented");
78
+ },
79
+ update: async () => {
80
+ throw new Error("not implemented");
81
+ },
82
+ delete: async () => {},
83
+ reconcileSystemCrons: async () => ({
84
+ created: 0,
85
+ updated: 0,
86
+ deleted: 0,
87
+ }),
88
+ },
89
+ agentToolService: {
90
+ list: async () => [],
91
+ add: async () => {
92
+ throw new Error("not implemented");
93
+ },
94
+ toggle: async () => {
95
+ throw new Error("not implemented");
96
+ },
97
+ remove: async () => {},
98
+ },
99
+ agentTokenService: {
100
+ listForAgent: async () => [],
101
+ create: async () => {
102
+ throw new Error("not implemented");
103
+ },
104
+ revoke: async () => {
105
+ throw new Error("not implemented");
106
+ },
107
+ },
108
+ agentPluginService: {
109
+ list: async () => [],
110
+ add: async () => {
111
+ throw new Error("not implemented");
112
+ },
113
+ },
114
+ agentMemberService: {
115
+ listByEmail: async () => [],
116
+ exists: async () => false,
117
+ add: async () => {
118
+ throw new Error("not implemented");
119
+ },
120
+ remove: async () => {},
121
+ listByAgentId: async () => [],
122
+ },
123
+ agentService: {
124
+ listAll: async () => [],
125
+ listByIds: async () => [],
126
+ searchByName: async () => [],
127
+ listOptions: async () => [],
128
+ create: async () => {
129
+ throw new Error("not implemented");
130
+ },
131
+ delete: async () => {},
132
+ getDetail: async () => null,
133
+ updateFields: async () => {
134
+ throw new Error("not implemented");
135
+ },
136
+ },
137
+ provisioner: {
138
+ canProvision: false,
139
+ provision: async () => {
140
+ throw new Error("not implemented");
141
+ },
142
+ deprovision: async () => {},
143
+ reconcile: async () => ({
144
+ recreated: [],
145
+ migrated: [],
146
+ noOwner: [],
147
+ dryRun: false,
148
+ updated: [],
149
+ orphans: [],
150
+ failed: [],
151
+ }),
152
+ },
153
+ serviceIntegrations: [
154
+ {
155
+ name: "task-store",
156
+ listTokensForAgent: async () => [],
157
+ revokeToken: async () => {},
158
+ },
159
+ ],
160
+ chatService: {
161
+ listTokensForAgent: async () => [],
162
+ revokeToken: async () => {},
163
+ deleteThreadsForAgent: async () => ({ deleted: 0 }),
164
+ },
165
+ slack: { deleteApp: async () => {} },
166
+ decrypt: (value: string) => value,
167
+ sessionSecret: "test-admin-session-secret-32-bytes!",
168
+ googleClient: {
169
+ exchangeCode: async () => ({
170
+ accessToken: "t",
171
+ refreshToken: "r",
172
+ expiresIn: 3600,
173
+ }),
174
+ getUserInfo: async () => ({
175
+ sub: "s",
176
+ email: "a@example.com",
177
+ email_verified: true,
178
+ name: "A",
179
+ }),
180
+ },
181
+ googleClientId: "gcid",
182
+ googleClientSecret: "gcsecret",
183
+ adminAllowedEmails: ["admin@example.com"],
184
+ slackClient: {
185
+ createAppManifest: async () => {
186
+ throw new Error("not implemented");
187
+ },
188
+ updateAppManifest: async () => {},
189
+ exchangeOAuthCode: async () => ({ botToken: "xoxb" }),
190
+ authTest: async () => ({ userId: "U0AALR8M69X" }),
191
+ },
192
+ githubAppClient: {
193
+ exchangeManifestCode: async () => {
194
+ throw new Error("not implemented");
195
+ },
196
+ },
197
+ appBaseUrl: "https://example.com",
198
+ ...overrides,
199
+ };
200
+ }
201
+
202
+ describe("PWA head tags — HTTPS gating, applied site-wide via html()", () => {
203
+ it("emits the manifest link + SW registration script when appBaseUrl is https", async () => {
204
+ const app = createAdminUIApp(
205
+ makeMinimalDeps({
206
+ appBaseUrl: "https://admin.example.com",
207
+ pwaAssetsDir: makeFixtureIconsDir(),
208
+ }),
209
+ );
210
+ const res = await app.request("/admin/login");
211
+ const body = await res.text();
212
+ expect(body).toContain('rel="manifest"');
213
+ expect(body).toContain("/admin/manifest.webmanifest");
214
+ expect(body).toContain("serviceWorker");
215
+ });
216
+
217
+ it("omits the manifest link + SW registration script when appBaseUrl is plain http (home-lab)", async () => {
218
+ const app = createAdminUIApp(
219
+ makeMinimalDeps({
220
+ appBaseUrl: "http://localhost:3001",
221
+ pwaAssetsDir: makeFixtureIconsDir(),
222
+ }),
223
+ );
224
+ const res = await app.request("/admin/login");
225
+ const body = await res.text();
226
+ expect(body).not.toContain('rel="manifest"');
227
+ expect(body).not.toContain("serviceWorker");
228
+ });
229
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * admin/src/pwa-icons.unit.test.ts
3
+ * Content assertions on the six committed PWA icon PNGs generated by
4
+ * scripts/build-pwa-icons.ts and checked in under admin/pwa-assets/icons/.
5
+ * Verifies real bytes on disk — not the generation script itself.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { join } from "node:path";
10
+ import { PWA_ICONS, PWA_ASSETS_DIR } from "./pwa.ts";
11
+
12
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13
+ const MAX_ICON_BYTES = 60 * 1024;
14
+
15
+ describe("committed PWA icons", () => {
16
+ it("declares exactly six icons", () => {
17
+ expect(PWA_ICONS.length).toBe(6);
18
+ });
19
+
20
+ for (const icon of PWA_ICONS) {
21
+ describe(icon.filename, () => {
22
+ it("exists on disk", async () => {
23
+ const file = Bun.file(join(PWA_ASSETS_DIR, "icons", icon.filename));
24
+ expect(await file.exists()).toBe(true);
25
+ });
26
+
27
+ it("starts with the PNG magic bytes", async () => {
28
+ const file = Bun.file(join(PWA_ASSETS_DIR, "icons", icon.filename));
29
+ const bytes = new Uint8Array(await file.arrayBuffer());
30
+ const header = Buffer.from(bytes.subarray(0, 8));
31
+ expect(header.equals(PNG_MAGIC)).toBe(true);
32
+ });
33
+
34
+ it("is under 60KB", async () => {
35
+ const file = Bun.file(join(PWA_ASSETS_DIR, "icons", icon.filename));
36
+ expect(file.size).toBeGreaterThan(0);
37
+ expect(file.size).toBeLessThan(MAX_ICON_BYTES);
38
+ });
39
+ });
40
+ }
41
+ });