@agent-native/core 0.90.11 → 0.91.1

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 (322) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +34 -0
  3. package/corpus/core/docs/content/toolkit-settings.mdx +56 -3
  4. package/corpus/core/package.json +2 -1
  5. package/corpus/core/src/agent/run-ownership.ts +29 -1
  6. package/corpus/core/src/changelog/parse.ts +78 -0
  7. package/corpus/core/src/chat-threads/schema.ts +43 -0
  8. package/corpus/core/src/chat-threads/store.ts +144 -12
  9. package/corpus/core/src/cli/design-connect.ts +53 -9
  10. package/corpus/core/src/cli/workspace-dev.ts +25 -5
  11. package/corpus/core/src/client/AgentPanel.tsx +79 -14
  12. package/corpus/core/src/client/AssistantChat.tsx +359 -425
  13. package/corpus/core/src/client/analytics-session.ts +70 -0
  14. package/corpus/core/src/client/analytics.ts +175 -49
  15. package/corpus/core/src/client/chat/message-components.tsx +22 -2
  16. package/corpus/core/src/client/components/ui/message-scroller.tsx +131 -0
  17. package/corpus/core/src/client/conversation/AgentConversation.tsx +63 -41
  18. package/corpus/core/src/client/error-capture.ts +616 -0
  19. package/corpus/core/src/client/index.ts +12 -0
  20. package/corpus/core/src/client/session-replay.ts +57 -7
  21. package/corpus/core/src/client/settings/SettingsPanel.tsx +123 -1
  22. package/corpus/core/src/client/settings/SettingsTabsPage.tsx +292 -45
  23. package/corpus/core/src/client/settings/index.ts +1 -0
  24. package/corpus/core/src/client/sharing/ShareButton.tsx +14 -11
  25. package/corpus/core/src/client/sharing/ShareDialog.tsx +13 -10
  26. package/corpus/core/src/mcp/builtin-tools.ts +29 -2
  27. package/corpus/core/src/server/agent-chat-plugin.ts +174 -79
  28. package/corpus/core/src/server/onboarding-html.ts +28 -16
  29. package/corpus/core/src/sharing/actions/list-resource-shares.ts +45 -1
  30. package/corpus/core/src/sharing/actions/share-resource.ts +9 -0
  31. package/corpus/core/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
  32. package/corpus/core/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
  33. package/corpus/core/src/vite/client.ts +217 -35
  34. package/corpus/templates/analytics/.agents/skills/adhoc-analysis/SKILL.md +8 -7
  35. package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +14 -10
  36. package/corpus/templates/analytics/.agents/skills/bigquery/SKILL.md +15 -15
  37. package/corpus/templates/analytics/.agents/skills/cross-source-analysis/SKILL.md +1 -0
  38. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -22
  39. package/corpus/templates/analytics/.agents/skills/data-programs/SKILL.md +114 -61
  40. package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +1 -0
  41. package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +7 -7
  42. package/corpus/templates/analytics/.agents/skills/hubspot/SKILL.md +1 -0
  43. package/corpus/templates/analytics/.agents/skills/prometheus/SKILL.md +1 -1
  44. package/corpus/templates/analytics/.builder/skills/bigquery/SKILL.md +17 -17
  45. package/corpus/templates/analytics/.builder/skills/gcloud/SKILL.md +5 -5
  46. package/corpus/templates/analytics/.builder/skills/github/SKILL.md +7 -7
  47. package/corpus/templates/analytics/.builder/skills/gong/SKILL.md +12 -12
  48. package/corpus/templates/analytics/.builder/skills/grafana/SKILL.md +7 -7
  49. package/corpus/templates/analytics/.builder/skills/sentry/SKILL.md +7 -7
  50. package/corpus/templates/analytics/.builder/skills/slack/SKILL.md +6 -6
  51. package/corpus/templates/analytics/.builder/skills/stripe/SKILL.md +7 -7
  52. package/corpus/templates/analytics/AGENTS.md +47 -2
  53. package/corpus/templates/analytics/actions/add-status-page-monitor.ts +36 -0
  54. package/corpus/templates/analytics/actions/capture-test-error.ts +33 -0
  55. package/corpus/templates/analytics/actions/delete-monitor.ts +24 -0
  56. package/corpus/templates/analytics/actions/delete-status-page.ts +23 -0
  57. package/corpus/templates/analytics/actions/get-error-issue.ts +36 -0
  58. package/corpus/templates/analytics/actions/get-monitor-stats.ts +55 -0
  59. package/corpus/templates/analytics/actions/get-monitor.ts +27 -0
  60. package/corpus/templates/analytics/actions/get-public-status-page.ts +41 -0
  61. package/corpus/templates/analytics/actions/get-status-page.ts +29 -0
  62. package/corpus/templates/analytics/actions/list-error-issues.ts +40 -0
  63. package/corpus/templates/analytics/actions/list-monitors.ts +21 -0
  64. package/corpus/templates/analytics/actions/list-status-pages.ts +21 -0
  65. package/corpus/templates/analytics/actions/match-error-issues.ts +51 -0
  66. package/corpus/templates/analytics/actions/navigate.ts +58 -4
  67. package/corpus/templates/analytics/actions/remove-status-page-monitor.ts +22 -0
  68. package/corpus/templates/analytics/actions/reorder-status-page-monitors.ts +25 -0
  69. package/corpus/templates/analytics/actions/resolve-error-issue.ts +38 -0
  70. package/corpus/templates/analytics/actions/run-monitor-check.ts +23 -0
  71. package/corpus/templates/analytics/actions/save-analysis.ts +1 -0
  72. package/corpus/templates/analytics/actions/save-monitor.ts +145 -0
  73. package/corpus/templates/analytics/actions/save-status-page.ts +47 -0
  74. package/corpus/templates/analytics/actions/view-screen.ts +212 -0
  75. package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -0
  76. package/corpus/templates/analytics/app/components/layout/Layout.tsx +10 -1
  77. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +134 -39
  78. package/corpus/templates/analytics/app/components/monitoring/PublicStatusView.tsx +351 -0
  79. package/corpus/templates/analytics/app/components/monitoring/ResponseTimeChart.tsx +174 -0
  80. package/corpus/templates/analytics/app/components/monitoring/UptimeStatCards.tsx +85 -0
  81. package/corpus/templates/analytics/app/components/monitoring/UptimeTimelineBars.tsx +106 -0
  82. package/corpus/templates/analytics/app/components/monitoring/chart-utils.ts +101 -0
  83. package/corpus/templates/analytics/app/components/monitoring/index.ts +28 -0
  84. package/corpus/templates/analytics/app/components/monitoring/types.ts +42 -0
  85. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +34 -0
  86. package/corpus/templates/analytics/app/i18n/zh-TW.ts +6 -0
  87. package/corpus/templates/analytics/app/i18n-data.ts +33 -0
  88. package/corpus/templates/analytics/app/pages/DataSources.tsx +28 -0
  89. package/corpus/templates/analytics/app/pages/Settings.tsx +77 -9
  90. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +3 -3
  91. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +7 -0
  92. package/corpus/templates/analytics/app/pages/monitoring/ErrorsPanel.tsx +205 -0
  93. package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +100 -0
  94. package/corpus/templates/analytics/app/pages/monitoring/UptimePanel.tsx +475 -0
  95. package/corpus/templates/analytics/app/pages/monitoring/errors/IssueDetail.tsx +372 -0
  96. package/corpus/templates/analytics/app/pages/monitoring/errors/IssueList.tsx +360 -0
  97. package/corpus/templates/analytics/app/pages/monitoring/errors/Sparkline.tsx +55 -0
  98. package/corpus/templates/analytics/app/pages/monitoring/errors/i18n.ts +106 -0
  99. package/corpus/templates/analytics/app/pages/monitoring/errors/types.ts +75 -0
  100. package/corpus/templates/analytics/app/pages/monitoring/errors/utils.ts +132 -0
  101. package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorDetail.tsx +535 -0
  102. package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorFormPage.tsx +1026 -0
  103. package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorList.tsx +311 -0
  104. package/corpus/templates/analytics/app/pages/monitoring/uptime/i18n.ts +235 -0
  105. package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/StatusPageEditor.tsx +661 -0
  106. package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/StatusPagesView.tsx +355 -0
  107. package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/i18n.ts +106 -0
  108. package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/slug.ts +24 -0
  109. package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/types.ts +20 -0
  110. package/corpus/templates/analytics/app/pages/monitoring/uptime/status-summary.ts +87 -0
  111. package/corpus/templates/analytics/app/pages/monitoring/uptime/types.ts +161 -0
  112. package/corpus/templates/analytics/app/pages/monitoring/uptime/utils.ts +210 -0
  113. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +76 -1
  114. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +74 -9
  115. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +197 -20
  116. package/corpus/templates/analytics/app/root.tsx +33 -1
  117. package/corpus/templates/analytics/app/routes/monitoring._index.tsx +10 -0
  118. package/corpus/templates/analytics/app/routes/status.$slug.tsx +118 -0
  119. package/corpus/templates/analytics/changelog/2026-07-08-add-uptime-monitors-that-ping-your-urls-and-alert-you-when-t.md +6 -0
  120. package/corpus/templates/analytics/changelog/2026-07-08-analytics-chats-keep-large-data-dictionaries-compact-so-prov.md +6 -0
  121. package/corpus/templates/analytics/changelog/2026-07-08-create-public-status-pages-that-share-the-live-health-of-cho.md +6 -0
  122. package/corpus/templates/analytics/changelog/2026-07-08-dashboard-extension-panels-use-clearer-open-copy.md +6 -0
  123. package/corpus/templates/analytics/changelog/2026-07-08-error-capture.md +6 -0
  124. package/corpus/templates/analytics/changelog/2026-07-08-fixed-single-panel-dashboard-drags-so-they-no-longer-rewrite.md +6 -0
  125. package/corpus/templates/analytics/changelog/2026-07-08-jump-straight-from-an-error-in-a-session-recording-to-its-fu.md +6 -0
  126. package/corpus/templates/analytics/changelog/2026-07-08-monitor-detail-and-list-now-show-colorful-uptime-timelines-r.md +6 -0
  127. package/corpus/templates/analytics/changelog/2026-07-08-pinned-ask-chats-now-show-a-pin-icon-in-the-sidebar.md +6 -0
  128. package/corpus/templates/analytics/changelog/2026-07-08-publish-a-public-uptime-status-page-with-colorful-uptime-tim.md +6 -0
  129. package/corpus/templates/analytics/changelog/2026-07-08-sessions-now-hide-visitor-emails-in-demo-mode-and-show-clean.md +6 -0
  130. package/corpus/templates/analytics/changelog/2026-07-08-settings-are-cleaner-searchable-and-alerts-have-their-own-tab.md +5 -0
  131. package/corpus/templates/analytics/changelog/2026-07-08-sidebar-sections-stay-collapsed-until-opened.md +5 -0
  132. package/corpus/templates/analytics/changelog/2026-07-08-the-uptime-monitor-list-now-has-a-current-status-overview-wi.md +6 -0
  133. package/corpus/templates/analytics/docs/error-capture.md +172 -0
  134. package/corpus/templates/analytics/docs/uptime-monitoring.md +150 -0
  135. package/corpus/templates/analytics/server/db/schema-errors.ts +150 -0
  136. package/corpus/templates/analytics/server/db/schema-monitoring.ts +177 -0
  137. package/corpus/templates/analytics/server/db/schema.ts +6 -0
  138. package/corpus/templates/analytics/server/jobs/uptime-monitors.ts +123 -0
  139. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +41 -0
  140. package/corpus/templates/analytics/server/lib/data-dictionary-context.ts +55 -12
  141. package/corpus/templates/analytics/server/lib/error-capture.ts +1331 -0
  142. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +47 -1
  143. package/corpus/templates/analytics/server/lib/monitor-stats.ts +494 -0
  144. package/corpus/templates/analytics/server/lib/status-pages.ts +798 -0
  145. package/corpus/templates/analytics/server/lib/uptime-monitors.ts +1665 -0
  146. package/corpus/templates/analytics/server/plugins/agent-chat.ts +7 -37
  147. package/corpus/templates/analytics/server/plugins/auth.ts +11 -1
  148. package/corpus/templates/analytics/server/plugins/db.ts +408 -0
  149. package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +63 -0
  150. package/corpus/templates/assets/app/routes/_index.tsx +1 -1
  151. package/corpus/templates/assets/app/routes/settings.tsx +22 -1
  152. package/corpus/templates/assets/changelog/2026-07-08-recent-drafts-keep-comfortable-side-padding.md +6 -0
  153. package/corpus/templates/assets/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  154. package/corpus/templates/brain/app/routes/settings.tsx +43 -5
  155. package/corpus/templates/brain/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  156. package/corpus/templates/calendar/app/pages/Settings.tsx +44 -6
  157. package/corpus/templates/calendar/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  158. package/corpus/templates/chat/app/routes/settings.tsx +16 -1
  159. package/corpus/templates/chat/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  160. package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +79 -37
  161. package/corpus/templates/clips/app/components/player/player-controls.tsx +35 -0
  162. package/corpus/templates/clips/app/components/player/video-player.tsx +162 -18
  163. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +63 -6
  164. package/corpus/templates/clips/app/routes/record.tsx +18 -1
  165. package/corpus/templates/clips/changelog/2026-07-07-shared-clips-start-playback-reliably-from-the-first-play-click.md +6 -0
  166. package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-camera-and-microphone-menus-now-show-which-.md +6 -0
  167. package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-copies-new-clip-links-after-saving.md +6 -0
  168. package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-recordings-now-continue-after-granting-came.md +6 -0
  169. package/corpus/templates/clips/changelog/2026-07-08-mobile-shared-clips-now-have-loom-style-tap-and-skip-controls.md +6 -0
  170. package/corpus/templates/clips/changelog/2026-07-08-recordings-and-uploads-copy-links-after-saving.md +6 -0
  171. package/corpus/templates/clips/changelog/2026-07-08-settings-are-cleaner-searchable-and-collapse-s3-with-builder.md +5 -0
  172. package/corpus/templates/clips/changelog/2026-07-08-transcripts-are-more-reliable-for-longer-desktop-recordings-.md +6 -0
  173. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -0
  174. package/corpus/templates/clips/chrome-extension/src/background.ts +162 -13
  175. package/corpus/templates/clips/chrome-extension/src/content-script.ts +14 -0
  176. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +36 -0
  177. package/corpus/templates/clips/chrome-extension/src/overlay.ts +99 -25
  178. package/corpus/templates/clips/chrome-extension/src/permission.ts +63 -14
  179. package/corpus/templates/clips/chrome-extension/src/popup.ts +123 -23
  180. package/corpus/templates/content/actions/_builder-cms-read-client.ts +47 -0
  181. package/corpus/templates/content/actions/_database-source-utils.ts +33 -4
  182. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +214 -29
  183. package/corpus/templates/content/actions/attach-content-database-source.ts +3 -0
  184. package/corpus/templates/content/actions/change-content-database-source-role.ts +1 -0
  185. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +42 -32
  186. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +352 -81
  187. package/corpus/templates/content/app/components/editor/database/settings.tsx +49 -24
  188. package/corpus/templates/content/app/routes/_app.settings.tsx +19 -1
  189. package/corpus/templates/content/changelog/2026-07-07-builder-source-tag-fields-now-import-as-multi-select.md +6 -0
  190. package/corpus/templates/content/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  191. package/corpus/templates/content/shared/api.ts +3 -0
  192. package/corpus/templates/content/shared/properties.ts +42 -0
  193. package/corpus/templates/design/AGENTS.md +9 -0
  194. package/corpus/templates/design/actions/generate-design.ts +3 -0
  195. package/corpus/templates/design/actions/get-design-system.ts +147 -1
  196. package/corpus/templates/design/actions/request-localhost-write-consent.ts +141 -0
  197. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +13 -19
  198. package/corpus/templates/design/app/routes/settings.tsx +16 -1
  199. package/corpus/templates/design/changelog/2026-07-08-design-generation-now-follows-hydrated-builder-design-system.md +6 -0
  200. package/corpus/templates/design/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  201. package/corpus/templates/design/server/lib/verify-write-grant.ts +4 -2
  202. package/corpus/templates/dispatch/app/routes/settings.tsx +23 -2
  203. package/corpus/templates/dispatch/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  204. package/corpus/templates/forms/app/routes/_app.settings.tsx +16 -1
  205. package/corpus/templates/forms/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  206. package/corpus/templates/macros/app/routes/settings.tsx +16 -1
  207. package/corpus/templates/macros/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  208. package/corpus/templates/mail/app/components/settings/GmailFiltersSection.tsx +1 -1
  209. package/corpus/templates/mail/app/components/settings/SnippetsSection.tsx +1 -1
  210. package/corpus/templates/mail/app/pages/SettingsPage.tsx +123 -105
  211. package/corpus/templates/mail/changelog/2026-07-08-settings-redesigned-with-a-consistent-searchable-layout.md +5 -0
  212. package/corpus/templates/plan/app/routes/settings.tsx +23 -2
  213. package/corpus/templates/plan/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  214. package/corpus/templates/slides/actions/add-slide.ts +1 -0
  215. package/corpus/templates/slides/actions/get-design-system.ts +147 -1
  216. package/corpus/templates/slides/app/pages/Index.tsx +46 -1
  217. package/corpus/templates/slides/app/routes/settings.tsx +16 -1
  218. package/corpus/templates/slides/changelog/2026-07-08-deck-generation-now-follows-hydrated-builder-design-system-g.md +6 -0
  219. package/corpus/templates/slides/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
  220. package/dist/agent/run-ownership.d.ts +6 -0
  221. package/dist/agent/run-ownership.d.ts.map +1 -1
  222. package/dist/agent/run-ownership.js +11 -1
  223. package/dist/agent/run-ownership.js.map +1 -1
  224. package/dist/changelog/parse.d.ts +7 -0
  225. package/dist/changelog/parse.d.ts.map +1 -1
  226. package/dist/changelog/parse.js +64 -0
  227. package/dist/changelog/parse.js.map +1 -1
  228. package/dist/chat-threads/schema.d.ts +445 -0
  229. package/dist/chat-threads/schema.d.ts.map +1 -0
  230. package/dist/chat-threads/schema.js +39 -0
  231. package/dist/chat-threads/schema.js.map +1 -0
  232. package/dist/chat-threads/store.d.ts +12 -0
  233. package/dist/chat-threads/store.d.ts.map +1 -1
  234. package/dist/chat-threads/store.js +99 -10
  235. package/dist/chat-threads/store.js.map +1 -1
  236. package/dist/cli/design-connect.d.ts.map +1 -1
  237. package/dist/cli/design-connect.js +40 -10
  238. package/dist/cli/design-connect.js.map +1 -1
  239. package/dist/cli/workspace-dev.d.ts.map +1 -1
  240. package/dist/cli/workspace-dev.js +20 -5
  241. package/dist/cli/workspace-dev.js.map +1 -1
  242. package/dist/client/AgentPanel.d.ts.map +1 -1
  243. package/dist/client/AgentPanel.js +35 -7
  244. package/dist/client/AgentPanel.js.map +1 -1
  245. package/dist/client/AssistantChat.d.ts.map +1 -1
  246. package/dist/client/AssistantChat.js +105 -183
  247. package/dist/client/AssistantChat.js.map +1 -1
  248. package/dist/client/analytics-session.d.ts +3 -0
  249. package/dist/client/analytics-session.d.ts.map +1 -0
  250. package/dist/client/analytics-session.js +66 -0
  251. package/dist/client/analytics-session.js.map +1 -0
  252. package/dist/client/analytics.d.ts +32 -0
  253. package/dist/client/analytics.d.ts.map +1 -1
  254. package/dist/client/analytics.js +116 -46
  255. package/dist/client/analytics.js.map +1 -1
  256. package/dist/client/chat/message-components.d.ts +2 -1
  257. package/dist/client/chat/message-components.d.ts.map +1 -1
  258. package/dist/client/chat/message-components.js +16 -6
  259. package/dist/client/chat/message-components.js.map +1 -1
  260. package/dist/client/components/ui/message-scroller.d.ts +16 -0
  261. package/dist/client/components/ui/message-scroller.d.ts.map +1 -0
  262. package/dist/client/components/ui/message-scroller.js +25 -0
  263. package/dist/client/components/ui/message-scroller.js.map +1 -0
  264. package/dist/client/conversation/AgentConversation.d.ts +1 -1
  265. package/dist/client/conversation/AgentConversation.d.ts.map +1 -1
  266. package/dist/client/conversation/AgentConversation.js +3 -8
  267. package/dist/client/conversation/AgentConversation.js.map +1 -1
  268. package/dist/client/error-capture.d.ts +99 -0
  269. package/dist/client/error-capture.d.ts.map +1 -0
  270. package/dist/client/error-capture.js +437 -0
  271. package/dist/client/error-capture.js.map +1 -0
  272. package/dist/client/index.d.ts +2 -2
  273. package/dist/client/index.d.ts.map +1 -1
  274. package/dist/client/index.js +3 -1
  275. package/dist/client/index.js.map +1 -1
  276. package/dist/client/session-replay.d.ts +22 -0
  277. package/dist/client/session-replay.d.ts.map +1 -1
  278. package/dist/client/session-replay.js +45 -4
  279. package/dist/client/session-replay.js.map +1 -1
  280. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  281. package/dist/client/settings/SettingsPanel.js +100 -5
  282. package/dist/client/settings/SettingsPanel.js.map +1 -1
  283. package/dist/client/settings/SettingsTabsPage.d.ts +49 -1
  284. package/dist/client/settings/SettingsTabsPage.d.ts.map +1 -1
  285. package/dist/client/settings/SettingsTabsPage.js +131 -19
  286. package/dist/client/settings/SettingsTabsPage.js.map +1 -1
  287. package/dist/client/settings/index.d.ts +1 -1
  288. package/dist/client/settings/index.d.ts.map +1 -1
  289. package/dist/client/settings/index.js.map +1 -1
  290. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  291. package/dist/client/sharing/ShareButton.js +13 -8
  292. package/dist/client/sharing/ShareButton.js.map +1 -1
  293. package/dist/client/sharing/ShareDialog.d.ts.map +1 -1
  294. package/dist/client/sharing/ShareDialog.js +12 -7
  295. package/dist/client/sharing/ShareDialog.js.map +1 -1
  296. package/dist/collab/routes.d.ts +1 -1
  297. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  298. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  299. package/dist/mcp/builtin-tools.js +21 -2
  300. package/dist/mcp/builtin-tools.js.map +1 -1
  301. package/dist/observability/routes.d.ts +1 -1
  302. package/dist/org/schema.d.ts +15 -15
  303. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  304. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  305. package/dist/server/agent-chat-plugin.js +109 -73
  306. package/dist/server/agent-chat-plugin.js.map +1 -1
  307. package/dist/server/onboarding-html.d.ts.map +1 -1
  308. package/dist/server/onboarding-html.js +28 -16
  309. package/dist/server/onboarding-html.js.map +1 -1
  310. package/dist/sharing/actions/list-resource-shares.js +30 -1
  311. package/dist/sharing/actions/list-resource-shares.js.map +1 -1
  312. package/dist/sharing/actions/share-resource.js +6 -0
  313. package/dist/sharing/actions/share-resource.js.map +1 -1
  314. package/dist/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
  315. package/dist/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
  316. package/dist/vite/client.d.ts.map +1 -1
  317. package/dist/vite/client.js +192 -30
  318. package/dist/vite/client.js.map +1 -1
  319. package/docs/content/toolkit-settings.mdx +56 -3
  320. package/package.json +2 -1
  321. package/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
  322. package/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
@@ -0,0 +1,1665 @@
1
+ /**
2
+ * Uptime monitoring engine.
3
+ *
4
+ * Mirrors the analytics-alerts engine (server/lib/analytics-alerts.ts) but for
5
+ * synthetic HTTP checks: it pings a user-defined URL on a schedule and alerts
6
+ * when the target is down, returns an unexpected status, is too slow, or its
7
+ * body is missing expected / contains forbidden text.
8
+ *
9
+ * Design notes:
10
+ * - Data lives in the ownable tables defined in ../db/schema-monitoring.ts.
11
+ * Every read/write is scoped by owner_email + org_id (see `ownerWhere`).
12
+ * - Physical table creation + indexes live in the app migration list
13
+ * (server/plugins/db.ts, versions 92+) so the db.spec.ts "every schema
14
+ * column has a migration" guard stays green and boot handles it.
15
+ * - `runMonitorCheck` fetches through an SSRF-safe path: private/loopback/
16
+ * link-local/metadata addresses are blocked (unless an explicit opt-in env
17
+ * flag is set), the scheme must be http/https, redirects are validated per
18
+ * hop, and the response body is capped before text assertions run.
19
+ * - `evaluateAssertions` / `evaluateCheck` / `matchesStatus` are pure and
20
+ * unit-tested (uptime-monitors.spec.ts).
21
+ */
22
+ import { randomUUID } from "node:crypto";
23
+
24
+ import {
25
+ createSsrfSafeDispatcher,
26
+ isBlockedExtensionUrl,
27
+ isBlockedExtensionUrlWithDns,
28
+ } from "@agent-native/core/extensions/url-safety";
29
+ import { notifyWithDelivery } from "@agent-native/core/notifications";
30
+ import { recordChange } from "@agent-native/core/server";
31
+ import {
32
+ and,
33
+ asc,
34
+ count,
35
+ desc,
36
+ eq,
37
+ gte,
38
+ isNull,
39
+ lte,
40
+ or,
41
+ sql,
42
+ } from "drizzle-orm";
43
+
44
+ import { getDb, schema } from "../db/index.js";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Types
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type MonitorMethod =
51
+ | "GET"
52
+ | "HEAD"
53
+ | "POST"
54
+ | "PUT"
55
+ | "PATCH"
56
+ | "DELETE"
57
+ | "OPTIONS";
58
+
59
+ export type MonitorSeverity = "warning" | "critical";
60
+
61
+ /** Runtime status of a monitor / a single check. */
62
+ export type MonitorStatus =
63
+ | "up"
64
+ | "down"
65
+ | "degraded"
66
+ | "error"
67
+ | "unknown"
68
+ | "running";
69
+
70
+ export type AssertionType =
71
+ | "body_contains"
72
+ | "body_absent"
73
+ | "header_contains"
74
+ | "header_equals"
75
+ | "max_latency_ms";
76
+
77
+ export interface Assertion {
78
+ type: AssertionType;
79
+ value: string | number;
80
+ /** Header name for header_* assertions. */
81
+ header?: string;
82
+ }
83
+
84
+ export type StatusMatcher =
85
+ | { mode: "class"; classes: string[] }
86
+ | { mode: "list"; codes: number[] }
87
+ | { mode: "range"; min: number; max: number };
88
+
89
+ export interface MonitorInput {
90
+ id?: string;
91
+ name: string;
92
+ url: string;
93
+ method?: MonitorMethod;
94
+ requestHeaders?: Record<string, string>;
95
+ requestBody?: string | null;
96
+ intervalSeconds?: number;
97
+ timeoutMs?: number;
98
+ expectedStatus?: StatusMatcher;
99
+ assertions?: Assertion[];
100
+ followRedirects?: boolean;
101
+ severity?: MonitorSeverity;
102
+ channels?: string[];
103
+ emailRecipients?: string[];
104
+ cooldownMinutes?: number;
105
+ enabled?: boolean;
106
+ }
107
+
108
+ export interface Monitor {
109
+ id: string;
110
+ name: string;
111
+ url: string;
112
+ method: MonitorMethod;
113
+ requestHeaders: Record<string, string>;
114
+ requestBody: string | null;
115
+ intervalSeconds: number;
116
+ timeoutMs: number;
117
+ expectedStatus: StatusMatcher;
118
+ assertions: Assertion[];
119
+ followRedirects: boolean;
120
+ severity: MonitorSeverity;
121
+ channels: string[];
122
+ emailRecipients: string[];
123
+ cooldownMinutes: number;
124
+ enabled: boolean;
125
+ lastStatus: MonitorStatus | null;
126
+ lastCheckedAt: string | null;
127
+ lastSuccessAt: string | null;
128
+ lastError: string | null;
129
+ lastLatencyMs: number | null;
130
+ lastStatusCode: number | null;
131
+ consecutiveFailures: number;
132
+ createdAt: string;
133
+ updatedAt: string;
134
+ ownerEmail: string;
135
+ orgId: string | null;
136
+ }
137
+
138
+ export interface MonitorUptime {
139
+ uptime24h: number | null;
140
+ uptime7d: number | null;
141
+ checks24h: number;
142
+ }
143
+
144
+ export type MonitorSummary = Monitor & MonitorUptime;
145
+
146
+ export interface MonitorCheckResult {
147
+ id: string;
148
+ monitorId: string;
149
+ checkedAt: string;
150
+ ok: boolean;
151
+ status: MonitorStatus;
152
+ statusCode: number | null;
153
+ latencyMs: number | null;
154
+ error: string | null;
155
+ failedAssertions: string[];
156
+ }
157
+
158
+ export interface MonitorIncident {
159
+ id: string;
160
+ monitorId: string;
161
+ startedAt: string;
162
+ resolvedAt: string | null;
163
+ status: MonitorStatus;
164
+ severity: MonitorSeverity;
165
+ cause: string;
166
+ lastError: string | null;
167
+ notificationId: string | null;
168
+ checksFailed: number;
169
+ createdAt: string;
170
+ }
171
+
172
+ /** Result of a single probe (before persistence). */
173
+ export interface CheckOutcome {
174
+ checkedAt: string;
175
+ status: MonitorStatus;
176
+ ok: boolean;
177
+ statusCode: number | null;
178
+ latencyMs: number | null;
179
+ error: string | null;
180
+ failedAssertions: string[];
181
+ }
182
+
183
+ export interface AssertionFailure {
184
+ type: AssertionType;
185
+ message: string;
186
+ }
187
+
188
+ export interface AccessCtx {
189
+ email: string;
190
+ orgId: string | null;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Constants
195
+ // ---------------------------------------------------------------------------
196
+
197
+ const MAX_RESPONSE_BODY_BYTES = 512 * 1024; // 512 KB read cap for text assertions
198
+ const MAX_REDIRECT_HOPS = 5;
199
+ const MONITOR_RUNNING_STALE_MS = 5 * 60 * 1000;
200
+ const DEFAULT_RESULT_RETENTION_DAYS = 30;
201
+ const DEFAULT_MONITOR_LIMIT_PER_OWNER = 100;
202
+ const MAX_REQUEST_HEADER_COUNT = 20;
203
+ const MAX_REQUEST_HEADER_NAME_LENGTH = 128;
204
+ const MAX_REQUEST_HEADER_VALUE_BYTES = 2048;
205
+ const MAX_REQUEST_BODY_BYTES = 16 * 1024;
206
+ const MAX_ASSERTIONS_PER_MONITOR = 20;
207
+ const MAX_ASSERTION_VALUE_BYTES = 2048;
208
+ const MAX_ASSERTION_HEADER_LENGTH = 128;
209
+
210
+ const MIN_INTERVAL_SECONDS = 30;
211
+ const MAX_INTERVAL_SECONDS = 24 * 60 * 60;
212
+ const MIN_TIMEOUT_MS = 1000;
213
+ const MAX_TIMEOUT_MS = 120_000;
214
+
215
+ const ASSERTION_TYPES: AssertionType[] = [
216
+ "body_contains",
217
+ "body_absent",
218
+ "header_contains",
219
+ "header_equals",
220
+ "max_latency_ms",
221
+ ];
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Small helpers
225
+ // ---------------------------------------------------------------------------
226
+
227
+ function nowIso(): string {
228
+ return new Date().toISOString();
229
+ }
230
+
231
+ function safeJsonParse<T>(raw: unknown, fallback: T): T {
232
+ if (typeof raw !== "string" || !raw.trim()) return fallback;
233
+ try {
234
+ const parsed = JSON.parse(raw) as T;
235
+ return parsed ?? fallback;
236
+ } catch {
237
+ return fallback;
238
+ }
239
+ }
240
+
241
+ function clampInt(value: unknown, min: number, max: number, fallback: number) {
242
+ const normalized = Math.floor(Number(value));
243
+ if (!Number.isFinite(normalized)) return fallback;
244
+ return Math.max(min, Math.min(max, normalized));
245
+ }
246
+
247
+ function boolEnv(name: string): boolean {
248
+ const raw = process.env[name]?.trim().toLowerCase();
249
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
250
+ }
251
+
252
+ function byteLength(value: string): number {
253
+ return new TextEncoder().encode(value).length;
254
+ }
255
+
256
+ /** Opt-in escape hatch for monitoring internal/private hosts (dev, self-host). */
257
+ export function monitorAllowPrivateHosts(): boolean {
258
+ return boolEnv("UPTIME_MONITOR_ALLOW_PRIVATE_HOSTS");
259
+ }
260
+
261
+ function resultRetentionDays(): number {
262
+ const raw = process.env.UPTIME_MONITOR_RESULT_RETENTION_DAYS?.trim();
263
+ if (!raw) return DEFAULT_RESULT_RETENTION_DAYS;
264
+ const parsed = Number.parseInt(raw, 10);
265
+ return Number.isFinite(parsed) && parsed > 0
266
+ ? Math.min(parsed, 365)
267
+ : DEFAULT_RESULT_RETENTION_DAYS;
268
+ }
269
+
270
+ function monitorLimitPerOwner(): number {
271
+ const raw = process.env.UPTIME_MONITOR_LIMIT_PER_OWNER?.trim();
272
+ if (!raw) return DEFAULT_MONITOR_LIMIT_PER_OWNER;
273
+ const parsed = Number.parseInt(raw, 10);
274
+ return Number.isFinite(parsed) && parsed > 0
275
+ ? Math.min(parsed, 1000)
276
+ : DEFAULT_MONITOR_LIMIT_PER_OWNER;
277
+ }
278
+
279
+ export function hostFromUrl(url: string): string {
280
+ try {
281
+ return new URL(url).host;
282
+ } catch {
283
+ return url;
284
+ }
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Normalization / validation
289
+ // ---------------------------------------------------------------------------
290
+
291
+ function badRequest(message: string): Error {
292
+ return Object.assign(new Error(message), { statusCode: 400 });
293
+ }
294
+
295
+ function normalizeName(name: string): string {
296
+ const normalized = (name ?? "").trim();
297
+ if (!normalized) throw badRequest("Monitor name is required");
298
+ return normalized.slice(0, 120);
299
+ }
300
+
301
+ function normalizeUrl(url: string): string {
302
+ const normalized = (url ?? "").trim();
303
+ if (!normalized) throw badRequest("Monitor URL is required");
304
+ let parsed: URL;
305
+ try {
306
+ parsed = new URL(normalized);
307
+ } catch {
308
+ throw badRequest("Monitor URL is not a valid URL");
309
+ }
310
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
311
+ throw badRequest("Monitor URL must use http or https");
312
+ }
313
+ return normalized;
314
+ }
315
+
316
+ function normalizeMethod(method: string | undefined): MonitorMethod {
317
+ const upper = (method ?? "GET").toUpperCase();
318
+ const allowed: MonitorMethod[] = [
319
+ "GET",
320
+ "HEAD",
321
+ "POST",
322
+ "PUT",
323
+ "PATCH",
324
+ "DELETE",
325
+ "OPTIONS",
326
+ ];
327
+ return (allowed as string[]).includes(upper)
328
+ ? (upper as MonitorMethod)
329
+ : "GET";
330
+ }
331
+
332
+ function normalizeHeaders(
333
+ headers: Record<string, string> | undefined,
334
+ ): Record<string, string> {
335
+ const out: Record<string, string> = {};
336
+ if (!headers || typeof headers !== "object") return out;
337
+ for (const [rawKey, rawValue] of Object.entries(headers)) {
338
+ if (Object.keys(out).length >= MAX_REQUEST_HEADER_COUNT) {
339
+ throw badRequest(
340
+ `Monitor request headers are limited to ${MAX_REQUEST_HEADER_COUNT}`,
341
+ );
342
+ }
343
+ const key = String(rawKey).trim();
344
+ if (!key) continue;
345
+ if (key.length > MAX_REQUEST_HEADER_NAME_LENGTH) {
346
+ throw badRequest(
347
+ `Monitor request header names are limited to ${MAX_REQUEST_HEADER_NAME_LENGTH} characters`,
348
+ );
349
+ }
350
+ const value = String(rawValue ?? "");
351
+ if (byteLength(value) > MAX_REQUEST_HEADER_VALUE_BYTES) {
352
+ throw badRequest(
353
+ `Monitor request header values are limited to ${MAX_REQUEST_HEADER_VALUE_BYTES} bytes`,
354
+ );
355
+ }
356
+ out[key] = value;
357
+ }
358
+ return out;
359
+ }
360
+
361
+ export function normalizeStatusMatcher(input: unknown): StatusMatcher {
362
+ const fallback: StatusMatcher = { mode: "class", classes: ["2xx"] };
363
+ if (!input || typeof input !== "object") return fallback;
364
+ const raw = input as Record<string, unknown>;
365
+ if (raw.mode === "list" && Array.isArray(raw.codes)) {
366
+ const codes = raw.codes
367
+ .map((c) => Math.floor(Number(c)))
368
+ .filter((c) => Number.isFinite(c) && c >= 100 && c <= 599);
369
+ return codes.length ? { mode: "list", codes } : fallback;
370
+ }
371
+ if (raw.mode === "range") {
372
+ const min = clampInt(raw.min, 100, 599, 200);
373
+ const max = clampInt(raw.max, 100, 599, 299);
374
+ return { mode: "range", min: Math.min(min, max), max: Math.max(min, max) };
375
+ }
376
+ // Default / mode === "class"
377
+ const classes = Array.isArray(raw.classes)
378
+ ? raw.classes
379
+ .map((c) => String(c).toLowerCase())
380
+ .filter((c) => /^[1-5]xx$/.test(c))
381
+ : [];
382
+ return classes.length ? { mode: "class", classes } : fallback;
383
+ }
384
+
385
+ export function normalizeAssertions(input: unknown): Assertion[] {
386
+ if (!Array.isArray(input)) return [];
387
+ const out: Assertion[] = [];
388
+ for (const raw of input) {
389
+ if (out.length >= MAX_ASSERTIONS_PER_MONITOR) {
390
+ throw badRequest(
391
+ `Monitors are limited to ${MAX_ASSERTIONS_PER_MONITOR} assertions`,
392
+ );
393
+ }
394
+ if (!raw || typeof raw !== "object") continue;
395
+ const entry = raw as Record<string, unknown>;
396
+ const type = String(entry.type ?? "") as AssertionType;
397
+ if (!ASSERTION_TYPES.includes(type)) continue;
398
+ if (type === "max_latency_ms") {
399
+ // A latency budget of 0 (or negative/NaN) is meaningless — drop it
400
+ // rather than clamping up to the floor, which would silently create a
401
+ // "1ms" assertion the user never asked for.
402
+ const raw = Math.floor(Number(entry.value));
403
+ if (!Number.isFinite(raw) || raw <= 0) continue;
404
+ out.push({ type, value: Math.min(raw, 600_000) });
405
+ continue;
406
+ }
407
+ const value = String(entry.value ?? "").trim();
408
+ if (!value) continue;
409
+ if (byteLength(value) > MAX_ASSERTION_VALUE_BYTES) {
410
+ throw badRequest(
411
+ `Monitor assertion values are limited to ${MAX_ASSERTION_VALUE_BYTES} bytes`,
412
+ );
413
+ }
414
+ if (type === "header_contains" || type === "header_equals") {
415
+ const header = String(entry.header ?? "").trim();
416
+ if (!header) continue;
417
+ if (header.length > MAX_ASSERTION_HEADER_LENGTH) {
418
+ throw badRequest(
419
+ `Monitor assertion header names are limited to ${MAX_ASSERTION_HEADER_LENGTH} characters`,
420
+ );
421
+ }
422
+ out.push({ type, value, header });
423
+ continue;
424
+ }
425
+ out.push({ type, value });
426
+ }
427
+ return out;
428
+ }
429
+
430
+ function normalizeChannels(
431
+ channels: string[] | undefined,
432
+ emailRecipients: string[],
433
+ ): string[] {
434
+ const source =
435
+ channels && channels.length
436
+ ? channels
437
+ : emailRecipients.length
438
+ ? ["inbox", "email"]
439
+ : ["inbox"];
440
+ const seen = new Set<string>();
441
+ const normalized: string[] = [];
442
+ for (const raw of source) {
443
+ const channel = String(raw ?? "").trim();
444
+ if (!channel || seen.has(channel)) continue;
445
+ seen.add(channel);
446
+ normalized.push(channel);
447
+ }
448
+ return normalized.length ? normalized : ["inbox"];
449
+ }
450
+
451
+ function normalizeEmailRecipients(recipients: string[] | undefined): string[] {
452
+ const seen = new Set<string>();
453
+ const normalized: string[] = [];
454
+ for (const raw of recipients ?? []) {
455
+ const email = String(raw ?? "")
456
+ .trim()
457
+ .toLowerCase();
458
+ if (!email || seen.has(email)) continue;
459
+ seen.add(email);
460
+ normalized.push(email);
461
+ }
462
+ return normalized;
463
+ }
464
+
465
+ function ensureInboxChannel(channels: string[]): string[] {
466
+ const normalized = channels.map((c) => c.trim()).filter(Boolean);
467
+ return normalized.includes("inbox") ? normalized : ["inbox", ...normalized];
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Pure evaluation core (unit-tested)
472
+ // ---------------------------------------------------------------------------
473
+
474
+ export function matchesStatus(
475
+ statusCode: number | null,
476
+ matcher: StatusMatcher,
477
+ ): boolean {
478
+ if (statusCode == null || !Number.isFinite(statusCode)) return false;
479
+ if (matcher.mode === "list") {
480
+ return matcher.codes.includes(statusCode);
481
+ }
482
+ if (matcher.mode === "range") {
483
+ return statusCode >= matcher.min && statusCode <= matcher.max;
484
+ }
485
+ const cls = `${Math.floor(statusCode / 100)}xx`;
486
+ return matcher.classes.map((c) => c.toLowerCase()).includes(cls);
487
+ }
488
+
489
+ export interface AssertionContext {
490
+ statusCode: number | null;
491
+ latencyMs: number | null;
492
+ bodyText: string;
493
+ headers: Record<string, string>;
494
+ }
495
+
496
+ export function evaluateAssertions(
497
+ assertions: Assertion[],
498
+ ctx: AssertionContext,
499
+ ): AssertionFailure[] {
500
+ const failures: AssertionFailure[] = [];
501
+ for (const assertion of assertions) {
502
+ switch (assertion.type) {
503
+ case "body_contains": {
504
+ const needle = String(assertion.value);
505
+ if (!ctx.bodyText.includes(needle)) {
506
+ failures.push({
507
+ type: assertion.type,
508
+ message: `Body is missing expected text: "${needle}"`,
509
+ });
510
+ }
511
+ break;
512
+ }
513
+ case "body_absent": {
514
+ const needle = String(assertion.value);
515
+ if (ctx.bodyText.includes(needle)) {
516
+ failures.push({
517
+ type: assertion.type,
518
+ message: `Body contains forbidden text: "${needle}"`,
519
+ });
520
+ }
521
+ break;
522
+ }
523
+ case "header_contains": {
524
+ const name = (assertion.header ?? "").toLowerCase();
525
+ const actual = ctx.headers[name];
526
+ const needle = String(assertion.value);
527
+ if (actual == null || !actual.includes(needle)) {
528
+ failures.push({
529
+ type: assertion.type,
530
+ message: `Header "${assertion.header}" does not contain "${needle}"`,
531
+ });
532
+ }
533
+ break;
534
+ }
535
+ case "header_equals": {
536
+ const name = (assertion.header ?? "").toLowerCase();
537
+ const actual = ctx.headers[name];
538
+ const expected = String(assertion.value);
539
+ if (actual !== expected) {
540
+ failures.push({
541
+ type: assertion.type,
542
+ message:
543
+ actual == null
544
+ ? `Header "${assertion.header}" is missing`
545
+ : `Header "${assertion.header}" did not match the expected value`,
546
+ });
547
+ }
548
+ break;
549
+ }
550
+ case "max_latency_ms": {
551
+ const max = Number(assertion.value);
552
+ if (
553
+ ctx.latencyMs != null &&
554
+ Number.isFinite(max) &&
555
+ ctx.latencyMs > max
556
+ ) {
557
+ failures.push({
558
+ type: assertion.type,
559
+ message: `Response took ${ctx.latencyMs}ms (max ${max}ms)`,
560
+ });
561
+ }
562
+ break;
563
+ }
564
+ }
565
+ }
566
+ return failures;
567
+ }
568
+
569
+ export interface EvaluateCheckParams {
570
+ statusCode: number | null;
571
+ latencyMs: number | null;
572
+ bodyText: string;
573
+ headers: Record<string, string>;
574
+ matcher: StatusMatcher;
575
+ assertions: Assertion[];
576
+ /** Non-null when the request could not complete. */
577
+ fetchError?: string | null;
578
+ /** "config" → misconfiguration (status "error"); "network" → down. */
579
+ errorKind?: "config" | "network" | null;
580
+ }
581
+
582
+ /**
583
+ * Pure classifier. Turns a probe's raw signals into a status + failure list:
584
+ * - fetchError present → "error" (config) or "down" (network/timeout)
585
+ * - status mismatch OR a body/header assertion fails → "down"
586
+ * - only a latency assertion fails → "degraded"
587
+ * - otherwise → "up"
588
+ */
589
+ export function evaluateCheck(params: EvaluateCheckParams): {
590
+ status: MonitorStatus;
591
+ ok: boolean;
592
+ failedAssertions: string[];
593
+ } {
594
+ if (params.fetchError) {
595
+ return {
596
+ status: params.errorKind === "config" ? "error" : "down",
597
+ ok: false,
598
+ failedAssertions: [params.fetchError],
599
+ };
600
+ }
601
+
602
+ const messages: string[] = [];
603
+ const statusMatched = matchesStatus(params.statusCode, params.matcher);
604
+ if (!statusMatched) {
605
+ messages.push(`Unexpected status ${params.statusCode ?? "n/a"}`);
606
+ }
607
+
608
+ const assertionFailures = evaluateAssertions(params.assertions, {
609
+ statusCode: params.statusCode,
610
+ latencyMs: params.latencyMs,
611
+ bodyText: params.bodyText,
612
+ headers: params.headers,
613
+ });
614
+ for (const failure of assertionFailures) messages.push(failure.message);
615
+
616
+ const hardFailure =
617
+ !statusMatched ||
618
+ assertionFailures.some((f) => f.type !== "max_latency_ms");
619
+ const latencyFailure = assertionFailures.some(
620
+ (f) => f.type === "max_latency_ms",
621
+ );
622
+
623
+ let status: MonitorStatus = "up";
624
+ if (hardFailure) status = "down";
625
+ else if (latencyFailure) status = "degraded";
626
+
627
+ return { status, ok: status === "up", failedAssertions: messages };
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // SSRF-safe fetch + probe
632
+ // ---------------------------------------------------------------------------
633
+
634
+ async function safeMonitorFetch(
635
+ url: string,
636
+ init: RequestInit,
637
+ opts: {
638
+ followRedirects: boolean;
639
+ maxRedirects: number;
640
+ allowPrivateHosts: boolean;
641
+ },
642
+ ): Promise<Response> {
643
+ const dispatcher = opts.allowPrivateHosts
644
+ ? undefined
645
+ : ((await createSsrfSafeDispatcher()) ?? undefined);
646
+
647
+ let currentUrl = url;
648
+ const maxHops = opts.followRedirects ? opts.maxRedirects : 0;
649
+
650
+ for (let hop = 0; hop <= maxHops; hop++) {
651
+ if (
652
+ !opts.allowPrivateHosts &&
653
+ (await isBlockedExtensionUrlWithDns(currentUrl))
654
+ ) {
655
+ throw new Error(
656
+ `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,
657
+ );
658
+ }
659
+ const fetchOpts: RequestInit & { dispatcher?: unknown } = {
660
+ ...init,
661
+ redirect: "manual",
662
+ };
663
+ if (dispatcher) fetchOpts.dispatcher = dispatcher;
664
+
665
+ const response = await fetch(currentUrl, fetchOpts as RequestInit);
666
+ const isRedirect = response.status >= 300 && response.status < 400;
667
+ if (opts.followRedirects && isRedirect) {
668
+ const location = response.headers.get("location");
669
+ if (!location) return response;
670
+ currentUrl = new URL(location, currentUrl).href;
671
+ continue;
672
+ }
673
+ return response;
674
+ }
675
+ throw new Error(
676
+ `SSRF blocked: too many redirects (>${opts.maxRedirects}) while fetching ${url}`,
677
+ );
678
+ }
679
+
680
+ async function readCappedText(res: Response, cap: number): Promise<string> {
681
+ const body = res.body;
682
+ if (!body) {
683
+ try {
684
+ const text = await res.text();
685
+ return text.length > cap ? text.slice(0, cap) : text;
686
+ } catch {
687
+ return "";
688
+ }
689
+ }
690
+ const reader = body.getReader();
691
+ const chunks: Uint8Array[] = [];
692
+ let total = 0;
693
+ try {
694
+ while (total < cap) {
695
+ const { done, value } = await reader.read();
696
+ if (done) break;
697
+ if (value) {
698
+ chunks.push(value);
699
+ total += value.length;
700
+ }
701
+ }
702
+ } catch {
703
+ // Partial body is fine for text assertions.
704
+ } finally {
705
+ try {
706
+ await reader.cancel();
707
+ } catch {
708
+ // ignore
709
+ }
710
+ }
711
+ const size = Math.min(total, cap);
712
+ const merged = new Uint8Array(size);
713
+ let offset = 0;
714
+ for (const chunk of chunks) {
715
+ if (offset >= size) break;
716
+ const slice =
717
+ chunk.length > size - offset ? chunk.subarray(0, size - offset) : chunk;
718
+ merged.set(slice, offset);
719
+ offset += slice.length;
720
+ }
721
+ return new TextDecoder("utf-8", { fatal: false }).decode(merged);
722
+ }
723
+
724
+ function headersToObject(headers: Headers): Record<string, string> {
725
+ const out: Record<string, string> = {};
726
+ headers.forEach((value, key) => {
727
+ out[key.toLowerCase()] = value;
728
+ });
729
+ return out;
730
+ }
731
+
732
+ /**
733
+ * Execute one probe for `monitor`. Performs an SSRF-safe fetch with an
734
+ * AbortController timeout, measures latency, caps the response body, and
735
+ * classifies the result. Never throws — failures are captured in the outcome.
736
+ */
737
+ export async function runMonitorCheck(
738
+ monitor: Pick<
739
+ Monitor,
740
+ | "url"
741
+ | "method"
742
+ | "requestHeaders"
743
+ | "requestBody"
744
+ | "timeoutMs"
745
+ | "expectedStatus"
746
+ | "assertions"
747
+ | "followRedirects"
748
+ >,
749
+ opts: { allowPrivateHosts?: boolean } = {},
750
+ ): Promise<CheckOutcome> {
751
+ const checkedAt = nowIso();
752
+ const matcher = monitor.expectedStatus;
753
+ const assertions = monitor.assertions;
754
+ const allowPrivateHosts =
755
+ opts.allowPrivateHosts ?? monitorAllowPrivateHosts();
756
+
757
+ // Fast, deterministic pre-flight guard (scheme + literal private hosts).
758
+ if (!allowPrivateHosts && isBlockedExtensionUrl(monitor.url)) {
759
+ return {
760
+ checkedAt,
761
+ statusCode: null,
762
+ latencyMs: null,
763
+ ...evaluateCheck({
764
+ statusCode: null,
765
+ latencyMs: null,
766
+ bodyText: "",
767
+ headers: {},
768
+ matcher,
769
+ assertions,
770
+ fetchError: `SSRF blocked: ${monitor.url} is a private, internal, or non-http(s) address`,
771
+ errorKind: "config",
772
+ }),
773
+ error: "SSRF blocked: private/internal or non-http(s) address",
774
+ };
775
+ }
776
+
777
+ const controller = new AbortController();
778
+ const timeoutMs = clampInt(
779
+ monitor.timeoutMs,
780
+ MIN_TIMEOUT_MS,
781
+ MAX_TIMEOUT_MS,
782
+ 15000,
783
+ );
784
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
785
+ const start = Date.now();
786
+
787
+ try {
788
+ const method = normalizeMethod(monitor.method);
789
+ const hasBody =
790
+ method !== "GET" &&
791
+ method !== "HEAD" &&
792
+ monitor.requestBody != null &&
793
+ monitor.requestBody !== "";
794
+ const init: RequestInit = {
795
+ method,
796
+ headers: monitor.requestHeaders,
797
+ signal: controller.signal,
798
+ body: hasBody ? (monitor.requestBody ?? undefined) : undefined,
799
+ };
800
+
801
+ const response = await safeMonitorFetch(monitor.url, init, {
802
+ followRedirects: monitor.followRedirects,
803
+ maxRedirects: MAX_REDIRECT_HOPS,
804
+ allowPrivateHosts,
805
+ });
806
+
807
+ const bodyText =
808
+ method === "HEAD"
809
+ ? ""
810
+ : await readCappedText(response, MAX_RESPONSE_BODY_BYTES);
811
+ const latencyMs = Date.now() - start;
812
+ const statusCode = response.status;
813
+ const headers = headersToObject(response.headers);
814
+
815
+ const outcome = evaluateCheck({
816
+ statusCode,
817
+ latencyMs,
818
+ bodyText,
819
+ headers,
820
+ matcher,
821
+ assertions,
822
+ });
823
+
824
+ return {
825
+ checkedAt,
826
+ statusCode,
827
+ latencyMs,
828
+ status: outcome.status,
829
+ ok: outcome.ok,
830
+ failedAssertions: outcome.failedAssertions,
831
+ error: outcome.failedAssertions.length
832
+ ? outcome.failedAssertions.join("; ")
833
+ : null,
834
+ };
835
+ } catch (err) {
836
+ const isTimeout =
837
+ (err as { name?: string })?.name === "AbortError" ||
838
+ controller.signal.aborted;
839
+ const message =
840
+ err instanceof Error ? err.message : String(err ?? "check failed");
841
+ const isConfig = message.startsWith("SSRF blocked");
842
+ const errorText = isTimeout
843
+ ? `Timed out after ${timeoutMs}ms`
844
+ : message.slice(0, 500);
845
+ const outcome = evaluateCheck({
846
+ statusCode: null,
847
+ latencyMs: null,
848
+ bodyText: "",
849
+ headers: {},
850
+ matcher,
851
+ assertions,
852
+ fetchError: errorText,
853
+ errorKind: isConfig ? "config" : "network",
854
+ });
855
+ return {
856
+ checkedAt,
857
+ statusCode: null,
858
+ latencyMs: null,
859
+ status: outcome.status,
860
+ ok: outcome.ok,
861
+ failedAssertions: outcome.failedAssertions,
862
+ error: errorText,
863
+ };
864
+ } finally {
865
+ clearTimeout(timer);
866
+ }
867
+ }
868
+
869
+ // ---------------------------------------------------------------------------
870
+ // Row mapping + scoping
871
+ // ---------------------------------------------------------------------------
872
+
873
+ function rowToMonitor(row: any): Monitor {
874
+ return {
875
+ id: row.id,
876
+ name: row.name,
877
+ url: row.url,
878
+ method: normalizeMethod(row.method),
879
+ requestHeaders: safeJsonParse<Record<string, string>>(
880
+ row.requestHeaders,
881
+ {},
882
+ ),
883
+ requestBody: row.requestBody ?? null,
884
+ intervalSeconds: Number(row.intervalSeconds ?? 300),
885
+ timeoutMs: Number(row.timeoutMs ?? 15000),
886
+ expectedStatus: normalizeStatusMatcher(
887
+ safeJsonParse<unknown>(row.expectedStatus, null),
888
+ ),
889
+ assertions: normalizeAssertions(safeJsonParse<unknown>(row.assertions, [])),
890
+ followRedirects: row.followRedirects === true || row.followRedirects === 1,
891
+ severity: row.severity === "warning" ? "warning" : "critical",
892
+ channels: safeJsonParse<string[]>(row.channels, ["inbox"]),
893
+ emailRecipients: safeJsonParse<string[]>(row.emailRecipients, []),
894
+ cooldownMinutes: Number(row.cooldownMinutes ?? 15),
895
+ enabled: row.enabled === true || row.enabled === 1,
896
+ lastStatus: (row.lastStatus ?? null) as MonitorStatus | null,
897
+ lastCheckedAt: row.lastCheckedAt ?? null,
898
+ lastSuccessAt: row.lastSuccessAt ?? null,
899
+ lastError: row.lastError ?? null,
900
+ lastLatencyMs: row.lastLatencyMs ?? null,
901
+ lastStatusCode: row.lastStatusCode ?? null,
902
+ consecutiveFailures: Number(row.consecutiveFailures ?? 0),
903
+ createdAt: row.createdAt,
904
+ updatedAt: row.updatedAt,
905
+ ownerEmail: row.ownerEmail,
906
+ orgId: row.orgId ?? null,
907
+ };
908
+ }
909
+
910
+ function rowToResult(row: any): MonitorCheckResult {
911
+ return {
912
+ id: row.id,
913
+ monitorId: row.monitorId,
914
+ checkedAt: row.checkedAt,
915
+ ok: row.ok === true || row.ok === 1,
916
+ status: (row.status ?? "up") as MonitorStatus,
917
+ statusCode: row.statusCode ?? null,
918
+ latencyMs: row.latencyMs ?? null,
919
+ error: row.error ?? null,
920
+ failedAssertions: safeJsonParse<string[]>(row.failedAssertions, []),
921
+ };
922
+ }
923
+
924
+ function rowToIncident(row: any): MonitorIncident {
925
+ return {
926
+ id: row.id,
927
+ monitorId: row.monitorId,
928
+ startedAt: row.startedAt,
929
+ resolvedAt: row.resolvedAt ?? null,
930
+ status: (row.status ?? "down") as MonitorStatus,
931
+ severity: row.severity === "warning" ? "warning" : "critical",
932
+ cause: row.cause ?? "",
933
+ lastError: row.lastError ?? null,
934
+ notificationId: row.notificationId ?? null,
935
+ checksFailed: Number(row.checksFailed ?? 1),
936
+ createdAt: row.createdAt,
937
+ };
938
+ }
939
+
940
+ function ownerWhere(ctx: AccessCtx, id?: string) {
941
+ const table = schema.monitors;
942
+ const clauses = [
943
+ sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
944
+ ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
945
+ ];
946
+ if (id) clauses.push(eq(table.id, id));
947
+ return and(...clauses);
948
+ }
949
+
950
+ function resultsOwnerWhere(ctx: AccessCtx) {
951
+ const table = schema.monitorCheckResults;
952
+ return and(
953
+ sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
954
+ ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
955
+ );
956
+ }
957
+
958
+ function incidentsOwnerWhere(ctx: AccessCtx) {
959
+ const table = schema.monitorIncidents;
960
+ return and(
961
+ sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
962
+ ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
963
+ );
964
+ }
965
+
966
+ // ---------------------------------------------------------------------------
967
+ // CRUD
968
+ // ---------------------------------------------------------------------------
969
+
970
+ export async function listMonitors(ctx: AccessCtx): Promise<MonitorSummary[]> {
971
+ const db = getDb() as any;
972
+ const rows = await db
973
+ .select()
974
+ .from(schema.monitors)
975
+ .where(ownerWhere(ctx))
976
+ .orderBy(asc(schema.monitors.name));
977
+ const monitors = rows.map(rowToMonitor);
978
+ const uptime = await computeUptime(
979
+ ctx,
980
+ monitors.map((m: Monitor) => m.id),
981
+ );
982
+ return monitors.map((monitor: Monitor) => ({
983
+ ...monitor,
984
+ ...(uptime.get(monitor.id) ?? {
985
+ uptime24h: null,
986
+ uptime7d: null,
987
+ checks24h: 0,
988
+ }),
989
+ }));
990
+ }
991
+
992
+ export async function getMonitor(
993
+ id: string,
994
+ ctx: AccessCtx,
995
+ ): Promise<{
996
+ monitor: MonitorSummary;
997
+ recentResults: MonitorCheckResult[];
998
+ incidents: MonitorIncident[];
999
+ } | null> {
1000
+ const db = getDb() as any;
1001
+ const [row] = await db
1002
+ .select()
1003
+ .from(schema.monitors)
1004
+ .where(ownerWhere(ctx, id));
1005
+ if (!row) return null;
1006
+ const monitor = rowToMonitor(row);
1007
+
1008
+ const [resultRows, incidentRows, uptime] = await Promise.all([
1009
+ db
1010
+ .select()
1011
+ .from(schema.monitorCheckResults)
1012
+ .where(
1013
+ and(
1014
+ resultsOwnerWhere(ctx),
1015
+ eq(schema.monitorCheckResults.monitorId, id),
1016
+ ),
1017
+ )
1018
+ .orderBy(desc(schema.monitorCheckResults.checkedAt))
1019
+ .limit(100),
1020
+ db
1021
+ .select()
1022
+ .from(schema.monitorIncidents)
1023
+ .where(
1024
+ and(
1025
+ incidentsOwnerWhere(ctx),
1026
+ eq(schema.monitorIncidents.monitorId, id),
1027
+ ),
1028
+ )
1029
+ .orderBy(desc(schema.monitorIncidents.startedAt))
1030
+ .limit(50),
1031
+ computeUptime(ctx, [id]),
1032
+ ]);
1033
+
1034
+ return {
1035
+ monitor: {
1036
+ ...monitor,
1037
+ ...(uptime.get(id) ?? {
1038
+ uptime24h: null,
1039
+ uptime7d: null,
1040
+ checks24h: 0,
1041
+ }),
1042
+ },
1043
+ recentResults: resultRows.map(rowToResult),
1044
+ incidents: incidentRows.map(rowToIncident),
1045
+ };
1046
+ }
1047
+
1048
+ async function computeUptime(
1049
+ ctx: AccessCtx,
1050
+ ids: string[],
1051
+ ): Promise<Map<string, MonitorUptime>> {
1052
+ const result = new Map<string, MonitorUptime>();
1053
+ if (ids.length === 0) return result;
1054
+ const db = getDb() as any;
1055
+ const table = schema.monitorCheckResults;
1056
+ const now = Date.now();
1057
+ const since24h = new Date(now - 24 * 60 * 60 * 1000).toISOString();
1058
+ const since7d = new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString();
1059
+
1060
+ const rows = await db
1061
+ .select({
1062
+ monitorId: table.monitorId,
1063
+ total24h: sql<number>`sum(case when ${table.checkedAt} >= ${since24h} then 1 else 0 end)`,
1064
+ ok24h: sql<number>`sum(case when ${table.checkedAt} >= ${since24h} and ${table.ok} then 1 else 0 end)`,
1065
+ total7d: sql<number>`sum(case when ${table.checkedAt} >= ${since7d} then 1 else 0 end)`,
1066
+ ok7d: sql<number>`sum(case when ${table.checkedAt} >= ${since7d} and ${table.ok} then 1 else 0 end)`,
1067
+ })
1068
+ .from(table)
1069
+ .where(and(resultsOwnerWhere(ctx), gte(table.checkedAt, since7d)))
1070
+ .groupBy(table.monitorId);
1071
+
1072
+ const wanted = new Set(ids);
1073
+ for (const row of rows) {
1074
+ if (!wanted.has(row.monitorId)) continue;
1075
+ const total24h = Number(row.total24h ?? 0);
1076
+ const ok24h = Number(row.ok24h ?? 0);
1077
+ const total7d = Number(row.total7d ?? 0);
1078
+ const ok7d = Number(row.ok7d ?? 0);
1079
+ result.set(row.monitorId, {
1080
+ uptime24h: total24h > 0 ? (ok24h / total24h) * 100 : null,
1081
+ uptime7d: total7d > 0 ? (ok7d / total7d) * 100 : null,
1082
+ checks24h: total24h,
1083
+ });
1084
+ }
1085
+ return result;
1086
+ }
1087
+
1088
+ export async function saveMonitor(
1089
+ input: MonitorInput,
1090
+ ctx: AccessCtx,
1091
+ ): Promise<Monitor> {
1092
+ const db = getDb() as any;
1093
+ const updatedAt = nowIso();
1094
+ const id = input.id || randomUUID();
1095
+
1096
+ const name = normalizeName(input.name);
1097
+ const url = normalizeUrl(input.url);
1098
+ const method = normalizeMethod(input.method);
1099
+ const requestHeaders = normalizeHeaders(input.requestHeaders);
1100
+ const requestBody = input.requestBody?.trim() ? input.requestBody : null;
1101
+ if (requestBody && byteLength(requestBody) > MAX_REQUEST_BODY_BYTES) {
1102
+ throw badRequest(
1103
+ `Monitor request bodies are limited to ${MAX_REQUEST_BODY_BYTES} bytes`,
1104
+ );
1105
+ }
1106
+ const intervalSeconds = clampInt(
1107
+ input.intervalSeconds ?? 300,
1108
+ MIN_INTERVAL_SECONDS,
1109
+ MAX_INTERVAL_SECONDS,
1110
+ 300,
1111
+ );
1112
+ const timeoutMs = clampInt(
1113
+ input.timeoutMs ?? 15000,
1114
+ MIN_TIMEOUT_MS,
1115
+ MAX_TIMEOUT_MS,
1116
+ 15000,
1117
+ );
1118
+ const expectedStatus = normalizeStatusMatcher(input.expectedStatus);
1119
+ const assertions = normalizeAssertions(input.assertions);
1120
+ const followRedirects = input.followRedirects ?? true;
1121
+ const severity = input.severity === "warning" ? "warning" : "critical";
1122
+ const emailRecipients = normalizeEmailRecipients(input.emailRecipients);
1123
+ const channels = normalizeChannels(input.channels, emailRecipients);
1124
+ const cooldownMinutes = clampInt(input.cooldownMinutes ?? 15, 0, 24 * 60, 15);
1125
+ const enabled = input.enabled ?? true;
1126
+
1127
+ const shared = {
1128
+ name,
1129
+ url,
1130
+ method,
1131
+ requestHeaders: JSON.stringify(requestHeaders),
1132
+ requestBody,
1133
+ intervalSeconds,
1134
+ timeoutMs,
1135
+ expectedStatus: JSON.stringify(expectedStatus),
1136
+ assertions: JSON.stringify(assertions),
1137
+ followRedirects,
1138
+ severity,
1139
+ channels: JSON.stringify(channels),
1140
+ emailRecipients: JSON.stringify(emailRecipients),
1141
+ cooldownMinutes,
1142
+ enabled,
1143
+ };
1144
+
1145
+ if (input.id) {
1146
+ const existing = await getMonitor(input.id, ctx);
1147
+ if (!existing) {
1148
+ throw Object.assign(new Error("Monitor not found"), { statusCode: 404 });
1149
+ }
1150
+ await db
1151
+ .update(schema.monitors)
1152
+ .set({ ...shared, updatedAt })
1153
+ .where(ownerWhere(ctx, id));
1154
+ } else {
1155
+ const [{ total = 0 } = { total: 0 }] = await db
1156
+ .select({ total: count() })
1157
+ .from(schema.monitors)
1158
+ .where(ownerWhere(ctx));
1159
+ const limit = monitorLimitPerOwner();
1160
+ if (Number(total) >= limit) {
1161
+ throw Object.assign(new Error(`Monitor limit reached (${limit})`), {
1162
+ statusCode: 429,
1163
+ });
1164
+ }
1165
+ await db.insert(schema.monitors).values({
1166
+ id,
1167
+ ...shared,
1168
+ lastStatus: "unknown",
1169
+ consecutiveFailures: 0,
1170
+ createdAt: updatedAt,
1171
+ updatedAt,
1172
+ ownerEmail: ctx.email,
1173
+ orgId: ctx.orgId,
1174
+ });
1175
+ }
1176
+
1177
+ const [row] = await db
1178
+ .select()
1179
+ .from(schema.monitors)
1180
+ .where(ownerWhere(ctx, id));
1181
+ if (!row) throw new Error("Failed to save monitor");
1182
+ const saved = rowToMonitor(row);
1183
+ recordChange({
1184
+ source: "monitors",
1185
+ type: "change",
1186
+ key: saved.id,
1187
+ owner: saved.ownerEmail,
1188
+ orgId: saved.orgId ?? undefined,
1189
+ });
1190
+ return saved;
1191
+ }
1192
+
1193
+ export async function deleteMonitor(id: string, ctx: AccessCtx): Promise<void> {
1194
+ const [row] = await (getDb() as any)
1195
+ .select()
1196
+ .from(schema.monitors)
1197
+ .where(ownerWhere(ctx, id));
1198
+ if (!row) {
1199
+ throw Object.assign(new Error("Monitor not found"), { statusCode: 404 });
1200
+ }
1201
+ const monitor = rowToMonitor(row);
1202
+ const db = getDb() as any;
1203
+ await db.delete(schema.monitors).where(ownerWhere(ctx, id));
1204
+ await db
1205
+ .delete(schema.monitorCheckResults)
1206
+ .where(
1207
+ and(resultsOwnerWhere(ctx), eq(schema.monitorCheckResults.monitorId, id)),
1208
+ );
1209
+ await db
1210
+ .delete(schema.monitorIncidents)
1211
+ .where(
1212
+ and(incidentsOwnerWhere(ctx), eq(schema.monitorIncidents.monitorId, id)),
1213
+ );
1214
+ recordChange({
1215
+ source: "monitors",
1216
+ type: "delete",
1217
+ key: id,
1218
+ owner: monitor.ownerEmail,
1219
+ orgId: monitor.orgId ?? undefined,
1220
+ });
1221
+ }
1222
+
1223
+ // ---------------------------------------------------------------------------
1224
+ // Sweep helpers: claim / due selection
1225
+ // ---------------------------------------------------------------------------
1226
+
1227
+ function monitorNotRunningWhere(now: Date) {
1228
+ const table = schema.monitors;
1229
+ const staleBefore = new Date(
1230
+ now.getTime() - MONITOR_RUNNING_STALE_MS,
1231
+ ).toISOString();
1232
+ return or(
1233
+ isNull(table.lastStatus),
1234
+ sql`${table.lastStatus} <> 'running'`,
1235
+ isNull(table.lastCheckedAt),
1236
+ lte(table.lastCheckedAt, staleBefore),
1237
+ );
1238
+ }
1239
+
1240
+ function monitorPreviousCheckWhere(monitor: Monitor) {
1241
+ const table = schema.monitors;
1242
+ return monitor.lastCheckedAt
1243
+ ? eq(table.lastCheckedAt, monitor.lastCheckedAt)
1244
+ : isNull(table.lastCheckedAt);
1245
+ }
1246
+
1247
+ /** True when enough time has elapsed since the last check to run again. */
1248
+ export function isMonitorDue(
1249
+ monitor: Monitor,
1250
+ now: Date = new Date(),
1251
+ ): boolean {
1252
+ if (!monitor.lastCheckedAt) return true;
1253
+ const last = Date.parse(monitor.lastCheckedAt);
1254
+ if (!Number.isFinite(last)) return true;
1255
+ return now.getTime() - last >= monitor.intervalSeconds * 1000;
1256
+ }
1257
+
1258
+ export async function listDueMonitors(options: {
1259
+ limit: number;
1260
+ ownerEmail?: string;
1261
+ orgId?: string | null;
1262
+ now?: Date;
1263
+ }): Promise<Monitor[]> {
1264
+ const db = getDb() as any;
1265
+ const table = schema.monitors;
1266
+ const now = options.now ?? new Date();
1267
+ const clauses: any[] = [eq(table.enabled, true), monitorNotRunningWhere(now)];
1268
+ if (options.ownerEmail) {
1269
+ clauses.push(
1270
+ sql`lower(${table.ownerEmail}) = ${options.ownerEmail.toLowerCase()}`,
1271
+ );
1272
+ }
1273
+ if (options.orgId !== undefined) {
1274
+ clauses.push(
1275
+ options.orgId ? eq(table.orgId, options.orgId) : isNull(table.orgId),
1276
+ );
1277
+ }
1278
+ const limit = clampInt(options.limit, 1, 500, 100);
1279
+ const rows = await db
1280
+ .select()
1281
+ .from(table)
1282
+ .where(and(...clauses))
1283
+ .orderBy(
1284
+ sql`case when ${table.lastCheckedAt} is null then 0 else 1 end`,
1285
+ asc(table.lastCheckedAt),
1286
+ asc(table.createdAt),
1287
+ )
1288
+ .limit(Math.min(limit * 5, 500));
1289
+ return rows
1290
+ .map(rowToMonitor)
1291
+ .filter((m: Monitor) => isMonitorDue(m, now))
1292
+ .slice(0, limit);
1293
+ }
1294
+
1295
+ /**
1296
+ * Atomically claim a monitor for this sweep so concurrent sweeps don't
1297
+ * double-run it. Mirrors claimAnalyticsAlertRuleEvaluation.
1298
+ */
1299
+ export async function claimMonitorRun(
1300
+ monitor: Monitor,
1301
+ now: Date = new Date(),
1302
+ ): Promise<boolean> {
1303
+ const db = getDb() as any;
1304
+ const table = schema.monitors;
1305
+ const claimedAt = now.toISOString();
1306
+ const rows = await db
1307
+ .update(table)
1308
+ .set({ lastStatus: "running", lastCheckedAt: claimedAt })
1309
+ .where(
1310
+ and(
1311
+ eq(table.id, monitor.id),
1312
+ eq(table.enabled, true),
1313
+ monitorNotRunningWhere(now),
1314
+ monitorPreviousCheckWhere(monitor),
1315
+ ),
1316
+ )
1317
+ .returning({ id: table.id });
1318
+ return rows.length > 0;
1319
+ }
1320
+
1321
+ // ---------------------------------------------------------------------------
1322
+ // Persist result + status
1323
+ // ---------------------------------------------------------------------------
1324
+
1325
+ export async function recordMonitorResult(
1326
+ monitor: Monitor,
1327
+ outcome: CheckOutcome,
1328
+ ): Promise<void> {
1329
+ const db = getDb() as any;
1330
+ await db.insert(schema.monitorCheckResults).values({
1331
+ id: randomUUID(),
1332
+ monitorId: monitor.id,
1333
+ checkedAt: outcome.checkedAt,
1334
+ ok: outcome.ok,
1335
+ status: outcome.status,
1336
+ statusCode: outcome.statusCode,
1337
+ latencyMs: outcome.latencyMs,
1338
+ error: outcome.error,
1339
+ failedAssertions: JSON.stringify(outcome.failedAssertions),
1340
+ createdAt: outcome.checkedAt,
1341
+ ownerEmail: monitor.ownerEmail,
1342
+ orgId: monitor.orgId,
1343
+ });
1344
+
1345
+ const consecutiveFailures = outcome.ok
1346
+ ? 0
1347
+ : (monitor.consecutiveFailures ?? 0) + 1;
1348
+ // Note: no updatedAt bump — status writes must not churn the config
1349
+ // timestamp (mirrors analytics-alerts markRuleStatus).
1350
+ await db
1351
+ .update(schema.monitors)
1352
+ .set({
1353
+ lastStatus: outcome.status,
1354
+ lastCheckedAt: outcome.checkedAt,
1355
+ lastError: outcome.error,
1356
+ lastLatencyMs: outcome.latencyMs,
1357
+ lastStatusCode: outcome.statusCode,
1358
+ lastSuccessAt: outcome.ok ? outcome.checkedAt : monitor.lastSuccessAt,
1359
+ consecutiveFailures,
1360
+ })
1361
+ .where(eq(schema.monitors.id, monitor.id));
1362
+
1363
+ // Emit a change so open UIs invalidate via useDbSync() after each probe —
1364
+ // this is the only sync signal for background-sweep results.
1365
+ recordChange({
1366
+ source: "monitors",
1367
+ type: "change",
1368
+ key: monitor.id,
1369
+ owner: monitor.ownerEmail,
1370
+ orgId: monitor.orgId ?? undefined,
1371
+ });
1372
+ }
1373
+
1374
+ // ---------------------------------------------------------------------------
1375
+ // Incident management + notifications
1376
+ // ---------------------------------------------------------------------------
1377
+
1378
+ function describeCause(outcome: CheckOutcome): string {
1379
+ if (outcome.error) return outcome.error.slice(0, 300);
1380
+ if (outcome.statusCode != null) return `HTTP ${outcome.statusCode}`;
1381
+ return "Check failed";
1382
+ }
1383
+
1384
+ async function getOpenIncident(
1385
+ monitorId: string,
1386
+ ctx: AccessCtx,
1387
+ ): Promise<MonitorIncident | null> {
1388
+ const db = getDb() as any;
1389
+ const [row] = await db
1390
+ .select()
1391
+ .from(schema.monitorIncidents)
1392
+ .where(
1393
+ and(
1394
+ incidentsOwnerWhere(ctx),
1395
+ eq(schema.monitorIncidents.monitorId, monitorId),
1396
+ isNull(schema.monitorIncidents.resolvedAt),
1397
+ ),
1398
+ )
1399
+ .orderBy(desc(schema.monitorIncidents.startedAt))
1400
+ .limit(1);
1401
+ return row ? rowToIncident(row) : null;
1402
+ }
1403
+
1404
+ async function recentlyResolvedWithinCooldown(
1405
+ monitor: Monitor,
1406
+ ctx: AccessCtx,
1407
+ now: Date,
1408
+ ): Promise<boolean> {
1409
+ if (monitor.cooldownMinutes <= 0) return false;
1410
+ const db = getDb() as any;
1411
+ const [row] = await db
1412
+ .select({ resolvedAt: schema.monitorIncidents.resolvedAt })
1413
+ .from(schema.monitorIncidents)
1414
+ .where(
1415
+ and(
1416
+ incidentsOwnerWhere(ctx),
1417
+ eq(schema.monitorIncidents.monitorId, monitor.id),
1418
+ ),
1419
+ )
1420
+ .orderBy(desc(schema.monitorIncidents.startedAt))
1421
+ .limit(1);
1422
+ if (!row?.resolvedAt) return false;
1423
+ const resolved = Date.parse(row.resolvedAt);
1424
+ if (!Number.isFinite(resolved)) return false;
1425
+ return now.getTime() - resolved < monitor.cooldownMinutes * 60 * 1000;
1426
+ }
1427
+
1428
+ async function notifyMonitorDown(monitor: Monitor, outcome: CheckOutcome) {
1429
+ const host = hostFromUrl(monitor.url);
1430
+ const label = outcome.status === "degraded" ? "degraded" : "down";
1431
+ const severity: "warning" | "critical" =
1432
+ outcome.status === "degraded" ? "warning" : monitor.severity;
1433
+ const detail = outcome.error ? ` — ${outcome.error}` : "";
1434
+ const latency =
1435
+ outcome.latencyMs != null ? ` Latency ${outcome.latencyMs}ms.` : "";
1436
+ return notifyWithDelivery(
1437
+ {
1438
+ severity,
1439
+ title: `Monitor ${label}: ${monitor.name}`,
1440
+ body: `${host} is ${label}${detail}.${latency}`,
1441
+ channels: ensureInboxChannel(monitor.channels),
1442
+ metadata: {
1443
+ kind: "uptime_monitor",
1444
+ monitorId: monitor.id,
1445
+ monitorName: monitor.name,
1446
+ url: monitor.url,
1447
+ status: outcome.status,
1448
+ statusCode: outcome.statusCode,
1449
+ latencyMs: outcome.latencyMs,
1450
+ failedAssertions: outcome.failedAssertions,
1451
+ emailRecipients: monitor.emailRecipients,
1452
+ requestedChannels: monitor.channels,
1453
+ },
1454
+ },
1455
+ { owner: monitor.ownerEmail },
1456
+ );
1457
+ }
1458
+
1459
+ async function notifyMonitorRecovered(
1460
+ monitor: Monitor,
1461
+ outcome: CheckOutcome,
1462
+ incident: MonitorIncident,
1463
+ ) {
1464
+ const host = hostFromUrl(monitor.url);
1465
+ const startedMs = Date.parse(incident.startedAt);
1466
+ const downFor = Number.isFinite(startedMs)
1467
+ ? humanizeDuration(Date.parse(outcome.checkedAt) - startedMs)
1468
+ : null;
1469
+ const latency =
1470
+ outcome.latencyMs != null ? ` Latency ${outcome.latencyMs}ms.` : "";
1471
+ return notifyWithDelivery(
1472
+ {
1473
+ severity: "info",
1474
+ title: `Monitor recovered: ${monitor.name}`,
1475
+ body: `${host} is back up${downFor ? ` after ${downFor} of downtime` : ""}.${latency}`,
1476
+ channels: ensureInboxChannel(monitor.channels),
1477
+ metadata: {
1478
+ kind: "uptime_monitor",
1479
+ monitorId: monitor.id,
1480
+ monitorName: monitor.name,
1481
+ url: monitor.url,
1482
+ status: "up",
1483
+ statusCode: outcome.statusCode,
1484
+ latencyMs: outcome.latencyMs,
1485
+ incidentId: incident.id,
1486
+ emailRecipients: monitor.emailRecipients,
1487
+ requestedChannels: monitor.channels,
1488
+ },
1489
+ },
1490
+ { owner: monitor.ownerEmail },
1491
+ );
1492
+ }
1493
+
1494
+ function humanizeDuration(ms: number): string {
1495
+ if (!Number.isFinite(ms) || ms < 0) return "";
1496
+ const minutes = Math.round(ms / 60000);
1497
+ if (minutes < 1) return "less than a minute";
1498
+ if (minutes < 60) return `${minutes}m`;
1499
+ const hours = Math.floor(minutes / 60);
1500
+ const rem = minutes % 60;
1501
+ if (hours < 24) return rem ? `${hours}h ${rem}m` : `${hours}h`;
1502
+ const days = Math.floor(hours / 24);
1503
+ return `${days}d ${hours % 24}h`;
1504
+ }
1505
+
1506
+ export interface EvaluateMonitorResult {
1507
+ status: MonitorStatus;
1508
+ incidentId?: string;
1509
+ notified: boolean;
1510
+ recovered?: boolean;
1511
+ }
1512
+
1513
+ /**
1514
+ * Open / update / resolve incidents based on the latest outcome and send
1515
+ * notifications. On a transition into failure it opens an incident and
1516
+ * notifies (respecting an anti-flap cooldown); on recovery it resolves the
1517
+ * open incident and sends a recovery notice.
1518
+ */
1519
+ export async function evaluateAndNotifyMonitor(
1520
+ monitor: Monitor,
1521
+ outcome: CheckOutcome,
1522
+ ctx: AccessCtx,
1523
+ now: Date = new Date(),
1524
+ ): Promise<EvaluateMonitorResult> {
1525
+ const db = getDb() as any;
1526
+ const open = await getOpenIncident(monitor.id, ctx);
1527
+
1528
+ if (!outcome.ok) {
1529
+ const cause = describeCause(outcome);
1530
+ if (open) {
1531
+ const nextStatus =
1532
+ open.status === "down" ? "down" : (outcome.status as MonitorStatus);
1533
+ await db
1534
+ .update(schema.monitorIncidents)
1535
+ .set({
1536
+ checksFailed: (open.checksFailed ?? 1) + 1,
1537
+ lastError: outcome.error,
1538
+ cause,
1539
+ status: nextStatus,
1540
+ })
1541
+ .where(eq(schema.monitorIncidents.id, open.id));
1542
+ return { status: outcome.status, incidentId: open.id, notified: false };
1543
+ }
1544
+
1545
+ const suppressed = await recentlyResolvedWithinCooldown(monitor, ctx, now);
1546
+ let notificationId: string | undefined;
1547
+ if (!suppressed) {
1548
+ try {
1549
+ const delivery = await notifyMonitorDown(monitor, outcome);
1550
+ notificationId = delivery.notification?.id;
1551
+ } catch (err) {
1552
+ console.error(
1553
+ `[uptime-monitors] notify failed for ${monitor.id}:`,
1554
+ err,
1555
+ );
1556
+ }
1557
+ }
1558
+ const incidentId = randomUUID();
1559
+ await db.insert(schema.monitorIncidents).values({
1560
+ id: incidentId,
1561
+ monitorId: monitor.id,
1562
+ startedAt: outcome.checkedAt,
1563
+ resolvedAt: null,
1564
+ status: outcome.status === "degraded" ? "degraded" : "down",
1565
+ severity: monitor.severity,
1566
+ cause,
1567
+ lastError: outcome.error,
1568
+ notificationId: notificationId ?? null,
1569
+ checksFailed: 1,
1570
+ createdAt: outcome.checkedAt,
1571
+ ownerEmail: monitor.ownerEmail,
1572
+ orgId: monitor.orgId,
1573
+ });
1574
+ return {
1575
+ status: outcome.status,
1576
+ incidentId,
1577
+ notified: Boolean(notificationId),
1578
+ };
1579
+ }
1580
+
1581
+ // Recovery.
1582
+ if (open) {
1583
+ await db
1584
+ .update(schema.monitorIncidents)
1585
+ .set({ resolvedAt: outcome.checkedAt })
1586
+ .where(eq(schema.monitorIncidents.id, open.id));
1587
+ let notified = false;
1588
+ try {
1589
+ await notifyMonitorRecovered(monitor, outcome, open);
1590
+ notified = true;
1591
+ } catch (err) {
1592
+ console.error(
1593
+ `[uptime-monitors] recovery notify failed for ${monitor.id}:`,
1594
+ err,
1595
+ );
1596
+ }
1597
+ return { status: "up", incidentId: open.id, notified, recovered: true };
1598
+ }
1599
+ return { status: "up", notified: false };
1600
+ }
1601
+
1602
+ /**
1603
+ * Run one monitor end-to-end: probe, persist the result + status, then
1604
+ * open/resolve incidents and notify. Used by the sweep job and the on-demand
1605
+ * run-monitor-check action.
1606
+ */
1607
+ export async function runAndProcessMonitor(
1608
+ monitor: Monitor,
1609
+ ctx: AccessCtx,
1610
+ opts: { allowPrivateHosts?: boolean } = {},
1611
+ ): Promise<CheckOutcome> {
1612
+ const outcome = await runMonitorCheck(monitor, opts);
1613
+ // recordMonitorResult() emits the "monitors" change for the UI.
1614
+ await recordMonitorResult(monitor, outcome);
1615
+ await evaluateAndNotifyMonitor(monitor, outcome, ctx);
1616
+ return outcome;
1617
+ }
1618
+
1619
+ /**
1620
+ * Run one check now for a specific monitor id (on-demand). Returns the outcome
1621
+ * and the refreshed monitor detail.
1622
+ */
1623
+ export async function runMonitorNow(
1624
+ id: string,
1625
+ ctx: AccessCtx,
1626
+ ): Promise<CheckOutcome> {
1627
+ const [row] = await (getDb() as any)
1628
+ .select()
1629
+ .from(schema.monitors)
1630
+ .where(ownerWhere(ctx, id));
1631
+ if (!row) {
1632
+ throw Object.assign(new Error("Monitor not found"), { statusCode: 404 });
1633
+ }
1634
+ const monitor = rowToMonitor(row);
1635
+ const claimed = await claimMonitorRun(monitor);
1636
+ if (!claimed) {
1637
+ throw Object.assign(new Error("Monitor check is already running"), {
1638
+ statusCode: 409,
1639
+ });
1640
+ }
1641
+ return runAndProcessMonitor(monitor, ctx);
1642
+ }
1643
+
1644
+ // ---------------------------------------------------------------------------
1645
+ // Retention
1646
+ // ---------------------------------------------------------------------------
1647
+
1648
+ /**
1649
+ * Delete check results older than the retention window so the table can't grow
1650
+ * unbounded. This is a global maintenance prune by age (not a per-user read),
1651
+ * so it intentionally isn't owner-scoped.
1652
+ */
1653
+ export async function pruneOldCheckResults(
1654
+ now: Date = new Date(),
1655
+ ): Promise<number> {
1656
+ const cutoff = new Date(
1657
+ now.getTime() - resultRetentionDays() * 24 * 60 * 60 * 1000,
1658
+ ).toISOString();
1659
+ const db = getDb() as any;
1660
+ const deleted = await db
1661
+ .delete(schema.monitorCheckResults)
1662
+ .where(lte(schema.monitorCheckResults.checkedAt, cutoff))
1663
+ .returning({ id: schema.monitorCheckResults.id });
1664
+ return Array.isArray(deleted) ? deleted.length : 0;
1665
+ }