@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,213 @@
1
+ /**
2
+ * admin/src/push-content.unit.test.ts
3
+ * Unit tests for the notification-content policy: detail-level resolution
4
+ * (server ceiling vs. per-subscription opt-in) and the payload builder.
5
+ *
6
+ * The security-critical assertion (acceptance criterion 1) is that NO payload
7
+ * at ANY detail level ever leaks an agentId, threadId, repo name, or file path.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import {
12
+ type PushDetailLevel,
13
+ type SessionNotificationKind,
14
+ buildNotificationPayload,
15
+ buildSessionNotificationPayload,
16
+ resolveDetailLevel,
17
+ } from "./push-content.ts";
18
+
19
+ describe("resolveDetailLevel", () => {
20
+ it("returns the min of the operator ceiling and the user opt-in", () => {
21
+ expect(resolveDetailLevel("preview", "preview")).toBe("preview");
22
+ expect(resolveDetailLevel("title", "preview")).toBe("title");
23
+ expect(resolveDetailLevel("preview", "title")).toBe("title");
24
+ expect(resolveDetailLevel("generic", "preview")).toBe("generic");
25
+ expect(resolveDetailLevel("preview", "generic")).toBe("generic");
26
+ expect(resolveDetailLevel("title", "title")).toBe("title");
27
+ expect(resolveDetailLevel("generic", "generic")).toBe("generic");
28
+ });
29
+
30
+ it("defaults an unknown/undefined ceiling to the operator default (title)", () => {
31
+ expect(resolveDetailLevel(undefined, "preview")).toBe("title");
32
+ expect(resolveDetailLevel("bogus" as PushDetailLevel, "preview")).toBe(
33
+ "title",
34
+ );
35
+ });
36
+
37
+ it("defaults an unknown/undefined opt-in to the safest level (generic)", () => {
38
+ expect(resolveDetailLevel("preview", undefined)).toBe("generic");
39
+ expect(resolveDetailLevel("preview", "bogus" as PushDetailLevel)).toBe(
40
+ "generic",
41
+ );
42
+ });
43
+ });
44
+
45
+ describe("buildNotificationPayload — content per level", () => {
46
+ const thread = {
47
+ threadId: "thr_abc123",
48
+ agentId: "agt_secret",
49
+ title: "Refactor the billing exporter",
50
+ preview:
51
+ "The billing exporter needs to page through acme-corp/finance-svc and rewrite src/export/ledger.ts before the deploy.",
52
+ };
53
+
54
+ it("generic: no body, generic title, no thread title", () => {
55
+ const p = buildNotificationPayload("generic", thread);
56
+ expect(p.title).toBe("Your agent replied");
57
+ expect(p.body).toBe("");
58
+ });
59
+
60
+ it("title: adds the thread title, still no preview body", () => {
61
+ const p = buildNotificationPayload("title", thread);
62
+ expect(p.body).toContain("Refactor the billing exporter");
63
+ });
64
+
65
+ it("preview: adds up to 120 chars of preview", () => {
66
+ const p = buildNotificationPayload("preview", thread);
67
+ expect(p.body.length).toBeLessThanOrEqual(120);
68
+ expect(p.body.length).toBeGreaterThan(0);
69
+ });
70
+
71
+ it("carries a url that deep-links the thread (the ONLY place ids may live)", () => {
72
+ const p = buildNotificationPayload("title", thread);
73
+ expect(p.url).toContain(thread.threadId);
74
+ });
75
+ });
76
+
77
+ describe("buildNotificationPayload — NEVER leaks sensitive tokens (AC 1)", () => {
78
+ // A notification renders on a locked phone, possibly in public. The visible
79
+ // fields (title, body) must never contain an agentId, threadId, repo name,
80
+ // or file path — at ANY detail level.
81
+ const thread = {
82
+ threadId: "thr_LEAKING_THREAD_ID",
83
+ agentId: "agt_LEAKING_AGENT_ID",
84
+ title: "Ship agt_LEAKING_AGENT_ID for acme-corp/finance-svc",
85
+ preview:
86
+ "Working thr_LEAKING_THREAD_ID on acme-corp/finance-svc touching src/export/ledger.ts costs $4.20",
87
+ };
88
+
89
+ const forbidden = [
90
+ thread.agentId,
91
+ thread.threadId,
92
+ "acme-corp/finance-svc",
93
+ "src/export/ledger.ts",
94
+ ];
95
+
96
+ const levels: PushDetailLevel[] = ["generic", "title", "preview"];
97
+
98
+ for (const level of levels) {
99
+ it(`level "${level}": visible fields contain no id/repo/path token`, () => {
100
+ const p = buildNotificationPayload(level, thread);
101
+ const visible = `${p.title}\n${p.body}`;
102
+ for (const token of forbidden) {
103
+ expect(visible).not.toContain(token);
104
+ }
105
+ });
106
+ }
107
+ });
108
+
109
+ describe("buildSessionNotificationPayload — kind x level matrix", () => {
110
+ const session = {
111
+ slug: "refactor-billing-exporter",
112
+ title: "Refactor the billing exporter",
113
+ reason:
114
+ "The billing exporter needs to page through several upstream services and rewrite the ledger export logic before the deploy window closes tonight.",
115
+ };
116
+
117
+ const kinds: SessionNotificationKind[] = [
118
+ "immediate",
119
+ "reminder",
120
+ "completed",
121
+ ];
122
+ const levels: PushDetailLevel[] = ["generic", "title", "preview"];
123
+
124
+ const expectedTitle: Record<SessionNotificationKind, string> = {
125
+ immediate: "A session needs you",
126
+ reminder: "Still waiting on you",
127
+ completed: "Session completed",
128
+ };
129
+
130
+ for (const kind of kinds) {
131
+ for (const level of levels) {
132
+ it(`kind "${kind}" level "${level}": correct title/body/url/tag/kind`, () => {
133
+ const p = buildSessionNotificationPayload(level, kind, session);
134
+
135
+ expect(p.title).toBe(expectedTitle[kind]);
136
+ expect(p.kind).toBe(kind);
137
+ expect(p.url).toBe(
138
+ `/admin/sessions/${encodeURIComponent(session.slug)}`,
139
+ );
140
+ expect(p.tag).toBe(`shipwright-session-${session.slug}`);
141
+
142
+ if (level === "generic") {
143
+ expect(p.body).toBe("");
144
+ } else if (level === "title") {
145
+ expect(p.body).toBe(session.title);
146
+ } else {
147
+ expect(p.body.length).toBeLessThanOrEqual(120);
148
+ expect(p.body.length).toBeGreaterThan(0);
149
+ expect(session.reason.startsWith(p.body)).toBe(true);
150
+ }
151
+ });
152
+ }
153
+ }
154
+
155
+ it("falls back to title when reason is missing at preview level", () => {
156
+ const p = buildSessionNotificationPayload("preview", "immediate", {
157
+ slug: "no-reason",
158
+ title: "Fallback title",
159
+ });
160
+ expect(p.body).toBe("Fallback title");
161
+ });
162
+
163
+ it("falls back to empty body when both title and reason are missing", () => {
164
+ const p = buildSessionNotificationPayload("title", "reminder", {
165
+ slug: "bare-session",
166
+ });
167
+ expect(p.body).toBe("");
168
+
169
+ const preview = buildSessionNotificationPayload("preview", "reminder", {
170
+ slug: "bare-session",
171
+ title: null,
172
+ reason: null,
173
+ });
174
+ expect(preview.body).toBe("");
175
+ });
176
+
177
+ it("truncates a reason longer than 120 chars at preview level", () => {
178
+ const longReason = "x".repeat(200);
179
+ const p = buildSessionNotificationPayload("preview", "completed", {
180
+ slug: "long-reason",
181
+ reason: longReason,
182
+ });
183
+ expect(p.body.length).toBe(120);
184
+ });
185
+ });
186
+
187
+ describe("buildSessionNotificationPayload — NEVER leaks sensitive tokens (AC 2)", () => {
188
+ const session = {
189
+ slug: "leaky-session",
190
+ title: "Ship agt_LEAKING_AGENT_ID for acme-corp/finance-svc",
191
+ reason:
192
+ "Working thr_LEAKING_THREAD_ID on acme-corp/finance-svc touching src/export/ledger.ts costs $4.20",
193
+ };
194
+
195
+ const forbidden = [
196
+ "agt_LEAKING_AGENT_ID",
197
+ "thr_LEAKING_THREAD_ID",
198
+ "acme-corp/finance-svc",
199
+ "src/export/ledger.ts",
200
+ ];
201
+
202
+ const levels: PushDetailLevel[] = ["generic", "title", "preview"];
203
+
204
+ for (const level of levels) {
205
+ it(`level "${level}": visible fields contain no id/repo/path token`, () => {
206
+ const p = buildSessionNotificationPayload(level, "immediate", session);
207
+ const visible = `${p.title}\n${p.body}`;
208
+ for (const token of forbidden) {
209
+ expect(visible).not.toContain(token);
210
+ }
211
+ });
212
+ }
213
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * admin/src/push-crypto.ts
3
+ * Hand-rolled RFC 8291 (Message Encryption for Web Push, "aes128gcm") and the
4
+ * primitives RFC 8292 (VAPID) needs, implemented on Bun's node:crypto.
5
+ *
6
+ * WHY HAND-ROLLED (not the `web-push` npm package): a ~20-line spike confirmed
7
+ * Bun's node:crypto supports the entire path — ECDH P-256, HKDF-SHA256,
8
+ * AES-128-GCM, and ES256 (ECDSA P-256, `dsaEncoding: "ieee-p1363"` → the raw
9
+ * 64-byte JWT signature). The web-push dependency drags in a large tree and has
10
+ * historically been finicky under non-Node runtimes for ~150 lines of
11
+ * well-specified crypto. Correctness here is pinned to the RFC 8291 Appendix A
12
+ * test vector in push-crypto.unit.test.ts, so drift fails CI.
13
+ *
14
+ * Pure module: no Hono, no Prisma, no network. The actual HTTP send lives in
15
+ * push-sender.ts.
16
+ */
17
+
18
+ import { createCipheriv, createECDH, hkdfSync, randomBytes } from "node:crypto";
19
+
20
+ // ─── base64url ────────────────────────────────────────────────────────────────
21
+
22
+ export function base64UrlEncode(bytes: Uint8Array): string {
23
+ return Buffer.from(bytes).toString("base64url");
24
+ }
25
+
26
+ export function base64UrlDecode(str: string): Uint8Array {
27
+ return new Uint8Array(Buffer.from(str, "base64url"));
28
+ }
29
+
30
+ // ─── RFC 8291 aes128gcm encryption ───────────────────────────────────────────
31
+
32
+ interface EncryptPushPayloadArgs {
33
+ /** UTF-8 plaintext to encrypt. */
34
+ plaintext: string;
35
+ /** UA public key (base64url, uncompressed P-256 point) — the `p256dh` value. */
36
+ uaPublicKey: string;
37
+ /** UA auth secret (base64url, 16 bytes) — the `auth` value. */
38
+ uaAuthSecret: string;
39
+ /**
40
+ * Optional fixed server ephemeral private key (base64url raw scalar) — used
41
+ * ONLY to reproduce the RFC's test vector. Omitted in production, where a
42
+ * fresh ephemeral keypair is generated per message.
43
+ */
44
+ serverPrivateKey?: string;
45
+ /** Optional fixed 16-byte salt (base64url) — used ONLY for the test vector. */
46
+ salt?: string;
47
+ }
48
+
49
+ const HKDF_HASH = "sha256";
50
+
51
+ function hkdf(
52
+ salt: Uint8Array,
53
+ ikm: Uint8Array,
54
+ info: Uint8Array,
55
+ length: number,
56
+ ): Uint8Array {
57
+ return new Uint8Array(hkdfSync(HKDF_HASH, ikm, salt, info, length));
58
+ }
59
+
60
+ function infoLabel(label: string, ...parts: Uint8Array[]): Uint8Array {
61
+ const chunks = [Buffer.from(`Content-Encoding: ${label}\0`, "ascii")];
62
+ for (const p of parts) chunks.push(Buffer.from(p));
63
+ return new Uint8Array(Buffer.concat(chunks));
64
+ }
65
+
66
+ /**
67
+ * Encrypts `plaintext` per RFC 8291 §3-4 producing the full aes128gcm body:
68
+ * header = salt(16) || rs(4, big-endian) || idlen(1) || serverPublicKey(65)
69
+ * body = header || AES-128-GCM(cek, nonce, plaintext || 0x02)
70
+ *
71
+ * The 0x02 delimiter marks the last (and here only) record.
72
+ */
73
+ export function encryptPushPayload(args: EncryptPushPayloadArgs): Uint8Array {
74
+ const uaPublic = base64UrlDecode(args.uaPublicKey);
75
+ const authSecret = base64UrlDecode(args.uaAuthSecret);
76
+
77
+ const ecdh = createECDH("prime256v1");
78
+ if (args.serverPrivateKey) {
79
+ ecdh.setPrivateKey(Buffer.from(base64UrlDecode(args.serverPrivateKey)));
80
+ } else {
81
+ ecdh.generateKeys();
82
+ }
83
+ const serverPublic = new Uint8Array(ecdh.getPublicKey()); // 65 bytes, uncompressed
84
+ const sharedSecret = new Uint8Array(
85
+ ecdh.computeSecret(Buffer.from(uaPublic)),
86
+ );
87
+
88
+ const salt = args.salt
89
+ ? base64UrlDecode(args.salt)
90
+ : new Uint8Array(randomBytes(16));
91
+
92
+ // Step 1: derive the pseudo-random key (PRK) from the ECDH secret, keyed by
93
+ // the auth secret. info = "WebPush: info\0" || ua_public || server_public.
94
+ const prkInfo = new Uint8Array(
95
+ Buffer.concat([
96
+ Buffer.from("WebPush: info\0", "ascii"),
97
+ Buffer.from(uaPublic),
98
+ Buffer.from(serverPublic),
99
+ ]),
100
+ );
101
+ const prk = hkdf(authSecret, sharedSecret, prkInfo, 32);
102
+
103
+ // Step 2: derive the content encryption key and nonce from the PRK, salted.
104
+ const cek = hkdf(salt, prk, infoLabel("aes128gcm"), 16);
105
+ const nonce = hkdf(salt, prk, infoLabel("nonce"), 12);
106
+
107
+ // Step 3: AES-128-GCM over (plaintext || 0x02 delimiter).
108
+ const cipher = createCipheriv(
109
+ "aes-128-gcm",
110
+ Buffer.from(cek),
111
+ Buffer.from(nonce),
112
+ );
113
+ const padded = Buffer.concat([
114
+ Buffer.from(args.plaintext, "utf-8"),
115
+ Buffer.from([0x02]),
116
+ ]);
117
+ const encrypted = Buffer.concat([
118
+ cipher.update(padded),
119
+ cipher.final(),
120
+ cipher.getAuthTag(),
121
+ ]);
122
+
123
+ // Step 4: assemble the aes128gcm content-coding header + ciphertext.
124
+ const rs = Buffer.alloc(4);
125
+ rs.writeUInt32BE(4096, 0); // record size
126
+ const idlen = Buffer.from([serverPublic.length]); // 65
127
+ const body = Buffer.concat([
128
+ Buffer.from(salt),
129
+ rs,
130
+ idlen,
131
+ Buffer.from(serverPublic),
132
+ encrypted,
133
+ ]);
134
+ return new Uint8Array(body);
135
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * admin/src/push-crypto.unit.test.ts
3
+ * Validates the hand-rolled RFC 8291 (aes128gcm Web Push encryption) and
4
+ * RFC 8292 (VAPID) primitives against the specs' published test vectors.
5
+ *
6
+ * The web-push npm package was spiked under Bun (see push-sender.ts header)
7
+ * and the crypto path works, but the dependency is heavy for ~150 lines of
8
+ * well-specified crypto — so we hand-roll and pin correctness to the RFC's
9
+ * own vectors here.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import {
14
+ base64UrlDecode,
15
+ base64UrlEncode,
16
+ encryptPushPayload,
17
+ } from "./push-crypto.ts";
18
+
19
+ describe("base64url", () => {
20
+ it("round-trips arbitrary bytes with no padding", () => {
21
+ const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]);
22
+ const encoded = base64UrlEncode(bytes);
23
+ expect(encoded).not.toContain("=");
24
+ expect(encoded).not.toContain("+");
25
+ expect(encoded).not.toContain("/");
26
+ expect(Array.from(base64UrlDecode(encoded))).toEqual(Array.from(bytes));
27
+ });
28
+ });
29
+
30
+ describe("encryptPushPayload — RFC 8291 Appendix A test vector", () => {
31
+ // RFC 8291 §5 "Push Message Encryption Example". All values are base64url.
32
+ const plaintext = "When I grow up, I want to be a watermelon";
33
+ const uaPublic =
34
+ "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
35
+ const uaAuth = "BTBZMqHH6r4Tts7J_aSIgg";
36
+ // The RFC fixes the server's ephemeral keypair and salt so the ciphertext is
37
+ // reproducible; the implementation must accept them to be vector-testable.
38
+ const serverPrivate = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw";
39
+ const salt = "DGv6ra1nlYgDCS1FRnbzlw";
40
+
41
+ const expectedBody =
42
+ "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
43
+
44
+ it("produces the exact ciphertext body from the RFC's fixed inputs", () => {
45
+ const body = encryptPushPayload({
46
+ plaintext,
47
+ uaPublicKey: uaPublic,
48
+ uaAuthSecret: uaAuth,
49
+ serverPrivateKey: serverPrivate,
50
+ salt,
51
+ });
52
+ expect(base64UrlEncode(body)).toBe(expectedBody);
53
+ });
54
+
55
+ it("decrypts back only with the right keys (self-consistency)", () => {
56
+ // Different salt/server key → different ciphertext (sanity, not a vector).
57
+ const body = encryptPushPayload({
58
+ plaintext,
59
+ uaPublicKey: uaPublic,
60
+ uaAuthSecret: uaAuth,
61
+ });
62
+ expect(body.length).toBeGreaterThan(plaintext.length);
63
+ // aes128gcm header is: salt(16) || rs(4) || idlen(1) || keyid(65) = 86 bytes
64
+ expect(body.length).toBeGreaterThan(86);
65
+ });
66
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * admin/src/push-sender.ts
3
+ * PushSender — sends an encrypted Web Push message to one or many
4
+ * subscriptions. Constructed with an injected `fetchImpl` (acceptance
5
+ * criterion 3): NO global.fetch override, so sibling test suites in Bun's
6
+ * shared process stay clean.
7
+ *
8
+ * Depends on the hand-rolled RFC 8291 encryption (push-crypto.ts) and signs the
9
+ * VAPID JWT (RFC 8292) here with ES256 via node:crypto. See push-crypto.ts's
10
+ * header for why we hand-roll rather than depend on the `web-push` npm package.
11
+ *
12
+ * Delivery contract:
13
+ * - 201/2xx → delivered (ok=true).
14
+ * - 404/410 → the subscription is gone; prune it IMMEDIATELY (AC 4).
15
+ * - anything else / network throw → failure, but NOT pruned (transient).
16
+ * A failure for one subscription never blocks delivery to the others.
17
+ */
18
+
19
+ import { createPrivateKey, createSign } from "node:crypto";
20
+ import {
21
+ base64UrlDecode,
22
+ base64UrlEncode,
23
+ encryptPushPayload,
24
+ } from "./push-crypto.ts";
25
+
26
+ export interface VapidConfig {
27
+ publicKey: string;
28
+ privateKey: string;
29
+ subject: string; // "mailto:..." or an https URL
30
+ }
31
+
32
+ export interface PushSubscriptionLike {
33
+ endpoint: string;
34
+ p256dh: string;
35
+ auth: string;
36
+ }
37
+
38
+ interface SendResult {
39
+ ok: boolean;
40
+ /** True when the subscription should be pruned (404/410). */
41
+ pruned: boolean;
42
+ status?: number;
43
+ }
44
+
45
+ /**
46
+ * Push is enabled only when public + private + subject are ALL set. A partial
47
+ * config must NOT half-enable the feature — routes 503 and the UI renders no
48
+ * toggle so local-first still holds.
49
+ */
50
+ export function isPushEnabled(v: VapidConfig | undefined): v is VapidConfig {
51
+ return Boolean(v?.publicKey && v.privateKey && v.subject);
52
+ }
53
+
54
+ const ONE_HOUR = 60 * 60;
55
+ const TTL_SECONDS = 4 * ONE_HOUR;
56
+
57
+ export class PushSender {
58
+ constructor(
59
+ private readonly vapid: VapidConfig,
60
+ private readonly fetchImpl: typeof fetch,
61
+ private readonly now: () => number = () => Date.now(),
62
+ ) {}
63
+
64
+ /** Builds the `Authorization: vapid ...` header value for an endpoint origin. */
65
+ private buildVapidAuthHeader(endpoint: string): string {
66
+ const audience = new URL(endpoint).origin;
67
+ const header = { typ: "JWT", alg: "ES256" };
68
+ const nowSec = Math.floor(this.now() / 1000);
69
+ const claims = {
70
+ aud: audience,
71
+ exp: nowSec + TTL_SECONDS,
72
+ sub: this.vapid.subject,
73
+ };
74
+ const signingInput = `${base64UrlEncode(
75
+ Buffer.from(JSON.stringify(header)),
76
+ )}.${base64UrlEncode(Buffer.from(JSON.stringify(claims)))}`;
77
+
78
+ // ES256: ECDSA over P-256 with SHA-256, raw (ieee-p1363) 64-byte signature.
79
+ const key = privateKeyFromVapid(
80
+ this.vapid.privateKey,
81
+ this.vapid.publicKey,
82
+ );
83
+ const signature = createSign("SHA256")
84
+ .update(signingInput)
85
+ .sign({ key, dsaEncoding: "ieee-p1363" });
86
+ const jwt = `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
87
+ return `vapid t=${jwt}, k=${this.vapid.publicKey}`;
88
+ }
89
+
90
+ /** Sends one payload to one subscription. Never throws — returns a SendResult. */
91
+ async send(sub: PushSubscriptionLike, payload: string): Promise<SendResult> {
92
+ let body: Uint8Array;
93
+ try {
94
+ body = encryptPushPayload({
95
+ plaintext: payload,
96
+ uaPublicKey: sub.p256dh,
97
+ uaAuthSecret: sub.auth,
98
+ });
99
+ } catch {
100
+ // A malformed subscription key can't be encrypted for — treat as prune.
101
+ return { ok: false, pruned: true };
102
+ }
103
+
104
+ try {
105
+ const res = await this.fetchImpl(sub.endpoint, {
106
+ method: "POST",
107
+ headers: {
108
+ authorization: this.buildVapidAuthHeader(sub.endpoint),
109
+ "content-encoding": "aes128gcm",
110
+ "content-type": "application/octet-stream",
111
+ ttl: String(TTL_SECONDS),
112
+ },
113
+ // A Uint8Array is a valid fetch body at runtime; the DOM lib's
114
+ // BodyInit type omits it, so assert through the shared BufferSource.
115
+ body: body as unknown as BodyInit,
116
+ });
117
+ const pruned = res.status === 404 || res.status === 410;
118
+ return { ok: res.ok, pruned, status: res.status };
119
+ } catch {
120
+ // Network error — transient, don't prune.
121
+ return { ok: false, pruned: false };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Fans a payload out to many subscriptions. One failure never blocks the
127
+ * rest (Promise.allSettled). Returns the endpoints that must be pruned.
128
+ */
129
+ async sendToMany(
130
+ subs: PushSubscriptionLike[],
131
+ payload: string,
132
+ ): Promise<{ prunedEndpoints: string[]; delivered: number }> {
133
+ const results = await Promise.allSettled(
134
+ subs.map((s) => this.send(s, payload)),
135
+ );
136
+ const prunedEndpoints: string[] = [];
137
+ let delivered = 0;
138
+ results.forEach((r, i) => {
139
+ if (r.status === "fulfilled") {
140
+ if (r.value.ok) delivered += 1;
141
+ if (r.value.pruned) prunedEndpoints.push(subs[i].endpoint);
142
+ }
143
+ });
144
+ return { prunedEndpoints, delivered };
145
+ }
146
+ }
147
+
148
+ // ─── VAPID key import ────────────────────────────────────────────────────────
149
+
150
+ // Import the VAPID keypair from its base64url raw forms via a JWK — the most
151
+ // portable path across node:crypto implementations (avoids hand-rolling SEC1/
152
+ // PKCS#8 DER, which Bun's OpenSSL rejects). The public key is the uncompressed
153
+ // P-256 point (0x04 || x(32) || y(32)); the private key is the raw scalar `d`.
154
+ function privateKeyFromVapid(
155
+ base64urlPrivate: string,
156
+ base64urlPublic: string,
157
+ ) {
158
+ const pub = base64UrlDecode(base64urlPublic);
159
+ if (pub.length !== 65 || pub[0] !== 0x04) {
160
+ throw new Error("VAPID public key must be an uncompressed P-256 point");
161
+ }
162
+ const x = base64UrlEncode(pub.slice(1, 33));
163
+ const y = base64UrlEncode(pub.slice(33, 65));
164
+ const d = base64UrlEncode(base64UrlDecode(base64urlPrivate));
165
+ return createPrivateKey({
166
+ key: { kty: "EC", crv: "P-256", x, y, d },
167
+ format: "jwk",
168
+ });
169
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * admin/src/push-sender.unit.test.ts
3
+ * Unit tests for PushSender — VAPID gating, injected fetchImpl (no global
4
+ * override, AC 3), and immediate pruning on 404/410 (AC 4).
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import { PushSender, isPushEnabled } from "./push-sender.ts";
9
+
10
+ // A minimal valid-shaped subscription. Keys are the RFC 8291 vector's UA keys
11
+ // so the encryption path exercises real values.
12
+ const sub = {
13
+ endpoint: "https://push.example.com/sub/abc",
14
+ p256dh:
15
+ "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
16
+ auth: "BTBZMqHH6r4Tts7J_aSIgg",
17
+ };
18
+
19
+ // A real, matched VAPID keypair (P-256). The public key is the uncompressed
20
+ // point (0x04 || x || y); the private key is the raw scalar `d`.
21
+ const vapid = {
22
+ publicKey:
23
+ "BAl-hYOXgpZwx-qqRoAapu5iSeHrlcJdy89wjv1NyctfsYSyGKCVO97vvlOVR4h61MROU8CZLAxzQEj8vIFsTaI",
24
+ privateKey: "Sl9AT1gAl_yFgHlTToZkarQRWNYHVTAviDf5osjxXN0",
25
+ subject: "mailto:ops@example.com",
26
+ };
27
+
28
+ describe("isPushEnabled", () => {
29
+ it("is true only when public + private + subject are all set", () => {
30
+ expect(isPushEnabled(vapid)).toBe(true);
31
+ });
32
+
33
+ it("is false when any of the three is missing", () => {
34
+ expect(isPushEnabled({ ...vapid, publicKey: "" })).toBe(false);
35
+ expect(isPushEnabled({ ...vapid, privateKey: "" })).toBe(false);
36
+ expect(isPushEnabled({ ...vapid, subject: "" })).toBe(false);
37
+ expect(isPushEnabled(undefined)).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe("PushSender.send — injected fetchImpl (AC 3)", () => {
42
+ it("POSTs to the subscription endpoint using the injected fetch, never a global", async () => {
43
+ const calls: Array<{ url: string; init: RequestInit }> = [];
44
+ const fakeFetch = async (url: string, init: RequestInit) => {
45
+ calls.push({ url, init });
46
+ return new Response(null, { status: 201 });
47
+ };
48
+ const sender = new PushSender(vapid, fakeFetch as unknown as typeof fetch);
49
+ const result = await sender.send(sub, "hello world");
50
+ expect(calls.length).toBe(1);
51
+ expect(calls[0].url).toBe(sub.endpoint);
52
+ expect(calls[0].init.method).toBe("POST");
53
+ // VAPID auth header + aes128gcm content encoding are mandatory.
54
+ const headers = new Headers(calls[0].init.headers);
55
+ expect(headers.get("authorization") ?? "").toContain("vapid");
56
+ expect(headers.get("content-encoding")).toBe("aes128gcm");
57
+ expect(result.pruned).toBe(false);
58
+ expect(result.ok).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("PushSender.send — pruning on 404/410 (AC 4)", () => {
63
+ it("flags the subscription for pruning on 410 Gone", async () => {
64
+ const fakeFetch = async () => new Response(null, { status: 410 });
65
+ const sender = new PushSender(vapid, fakeFetch as unknown as typeof fetch);
66
+ const result = await sender.send(sub, "x");
67
+ expect(result.pruned).toBe(true);
68
+ expect(result.ok).toBe(false);
69
+ });
70
+
71
+ it("flags the subscription for pruning on 404 Not Found", async () => {
72
+ const fakeFetch = async () => new Response(null, { status: 404 });
73
+ const sender = new PushSender(vapid, fakeFetch as unknown as typeof fetch);
74
+ const result = await sender.send(sub, "x");
75
+ expect(result.pruned).toBe(true);
76
+ });
77
+
78
+ it("does NOT prune on a transient 500 — just reports failure", async () => {
79
+ const fakeFetch = async () => new Response(null, { status: 500 });
80
+ const sender = new PushSender(vapid, fakeFetch as unknown as typeof fetch);
81
+ const result = await sender.send(sub, "x");
82
+ expect(result.pruned).toBe(false);
83
+ expect(result.ok).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("PushSender.sendToMany — one failure never blocks the others", () => {
88
+ it("delivers to every subscription and collects prune targets", async () => {
89
+ const seen: string[] = [];
90
+ const fakeFetch = async (url: string) => {
91
+ seen.push(url);
92
+ // The middle one is gone; the others succeed.
93
+ if (url.endsWith("/gone")) return new Response(null, { status: 410 });
94
+ if (url.endsWith("/boom")) throw new Error("network down");
95
+ return new Response(null, { status: 201 });
96
+ };
97
+ const sender = new PushSender(vapid, fakeFetch as unknown as typeof fetch);
98
+ const subs = [
99
+ { ...sub, endpoint: "https://push.example.com/ok" },
100
+ { ...sub, endpoint: "https://push.example.com/gone" },
101
+ { ...sub, endpoint: "https://push.example.com/boom" },
102
+ ];
103
+ const { prunedEndpoints } = await sender.sendToMany(subs, "payload");
104
+ expect(seen.length).toBe(3); // all attempted despite the failures
105
+ expect(prunedEndpoints).toEqual(["https://push.example.com/gone"]);
106
+ });
107
+ });