@raybase/core 0.0.2 → 0.3.4

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 (435) hide show
  1. package/LICENSE +21 -0
  2. package/dist/.admin-build/AdminApp.css +2 -0
  3. package/dist/.admin-build/AdminApp.js +77 -0
  4. package/dist/.admin-docs-build/AdminDocsApp.css +2 -0
  5. package/dist/.admin-docs-build/AdminDocsApp.js +45 -0
  6. package/dist/actions.d.ts +347 -0
  7. package/dist/actions.d.ts.map +1 -0
  8. package/dist/actions.js +661 -0
  9. package/dist/actions.js.map +1 -0
  10. package/dist/admin-assets/admin.7738f50720a8ed7b.css +2 -0
  11. package/dist/admin-assets/admin.8678cb61db34dc81.js +77 -0
  12. package/dist/admin-boot.d.ts +94 -0
  13. package/dist/admin-boot.d.ts.map +1 -0
  14. package/dist/admin-boot.js +139 -0
  15. package/dist/admin-boot.js.map +1 -0
  16. package/dist/admin-docs-content.d.ts +95 -0
  17. package/dist/admin-docs-content.d.ts.map +1 -0
  18. package/dist/admin-docs-content.js +463 -0
  19. package/dist/admin-docs-content.js.map +1 -0
  20. package/dist/admin-docs-ui.d.ts +11 -0
  21. package/dist/admin-docs-ui.d.ts.map +1 -0
  22. package/dist/admin-docs-ui.js +23 -0
  23. package/dist/admin-docs-ui.js.map +1 -0
  24. package/dist/admin-kit.d.ts +29 -0
  25. package/dist/admin-kit.js +1557 -0
  26. package/dist/admin-role.d.ts +8 -0
  27. package/dist/admin-role.d.ts.map +1 -0
  28. package/dist/admin-role.js +22 -0
  29. package/dist/admin-role.js.map +1 -0
  30. package/dist/admin-ui.d.ts +3 -0
  31. package/dist/admin-ui.d.ts.map +1 -0
  32. package/dist/admin-ui.js +16 -0
  33. package/dist/admin-ui.js.map +1 -0
  34. package/dist/agent-ai-chat.d.ts +127 -0
  35. package/dist/agent-ai-chat.d.ts.map +1 -0
  36. package/dist/agent-ai-chat.js +5975 -0
  37. package/dist/agent-ai-chat.js.map +1 -0
  38. package/dist/agent-chapter-archive.d.ts +214 -0
  39. package/dist/agent-chapter-archive.d.ts.map +1 -0
  40. package/dist/agent-chapter-archive.js +696 -0
  41. package/dist/agent-chapter-archive.js.map +1 -0
  42. package/dist/agent-chapter-search.d.ts +116 -0
  43. package/dist/agent-chapter-search.d.ts.map +1 -0
  44. package/dist/agent-chapter-search.js +605 -0
  45. package/dist/agent-chapter-search.js.map +1 -0
  46. package/dist/agent-chapter-tools.d.ts +45 -0
  47. package/dist/agent-chapter-tools.d.ts.map +1 -0
  48. package/dist/agent-chapter-tools.js +344 -0
  49. package/dist/agent-chapter-tools.js.map +1 -0
  50. package/dist/agent-chapters.d.ts +228 -0
  51. package/dist/agent-chapters.d.ts.map +1 -0
  52. package/dist/agent-chapters.js +737 -0
  53. package/dist/agent-chapters.js.map +1 -0
  54. package/dist/agent-data-parts.d.ts +167 -0
  55. package/dist/agent-data-parts.d.ts.map +1 -0
  56. package/dist/agent-data-parts.js +68 -0
  57. package/dist/agent-data-parts.js.map +1 -0
  58. package/dist/agent-errors.d.ts +41 -0
  59. package/dist/agent-errors.d.ts.map +1 -0
  60. package/dist/agent-errors.js +191 -0
  61. package/dist/agent-errors.js.map +1 -0
  62. package/dist/agent-follow-up-store.d.ts +273 -0
  63. package/dist/agent-follow-up-store.d.ts.map +1 -0
  64. package/dist/agent-follow-up-store.js +703 -0
  65. package/dist/agent-follow-up-store.js.map +1 -0
  66. package/dist/agent-follow-ups.d.ts +314 -0
  67. package/dist/agent-follow-ups.d.ts.map +1 -0
  68. package/dist/agent-follow-ups.js +840 -0
  69. package/dist/agent-follow-ups.js.map +1 -0
  70. package/dist/agent-memory.d.ts +323 -0
  71. package/dist/agent-memory.d.ts.map +1 -0
  72. package/dist/agent-memory.js +1270 -0
  73. package/dist/agent-memory.js.map +1 -0
  74. package/dist/agent-message-parts.d.ts +58 -0
  75. package/dist/agent-message-parts.d.ts.map +1 -0
  76. package/dist/agent-message-parts.js +163 -0
  77. package/dist/agent-message-parts.js.map +1 -0
  78. package/dist/agent-remote-mcp-credentials.d.ts +154 -0
  79. package/dist/agent-remote-mcp-credentials.d.ts.map +1 -0
  80. package/dist/agent-remote-mcp-credentials.js +287 -0
  81. package/dist/agent-remote-mcp-credentials.js.map +1 -0
  82. package/dist/agent-remote-mcp-crypto.d.ts +32 -0
  83. package/dist/agent-remote-mcp-crypto.d.ts.map +1 -0
  84. package/dist/agent-remote-mcp-crypto.js +101 -0
  85. package/dist/agent-remote-mcp-crypto.js.map +1 -0
  86. package/dist/agent-remote-mcp-install.d.ts +299 -0
  87. package/dist/agent-remote-mcp-install.d.ts.map +1 -0
  88. package/dist/agent-remote-mcp-install.js +785 -0
  89. package/dist/agent-remote-mcp-install.js.map +1 -0
  90. package/dist/agent-remote-mcp-oauth.d.ts +73 -0
  91. package/dist/agent-remote-mcp-oauth.d.ts.map +1 -0
  92. package/dist/agent-remote-mcp-oauth.js +344 -0
  93. package/dist/agent-remote-mcp-oauth.js.map +1 -0
  94. package/dist/agent-remote-mcp-transport.d.ts +46 -0
  95. package/dist/agent-remote-mcp-transport.d.ts.map +1 -0
  96. package/dist/agent-remote-mcp-transport.js +225 -0
  97. package/dist/agent-remote-mcp-transport.js.map +1 -0
  98. package/dist/agent-remote-mcp.d.ts +147 -0
  99. package/dist/agent-remote-mcp.d.ts.map +1 -0
  100. package/dist/agent-remote-mcp.js +681 -0
  101. package/dist/agent-remote-mcp.js.map +1 -0
  102. package/dist/agent-room-redaction.d.ts +66 -0
  103. package/dist/agent-room-redaction.d.ts.map +1 -0
  104. package/dist/agent-room-redaction.js +264 -0
  105. package/dist/agent-room-redaction.js.map +1 -0
  106. package/dist/agent-rooms.d.ts +209 -0
  107. package/dist/agent-rooms.d.ts.map +1 -0
  108. package/dist/agent-rooms.js +244 -0
  109. package/dist/agent-rooms.js.map +1 -0
  110. package/dist/agent-runtime.d.ts +541 -0
  111. package/dist/agent-runtime.d.ts.map +1 -0
  112. package/dist/agent-runtime.js +4260 -0
  113. package/dist/agent-runtime.js.map +1 -0
  114. package/dist/agent-skill-archive.d.ts +20 -0
  115. package/dist/agent-skill-archive.d.ts.map +1 -0
  116. package/dist/agent-skill-archive.js +316 -0
  117. package/dist/agent-skill-archive.js.map +1 -0
  118. package/dist/agent-skill-discovery.d.ts +96 -0
  119. package/dist/agent-skill-discovery.d.ts.map +1 -0
  120. package/dist/agent-skill-discovery.js +464 -0
  121. package/dist/agent-skill-discovery.js.map +1 -0
  122. package/dist/agent-skill-format.d.ts +57 -0
  123. package/dist/agent-skill-format.d.ts.map +1 -0
  124. package/dist/agent-skill-format.js +238 -0
  125. package/dist/agent-skill-format.js.map +1 -0
  126. package/dist/agent-skill-import.d.ts +64 -0
  127. package/dist/agent-skill-import.d.ts.map +1 -0
  128. package/dist/agent-skill-import.js +295 -0
  129. package/dist/agent-skill-import.js.map +1 -0
  130. package/dist/agent-skill-publish.d.ts +51 -0
  131. package/dist/agent-skill-publish.d.ts.map +1 -0
  132. package/dist/agent-skill-publish.js +136 -0
  133. package/dist/agent-skill-publish.js.map +1 -0
  134. package/dist/agent-skill-store.d.ts +252 -0
  135. package/dist/agent-skill-store.d.ts.map +1 -0
  136. package/dist/agent-skill-store.js +514 -0
  137. package/dist/agent-skill-store.js.map +1 -0
  138. package/dist/agent-skills.d.ts +85 -0
  139. package/dist/agent-skills.d.ts.map +1 -0
  140. package/dist/agent-skills.js +291 -0
  141. package/dist/agent-skills.js.map +1 -0
  142. package/dist/agent-transcript-pager.d.ts +77 -0
  143. package/dist/agent-transcript-pager.d.ts.map +1 -0
  144. package/dist/agent-transcript-pager.js +210 -0
  145. package/dist/agent-transcript-pager.js.map +1 -0
  146. package/dist/agent-user-data-parts.d.ts +20 -0
  147. package/dist/agent-user-data-parts.d.ts.map +1 -0
  148. package/dist/agent-user-data-parts.js +161 -0
  149. package/dist/agent-user-data-parts.js.map +1 -0
  150. package/dist/agent-user-transcript.d.ts +46 -0
  151. package/dist/agent-user-transcript.d.ts.map +1 -0
  152. package/dist/agent-user-transcript.js +106 -0
  153. package/dist/agent-user-transcript.js.map +1 -0
  154. package/dist/agent-voice-notes.d.ts +15 -0
  155. package/dist/agent-voice-notes.d.ts.map +1 -0
  156. package/dist/agent-voice-notes.js +17 -0
  157. package/dist/agent-voice-notes.js.map +1 -0
  158. package/dist/agent-wakes.d.ts +213 -0
  159. package/dist/agent-wakes.d.ts.map +1 -0
  160. package/dist/agent-wakes.js +534 -0
  161. package/dist/agent-wakes.js.map +1 -0
  162. package/dist/agent.d.ts +1780 -0
  163. package/dist/agent.d.ts.map +1 -0
  164. package/dist/agent.js +3123 -0
  165. package/dist/agent.js.map +1 -0
  166. package/dist/ai.d.ts +583 -0
  167. package/dist/ai.d.ts.map +1 -0
  168. package/dist/ai.js +1858 -0
  169. package/dist/ai.js.map +1 -0
  170. package/dist/app-manifest.d.ts +255 -0
  171. package/dist/app-manifest.d.ts.map +1 -0
  172. package/dist/app-manifest.js +372 -0
  173. package/dist/app-manifest.js.map +1 -0
  174. package/dist/app-users.d.ts +30 -0
  175. package/dist/app-users.d.ts.map +1 -0
  176. package/dist/app-users.js +91 -0
  177. package/dist/app-users.js.map +1 -0
  178. package/dist/audit-archive.d.ts +85 -0
  179. package/dist/audit-archive.d.ts.map +1 -0
  180. package/dist/audit-archive.js +178 -0
  181. package/dist/audit-archive.js.map +1 -0
  182. package/dist/audit.d.ts +54 -0
  183. package/dist/audit.d.ts.map +1 -0
  184. package/dist/audit.js +95 -0
  185. package/dist/audit.js.map +1 -0
  186. package/dist/auth-logger.d.ts +28 -0
  187. package/dist/auth-logger.d.ts.map +1 -0
  188. package/dist/auth-logger.js +53 -0
  189. package/dist/auth-logger.js.map +1 -0
  190. package/dist/auth-password-secret.d.ts +59 -0
  191. package/dist/auth-password-secret.d.ts.map +1 -0
  192. package/dist/auth-password-secret.js +409 -0
  193. package/dist/auth-password-secret.js.map +1 -0
  194. package/dist/auth-schema.d.ts +120 -0
  195. package/dist/auth-schema.d.ts.map +1 -0
  196. package/dist/auth-schema.js +285 -0
  197. package/dist/auth-schema.js.map +1 -0
  198. package/dist/auth.d.ts +321 -0
  199. package/dist/auth.d.ts.map +1 -0
  200. package/dist/auth.js +1562 -0
  201. package/dist/auth.js.map +1 -0
  202. package/dist/backup-archive.d.ts +257 -0
  203. package/dist/backup-archive.d.ts.map +1 -0
  204. package/dist/backup-archive.js +2068 -0
  205. package/dist/backup-archive.js.map +1 -0
  206. package/dist/backup-crypto.d.ts +313 -0
  207. package/dist/backup-crypto.d.ts.map +1 -0
  208. package/dist/backup-crypto.js +693 -0
  209. package/dist/backup-crypto.js.map +1 -0
  210. package/dist/backup-format.d.ts +200 -0
  211. package/dist/backup-format.d.ts.map +1 -0
  212. package/dist/backup-format.js +1247 -0
  213. package/dist/backup-format.js.map +1 -0
  214. package/dist/cli.d.ts +9 -0
  215. package/dist/cli.d.ts.map +1 -0
  216. package/dist/cli.js +15 -0
  217. package/dist/cli.js.map +1 -0
  218. package/dist/clock.d.ts +35 -0
  219. package/dist/clock.d.ts.map +1 -0
  220. package/dist/clock.js +83 -0
  221. package/dist/clock.js.map +1 -0
  222. package/dist/data-client.d.ts +187 -0
  223. package/dist/data-client.d.ts.map +1 -0
  224. package/dist/data-client.js +590 -0
  225. package/dist/data-client.js.map +1 -0
  226. package/dist/data-secrets.d.ts +164 -0
  227. package/dist/data-secrets.d.ts.map +1 -0
  228. package/dist/data-secrets.js +383 -0
  229. package/dist/data-secrets.js.map +1 -0
  230. package/dist/db.d.ts +453 -0
  231. package/dist/db.d.ts.map +1 -0
  232. package/dist/db.js +324 -0
  233. package/dist/db.js.map +1 -0
  234. package/dist/dev-activity.d.ts +48 -0
  235. package/dist/dev-activity.d.ts.map +1 -0
  236. package/dist/dev-activity.js +50 -0
  237. package/dist/dev-activity.js.map +1 -0
  238. package/dist/do.d.ts +429 -0
  239. package/dist/do.d.ts.map +1 -0
  240. package/dist/do.js +4526 -0
  241. package/dist/do.js.map +1 -0
  242. package/dist/email.d.ts +137 -0
  243. package/dist/email.d.ts.map +1 -0
  244. package/dist/email.js +527 -0
  245. package/dist/email.js.map +1 -0
  246. package/dist/generated/admin-assets.d.ts +16 -0
  247. package/dist/generated/admin-assets.d.ts.map +1 -0
  248. package/dist/generated/admin-assets.js +16 -0
  249. package/dist/generated/admin-assets.js.map +1 -0
  250. package/dist/generated/admin-docs-assets.d.ts +3 -0
  251. package/dist/generated/admin-docs-assets.d.ts.map +1 -0
  252. package/dist/generated/admin-docs-assets.js +3 -0
  253. package/dist/generated/admin-docs-assets.js.map +1 -0
  254. package/dist/handoff-consumer.d.ts +79 -0
  255. package/dist/handoff-consumer.d.ts.map +1 -0
  256. package/dist/handoff-consumer.js +300 -0
  257. package/dist/handoff-consumer.js.map +1 -0
  258. package/dist/handoff.d.ts +70 -0
  259. package/dist/handoff.d.ts.map +1 -0
  260. package/dist/handoff.js +228 -0
  261. package/dist/handoff.js.map +1 -0
  262. package/dist/host-auth.d.ts +47 -0
  263. package/dist/host-auth.d.ts.map +1 -0
  264. package/dist/host-auth.js +59 -0
  265. package/dist/host-auth.js.map +1 -0
  266. package/dist/host-user.d.ts +15 -0
  267. package/dist/host-user.d.ts.map +1 -0
  268. package/dist/host-user.js +23 -0
  269. package/dist/host-user.js.map +1 -0
  270. package/dist/html.d.ts +35 -0
  271. package/dist/html.d.ts.map +1 -0
  272. package/dist/html.js +634 -0
  273. package/dist/html.js.map +1 -0
  274. package/dist/http-error.d.ts +13 -0
  275. package/dist/http-error.d.ts.map +1 -0
  276. package/dist/http-error.js +15 -0
  277. package/dist/http-error.js.map +1 -0
  278. package/dist/imports.d.ts +219 -0
  279. package/dist/imports.d.ts.map +1 -0
  280. package/dist/imports.js +571 -0
  281. package/dist/imports.js.map +1 -0
  282. package/dist/index.d.ts +78 -0
  283. package/dist/index.d.ts.map +1 -0
  284. package/dist/index.js +76 -0
  285. package/dist/index.js.map +1 -0
  286. package/dist/inference-tape.d.ts +64 -0
  287. package/dist/inference-tape.d.ts.map +1 -0
  288. package/dist/inference-tape.js +278 -0
  289. package/dist/inference-tape.js.map +1 -0
  290. package/dist/internal.d.ts +15 -0
  291. package/dist/internal.d.ts.map +1 -0
  292. package/dist/internal.js +48 -0
  293. package/dist/internal.js.map +1 -0
  294. package/dist/jobs.d.ts +796 -0
  295. package/dist/jobs.d.ts.map +1 -0
  296. package/dist/jobs.js +538 -0
  297. package/dist/jobs.js.map +1 -0
  298. package/dist/json-schema.d.ts +91 -0
  299. package/dist/json-schema.d.ts.map +1 -0
  300. package/dist/json-schema.js +884 -0
  301. package/dist/json-schema.js.map +1 -0
  302. package/dist/knowledge.d.ts +33 -0
  303. package/dist/knowledge.d.ts.map +1 -0
  304. package/dist/knowledge.js +348 -0
  305. package/dist/knowledge.js.map +1 -0
  306. package/dist/local-dev-chrome.d.ts +29 -0
  307. package/dist/local-dev-chrome.d.ts.map +1 -0
  308. package/dist/local-dev-chrome.js +514 -0
  309. package/dist/local-dev-chrome.js.map +1 -0
  310. package/dist/local-dev-personas.d.ts +15 -0
  311. package/dist/local-dev-personas.d.ts.map +1 -0
  312. package/dist/local-dev-personas.js +46 -0
  313. package/dist/local-dev-personas.js.map +1 -0
  314. package/dist/mcp.d.ts +567 -0
  315. package/dist/mcp.d.ts.map +1 -0
  316. package/dist/mcp.js +2480 -0
  317. package/dist/mcp.js.map +1 -0
  318. package/dist/modules.d.ts +145 -0
  319. package/dist/modules.d.ts.map +1 -0
  320. package/dist/modules.js +164 -0
  321. package/dist/modules.js.map +1 -0
  322. package/dist/notification-client.d.ts +94 -0
  323. package/dist/notification-client.d.ts.map +1 -0
  324. package/dist/notification-client.js +127 -0
  325. package/dist/notification-client.js.map +1 -0
  326. package/dist/notifications.d.ts +447 -0
  327. package/dist/notifications.d.ts.map +1 -0
  328. package/dist/notifications.js +1625 -0
  329. package/dist/notifications.js.map +1 -0
  330. package/dist/operator-data-query.d.ts +117 -0
  331. package/dist/operator-data-query.d.ts.map +1 -0
  332. package/dist/operator-data-query.js +431 -0
  333. package/dist/operator-data-query.js.map +1 -0
  334. package/dist/operator-mcp.d.ts +63 -0
  335. package/dist/operator-mcp.d.ts.map +1 -0
  336. package/dist/operator-mcp.js +2828 -0
  337. package/dist/operator-mcp.js.map +1 -0
  338. package/dist/outbound-https.d.ts +24 -0
  339. package/dist/outbound-https.d.ts.map +1 -0
  340. package/dist/outbound-https.js +172 -0
  341. package/dist/outbound-https.js.map +1 -0
  342. package/dist/pitr.d.ts +60 -0
  343. package/dist/pitr.d.ts.map +1 -0
  344. package/dist/pitr.js +94 -0
  345. package/dist/pitr.js.map +1 -0
  346. package/dist/platform.d.ts +199 -0
  347. package/dist/platform.d.ts.map +1 -0
  348. package/dist/platform.js +196 -0
  349. package/dist/platform.js.map +1 -0
  350. package/dist/policies.d.ts +115 -0
  351. package/dist/policies.d.ts.map +1 -0
  352. package/dist/policies.js +158 -0
  353. package/dist/policies.js.map +1 -0
  354. package/dist/principal.d.ts +143 -0
  355. package/dist/principal.d.ts.map +1 -0
  356. package/dist/principal.js +174 -0
  357. package/dist/principal.js.map +1 -0
  358. package/dist/realtime.d.ts +280 -0
  359. package/dist/realtime.d.ts.map +1 -0
  360. package/dist/realtime.js +578 -0
  361. package/dist/realtime.js.map +1 -0
  362. package/dist/retrieval.d.ts +263 -0
  363. package/dist/retrieval.d.ts.map +1 -0
  364. package/dist/retrieval.js +602 -0
  365. package/dist/retrieval.js.map +1 -0
  366. package/dist/runtime-error-client.d.ts +10 -0
  367. package/dist/runtime-error-client.d.ts.map +1 -0
  368. package/dist/runtime-error-client.js +70 -0
  369. package/dist/runtime-error-client.js.map +1 -0
  370. package/dist/runtime-errors.d.ts +180 -0
  371. package/dist/runtime-errors.d.ts.map +1 -0
  372. package/dist/runtime-errors.js +572 -0
  373. package/dist/runtime-errors.js.map +1 -0
  374. package/dist/schema.d.ts +434 -0
  375. package/dist/schema.d.ts.map +1 -0
  376. package/dist/schema.js +232 -0
  377. package/dist/schema.js.map +1 -0
  378. package/dist/search.d.ts +216 -0
  379. package/dist/search.d.ts.map +1 -0
  380. package/dist/search.js +785 -0
  381. package/dist/search.js.map +1 -0
  382. package/dist/settings.d.ts +191 -0
  383. package/dist/settings.d.ts.map +1 -0
  384. package/dist/settings.js +478 -0
  385. package/dist/settings.js.map +1 -0
  386. package/dist/sql.d.ts +7 -0
  387. package/dist/sql.d.ts.map +1 -0
  388. package/dist/sql.js +30 -0
  389. package/dist/sql.js.map +1 -0
  390. package/dist/storage-client.d.ts +69 -0
  391. package/dist/storage-client.d.ts.map +1 -0
  392. package/dist/storage-client.js +469 -0
  393. package/dist/storage-client.js.map +1 -0
  394. package/dist/storage.d.ts +21 -0
  395. package/dist/storage.d.ts.map +1 -0
  396. package/dist/storage.js +82 -0
  397. package/dist/storage.js.map +1 -0
  398. package/dist/subject-domains.d.ts +15 -0
  399. package/dist/subject-domains.d.ts.map +1 -0
  400. package/dist/subject-domains.js +38 -0
  401. package/dist/subject-domains.js.map +1 -0
  402. package/dist/support.d.ts +132 -0
  403. package/dist/support.d.ts.map +1 -0
  404. package/dist/support.js +483 -0
  405. package/dist/support.js.map +1 -0
  406. package/dist/system-tables.d.ts +12 -0
  407. package/dist/system-tables.d.ts.map +1 -0
  408. package/dist/system-tables.js +225 -0
  409. package/dist/system-tables.js.map +1 -0
  410. package/dist/test.d.ts +57 -0
  411. package/dist/test.d.ts.map +1 -0
  412. package/dist/test.js +454 -0
  413. package/dist/test.js.map +1 -0
  414. package/dist/vite.d.ts +82 -0
  415. package/dist/vite.d.ts.map +1 -0
  416. package/dist/vite.js +82 -0
  417. package/dist/vite.js.map +1 -0
  418. package/dist/webpush-crypto.d.ts +39 -0
  419. package/dist/webpush-crypto.d.ts.map +1 -0
  420. package/dist/webpush-crypto.js +213 -0
  421. package/dist/webpush-crypto.js.map +1 -0
  422. package/dist/webpush-private.d.ts +11 -0
  423. package/dist/webpush-private.d.ts.map +1 -0
  424. package/dist/webpush-private.js +11 -0
  425. package/dist/webpush-private.js.map +1 -0
  426. package/dist/webpush.d.ts +86 -0
  427. package/dist/webpush.d.ts.map +1 -0
  428. package/dist/webpush.js +375 -0
  429. package/dist/webpush.js.map +1 -0
  430. package/dist/worker.d.ts +185 -0
  431. package/dist/worker.d.ts.map +1 -0
  432. package/dist/worker.js +13021 -0
  433. package/dist/worker.js.map +1 -0
  434. package/package.json +161 -8
  435. package/README.md +0 -3
@@ -0,0 +1,1625 @@
1
+ import { z } from "zod";
2
+ import { isHostUserId } from "./host-user.js";
3
+ import { hasAdminAccess, normalizeStoredAdminRole } from "./admin-role.js";
4
+ import { integer, longText, raybaseSystemTable, text } from "./schema.js";
5
+ import { addDuration, durationMs, truncateEventData, truncateJson } from "./jobs.js";
6
+ import { sha256Hex, stableJson } from "./internal.js";
7
+ import { raybaseRuntimeProfile, raybaseRuntimeScope } from "./platform.js";
8
+ import { nowIso, q, randomId } from "./sql.js";
9
+ import { wallNowIso, wallNowMs } from "./clock.js";
10
+ import { sendWebPushPrivately } from "./webpush-private.js";
11
+ export const raybaseNotificationDeliveryJobName = "raybase.notifications.deliver";
12
+ export const raybaseNotificationServicePrincipal = "raybase-notifications";
13
+ export const raybaseNotificationJobPrefix = "raybase.notifications.";
14
+ export const raybaseNotificationsTableName = "_notifications";
15
+ export const raybaseNotificationRecipientsTableName = "_notification_recipients";
16
+ export const raybaseNotificationSubscriptionsTableName = "_notification_subscriptions";
17
+ export const raybaseNotificationDeliveriesTableName = "_notification_deliveries";
18
+ export const raybaseNotificationAttemptsTableName = "_notification_attempts";
19
+ export const raybaseNotificationThrottleWindowsTableName = "_notification_throttle_windows";
20
+ export const raybaseInboundEventsTableName = "_inbound_events";
21
+ export const defaultNotificationSubscriptionOrganizationId = "default";
22
+ export class NotificationError extends Error {
23
+ code;
24
+ constructor(message, code) {
25
+ super(message);
26
+ this.code = code;
27
+ this.name = "NotificationError";
28
+ }
29
+ }
30
+ export function defineNotifications(notifications) {
31
+ validateNotifications(notifications);
32
+ return notifications;
33
+ }
34
+ export function mergeRaybaseNotifications(base, contribution, options = {}) {
35
+ if (!base && !contribution)
36
+ return undefined;
37
+ const duplicateLabel = options.duplicateLabel ?? "Raybase notification";
38
+ const channels = mergeNotificationRecord(base?.channels, contribution?.channels, `${duplicateLabel} channel`);
39
+ const inbound = mergeNotificationRecord(base?.inbound, contribution?.inbound, `${duplicateLabel} inbound adapter`);
40
+ const feed = base?.feed || contribution?.feed
41
+ ? { ...(base?.feed ?? {}), ...(contribution?.feed ?? {}) }
42
+ : undefined;
43
+ const merged = {
44
+ ...(base ?? {}),
45
+ ...(contribution ?? {}),
46
+ ...(feed ? { feed } : {}),
47
+ ...(channels ? { channels } : {}),
48
+ ...(inbound ? { inbound } : {})
49
+ };
50
+ if (options.validate !== false)
51
+ validateNotifications(merged);
52
+ return merged;
53
+ }
54
+ function mergeNotificationRecord(base, contribution, duplicateLabel) {
55
+ if (!base && !contribution)
56
+ return undefined;
57
+ const result = { ...(base ?? {}) };
58
+ for (const [key, value] of Object.entries(contribution ?? {})) {
59
+ if (Object.prototype.hasOwnProperty.call(result, key)) {
60
+ throw new Error(`Duplicate ${duplicateLabel}: ${key}`);
61
+ }
62
+ result[key] = value;
63
+ }
64
+ return result;
65
+ }
66
+ export const notificationRecipient = {
67
+ user(userId) {
68
+ return { kind: "user", userId };
69
+ },
70
+ role(role) {
71
+ return { kind: "role", role };
72
+ },
73
+ operator(role) {
74
+ return { kind: "operator", role };
75
+ },
76
+ currentUser() {
77
+ return { kind: "current-user" };
78
+ }
79
+ };
80
+ export const notificationDefaults = {
81
+ maxRecipientsPerSend: 100,
82
+ maxRecipientsHardLimit: 500,
83
+ maxBodyBytes: 2048,
84
+ maxMetadataBytes: 32 * 1024,
85
+ maxChannelTargetsBytes: 8 * 1024,
86
+ maxInboundBodyBytes: 256 * 1024,
87
+ maxAttemptSummaryBytes: 8 * 1024,
88
+ deliveryMaxAttempts: 3,
89
+ deliveryTimeoutMs: 10_000,
90
+ throttleKeyMaxLength: 128,
91
+ throttleMinIntervalMs: 60_000,
92
+ throttleMaxIntervalMs: 30 * 24 * 60 * 60 * 1000
93
+ };
94
+ export function resolveNotificationConfig(config, notifications) {
95
+ const input = config.notifications ?? {};
96
+ const enabled = input.enabled === true || Boolean(notifications);
97
+ const maxRecipientsPerSend = positiveInteger(input.maxRecipientsPerSend, notificationDefaults.maxRecipientsPerSend, "notifications.maxRecipientsPerSend");
98
+ if (maxRecipientsPerSend > notificationDefaults.maxRecipientsHardLimit) {
99
+ throw new Error(`notifications.maxRecipientsPerSend must not exceed ${notificationDefaults.maxRecipientsHardLimit}`);
100
+ }
101
+ return {
102
+ enabled,
103
+ maxRecipientsPerSend,
104
+ maxBodyBytes: positiveInteger(input.maxBodyBytes, notificationDefaults.maxBodyBytes, "notifications.maxBodyBytes"),
105
+ maxMetadataBytes: positiveInteger(input.maxMetadataBytes, notificationDefaults.maxMetadataBytes, "notifications.maxMetadataBytes"),
106
+ maxChannelTargetsBytes: positiveInteger(input.maxChannelTargetsBytes, notificationDefaults.maxChannelTargetsBytes, "notifications.maxChannelTargetsBytes"),
107
+ maxInboundBodyBytes: positiveInteger(input.maxInboundBodyBytes, notificationDefaults.maxInboundBodyBytes, "notifications.maxInboundBodyBytes"),
108
+ feed: {
109
+ enabled: input.feed?.enabled !== false,
110
+ endpoint: normalizeEndpoint(input.feed?.endpoint ?? "/api/notifications")
111
+ },
112
+ delivery: {
113
+ maxAttempts: positiveInteger(input.delivery?.maxAttempts, notificationDefaults.deliveryMaxAttempts, "notifications.delivery.maxAttempts"),
114
+ timeoutMs: positiveInteger(input.delivery?.timeoutMs, notificationDefaults.deliveryTimeoutMs, "notifications.delivery.timeoutMs")
115
+ }
116
+ };
117
+ }
118
+ function positiveInteger(value, fallback, name) {
119
+ if (value === undefined)
120
+ return fallback;
121
+ if (!Number.isInteger(value) || Number(value) <= 0)
122
+ throw new Error(`${name} must be a positive integer`);
123
+ return Number(value);
124
+ }
125
+ function normalizeEndpoint(endpoint) {
126
+ return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
127
+ }
128
+ export function validateNotifications(notifications) {
129
+ const names = new Set();
130
+ for (const [key, adapter] of Object.entries(notifications.channels ?? {})) {
131
+ if (key === "feed" || (key === "webpush" && (adapter.name !== "raybase-webpush" || adapter.channel !== "webpush"))) {
132
+ throw new Error(`Notification channel "${key}" is reserved`);
133
+ }
134
+ if (!adapter.name.trim())
135
+ throw new Error(`Notification channel ${key} adapter must declare a name`);
136
+ if (!adapter.channel.trim())
137
+ throw new Error(`Notification channel ${key} adapter must declare a channel`);
138
+ const id = `${adapter.channel}:${adapter.name}`;
139
+ if (names.has(id))
140
+ throw new Error(`Duplicate notification adapter ${id}`);
141
+ names.add(id);
142
+ }
143
+ const paths = new Set();
144
+ for (const [key, adapter] of Object.entries(notifications.inbound ?? {})) {
145
+ if (!adapter.name.trim())
146
+ throw new Error(`Inbound notification adapter ${key} must declare a name`);
147
+ if (!adapter.path.startsWith("/"))
148
+ throw new Error(`Inbound notification adapter ${key} path must start with "/"`);
149
+ if (reservedInboundPath(adapter.path))
150
+ throw new Error(`Inbound notification adapter ${key} path collides with a Raybase route`);
151
+ if (paths.has(adapter.path))
152
+ throw new Error(`Duplicate inbound notification path: ${adapter.path}`);
153
+ paths.add(adapter.path);
154
+ }
155
+ }
156
+ export function reservedInboundPath(path) {
157
+ return (path === "/api/notifications" ||
158
+ path.startsWith("/api/notifications/") ||
159
+ path === "/api/auth" ||
160
+ path.startsWith("/api/auth/") ||
161
+ path === "/api/admin" ||
162
+ path.startsWith("/api/admin/") ||
163
+ path === "/api/data" ||
164
+ path.startsWith("/api/data/") ||
165
+ path === "/api/realtime");
166
+ }
167
+ export const raybaseNotificationSystemTables = [
168
+ raybaseSystemTable(raybaseNotificationsTableName, {
169
+ id: text({ primaryKey: true, required: true }),
170
+ kind: text(),
171
+ severity: text({ required: true }),
172
+ title: text({ required: true }),
173
+ body: longText(),
174
+ targetHref: text(),
175
+ targetEntityType: text(),
176
+ targetEntityId: text(),
177
+ channelsJson: longText({ required: true }),
178
+ channelTargetsJson: longText(),
179
+ metadataJson: longText(),
180
+ dedupeKey: text(),
181
+ actorKind: text({ required: true }),
182
+ actorId: text(),
183
+ sourceType: text(),
184
+ sourceId: text(),
185
+ createdAt: text({ required: true }),
186
+ expiresAt: text()
187
+ }, {
188
+ indexes: [
189
+ { columns: ["dedupeKey"], unique: true, where: '"dedupeKey" IS NOT NULL', name: "_notifications_dedupe_idx" },
190
+ ["createdAt"]
191
+ ]
192
+ }),
193
+ raybaseSystemTable(raybaseNotificationRecipientsTableName, {
194
+ id: text({ primaryKey: true, required: true }),
195
+ notificationId: text({ required: true }),
196
+ recipientKind: text({ required: true }),
197
+ recipientSubjectId: text({ required: true }),
198
+ recipientRole: text(),
199
+ recipientSeq: integer({ required: true }),
200
+ feedStatus: text({ required: true }),
201
+ readAt: text(),
202
+ archivedAt: text(),
203
+ createdAt: text({ required: true })
204
+ }, {
205
+ indexes: [
206
+ { columns: ["recipientSubjectId", "feedStatus", "recipientSeq"], name: "_notification_recipients_user_idx" },
207
+ ["notificationId"]
208
+ ]
209
+ }),
210
+ raybaseSystemTable(raybaseNotificationSubscriptionsTableName, {
211
+ id: text({ primaryKey: true, required: true }),
212
+ organizationId: text({ required: true }),
213
+ userId: text({ required: true }),
214
+ kind: text({ required: true }),
215
+ enabled: integer({ required: true }),
216
+ channelsJson: longText({ required: true }),
217
+ createdAt: text({ required: true }),
218
+ updatedAt: text({ required: true })
219
+ }, {
220
+ label: "Notification subscriptions",
221
+ indexes: [
222
+ { columns: ["organizationId", "userId", "kind"], unique: true, name: "_notification_subscriptions_user_kind_idx" },
223
+ { columns: ["organizationId", "kind", "enabled"], name: "_notification_subscriptions_kind_idx" }
224
+ ]
225
+ }),
226
+ raybaseSystemTable(raybaseNotificationDeliveriesTableName, {
227
+ id: text({ primaryKey: true, required: true }),
228
+ notificationId: text({ required: true }),
229
+ recipientId: text(),
230
+ recipientSubjectId: text(),
231
+ recipientEmail: text(),
232
+ recipientName: text(),
233
+ channel: text({ required: true }),
234
+ adapter: text({ required: true }),
235
+ status: text({ required: true }),
236
+ attempt: integer({ required: true }),
237
+ maxAttempts: integer({ required: true }),
238
+ idempotencyKey: text(),
239
+ availableAt: text({ required: true }),
240
+ lastAttemptAt: text(),
241
+ deliveredAt: text(),
242
+ failedAt: text(),
243
+ providerMessageId: text(),
244
+ lastErrorJson: longText(),
245
+ createdAt: text({ required: true }),
246
+ updatedAt: text({ required: true })
247
+ }, {
248
+ indexes: [
249
+ { columns: ["adapter", "channel", "idempotencyKey"], unique: true, where: '"idempotencyKey" IS NOT NULL', name: "_notification_deliveries_idempotency_idx" },
250
+ { columns: ["status", "availableAt"], name: "_notification_deliveries_due_idx" },
251
+ ["notificationId"],
252
+ ["recipientSubjectId"]
253
+ ]
254
+ }),
255
+ raybaseSystemTable(raybaseNotificationAttemptsTableName, {
256
+ id: text({ primaryKey: true, required: true }),
257
+ deliveryId: text({ required: true }),
258
+ attempt: integer({ required: true }),
259
+ status: text({ required: true }),
260
+ startedAt: text({ required: true }),
261
+ endedAt: text(),
262
+ durationMs: integer(),
263
+ requestSummaryJson: longText(),
264
+ responseSummaryJson: longText(),
265
+ errorJson: longText()
266
+ }, { indexes: [["deliveryId"], ["startedAt"]] }),
267
+ raybaseSystemTable(raybaseNotificationThrottleWindowsTableName, {
268
+ id: text({ primaryKey: true, required: true }),
269
+ throttleKey: text({ required: true }),
270
+ recipientSubjectId: text({ required: true }),
271
+ lastSentAt: text({ required: true }),
272
+ windowExpiresAt: text({ required: true }),
273
+ suppressedCount: integer({ required: true }),
274
+ firstSuppressedAt: text(),
275
+ lastSuppressedJson: longText(),
276
+ updatedAt: text({ required: true })
277
+ }, {
278
+ label: "Notification throttles",
279
+ indexes: [
280
+ { columns: ["throttleKey", "recipientSubjectId"], unique: true, name: "_notification_throttle_windows_unique_idx" },
281
+ ["windowExpiresAt"]
282
+ ]
283
+ }),
284
+ raybaseSystemTable(raybaseInboundEventsTableName, {
285
+ id: text({ primaryKey: true, required: true }),
286
+ adapter: text({ required: true }),
287
+ path: text({ required: true }),
288
+ status: text({ required: true }),
289
+ verified: integer({ required: true }),
290
+ idempotencyKey: text(),
291
+ providerEventId: text(),
292
+ actorKind: text({ required: true }),
293
+ actorId: text(),
294
+ payloadJson: longText(),
295
+ resultJson: longText(),
296
+ errorJson: longText(),
297
+ receivedAt: text({ required: true }),
298
+ processedAt: text()
299
+ }, {
300
+ indexes: [
301
+ { columns: ["adapter", "idempotencyKey"], unique: true, where: '"idempotencyKey" IS NOT NULL', name: "_inbound_events_idempotency_idx" },
302
+ ["receivedAt"],
303
+ ["status"]
304
+ ]
305
+ })
306
+ ];
307
+ function assertEnabled(resolved) {
308
+ if (!resolved.enabled)
309
+ throw new NotificationError("Notifications are not enabled", "disabled");
310
+ }
311
+ export function createDisabledNotificationsRuntime() {
312
+ const fail = () => {
313
+ throw new NotificationError("Notifications are not enabled", "disabled");
314
+ };
315
+ return {
316
+ enabled: false,
317
+ feedEnabled: false,
318
+ send: fail,
319
+ list: fail,
320
+ unreadCount: fail,
321
+ markRead: fail,
322
+ markAllRead: fail,
323
+ archive: fail
324
+ };
325
+ }
326
+ function normalizeSubscriptionChannels(channels) {
327
+ const normalized = Array.from(new Set((channels ?? ["feed"]).map((channel) => channel.trim()).filter(Boolean)));
328
+ return normalized.length ? normalized : ["feed"];
329
+ }
330
+ function subscriptionChannelsFromJson(value) {
331
+ if (!value)
332
+ return ["feed"];
333
+ try {
334
+ const parsed = JSON.parse(value);
335
+ return Array.isArray(parsed) ? normalizeSubscriptionChannels(parsed.filter((channel) => typeof channel === "string")) : ["feed"];
336
+ }
337
+ catch {
338
+ return ["feed"];
339
+ }
340
+ }
341
+ function notificationSubscriptionFromRow(row) {
342
+ return {
343
+ id: row.id,
344
+ organizationId: row.organizationId ?? defaultNotificationSubscriptionOrganizationId,
345
+ userId: row.userId,
346
+ kind: row.kind,
347
+ enabled: Boolean(row.enabled),
348
+ channels: subscriptionChannelsFromJson(row.channelsJson),
349
+ createdAt: row.createdAt,
350
+ updatedAt: row.updatedAt
351
+ };
352
+ }
353
+ export async function getNotificationSubscription(db, input) {
354
+ const organizationId = input.organizationId ?? defaultNotificationSubscriptionOrganizationId;
355
+ const row = await db.first(`SELECT "id", "organizationId", "userId", "kind", "enabled", "channelsJson", "createdAt", "updatedAt"
356
+ FROM ${q(raybaseNotificationSubscriptionsTableName)}
357
+ WHERE "organizationId" = ? AND "userId" = ? AND "kind" = ? LIMIT 1`, [organizationId, input.userId, input.kind]);
358
+ return row ? notificationSubscriptionFromRow(row) : null;
359
+ }
360
+ export async function setNotificationSubscription(db, input) {
361
+ const organizationId = input.organizationId ?? defaultNotificationSubscriptionOrganizationId;
362
+ const existing = await getNotificationSubscription(db, { ...input, organizationId });
363
+ const now = nowIso();
364
+ const id = existing?.id ?? randomId("notifsub");
365
+ const channels = normalizeSubscriptionChannels(input.channels ?? existing?.channels);
366
+ await db.run(`INSERT INTO ${q(raybaseNotificationSubscriptionsTableName)}
367
+ ("id", "organizationId", "userId", "kind", "enabled", "channelsJson", "createdAt", "updatedAt")
368
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
369
+ ON CONFLICT("organizationId", "userId", "kind") DO UPDATE SET
370
+ "enabled" = excluded."enabled",
371
+ "channelsJson" = excluded."channelsJson",
372
+ "updatedAt" = excluded."updatedAt"`, [id, organizationId, input.userId, input.kind, input.enabled ? 1 : 0, stableJson(channels), existing?.createdAt ?? now, now]);
373
+ return {
374
+ id,
375
+ organizationId,
376
+ userId: input.userId,
377
+ kind: input.kind,
378
+ enabled: input.enabled,
379
+ channels,
380
+ createdAt: existing?.createdAt ?? now,
381
+ updatedAt: now
382
+ };
383
+ }
384
+ export async function listNotificationSubscribers(db, kind, input = {}) {
385
+ const organizationId = input.organizationId ?? defaultNotificationSubscriptionOrganizationId;
386
+ const rows = await db.all(`SELECT "userId", "channelsJson"
387
+ FROM ${q(raybaseNotificationSubscriptionsTableName)}
388
+ WHERE "organizationId" = ? AND "kind" = ? AND "enabled" = 1
389
+ ORDER BY "updatedAt" DESC`, [organizationId, kind]);
390
+ return rows.map((row) => ({
391
+ userId: row.userId,
392
+ channels: subscriptionChannelsFromJson(row.channelsJson)
393
+ }));
394
+ }
395
+ export function createNotificationsRuntime(options) {
396
+ const resolved = resolveNotificationConfig(options.config, options.notifications);
397
+ if (!resolved.enabled)
398
+ return createDisabledNotificationsRuntime();
399
+ const runtime = {
400
+ enabled: true,
401
+ feedEnabled: resolved.feed.enabled,
402
+ async send(input) {
403
+ return sendNotification(options, resolved, input);
404
+ },
405
+ async list(listOptions) {
406
+ const subjectId = requireCurrentSubject(options.currentUser);
407
+ const limit = Math.min(Math.max(1, Number(listOptions?.limit ?? 50)), 100);
408
+ const where = [`r."recipientSubjectId" = ?`, `r."feedStatus" != 'none'`];
409
+ const params = [subjectId];
410
+ if (listOptions?.status && listOptions.status !== "all") {
411
+ where.push(`r."feedStatus" = ?`);
412
+ params.push(listOptions.status);
413
+ }
414
+ const cursor = listOptions?.cursor ?? listOptions?.since;
415
+ if (cursor !== undefined) {
416
+ where.push(`r."recipientSeq" > ?`);
417
+ params.push(Math.max(0, Math.trunc(cursor)));
418
+ }
419
+ params.push(limit + 1);
420
+ const rows = await options.db.all(`SELECT r."id" AS "feedItemId", r."notificationId", r."feedStatus", r."readAt", r."archivedAt", r."recipientSeq", r."createdAt",
421
+ n."id", n."kind", n."severity", n."title", n."body", n."targetHref", n."targetEntityType", n."targetEntityId",
422
+ n."metadataJson", n."expiresAt"
423
+ FROM ${q(raybaseNotificationRecipientsTableName)} r
424
+ INNER JOIN ${q(raybaseNotificationsTableName)} n ON n."id" = r."notificationId"
425
+ WHERE ${where.join(" AND ")}
426
+ ORDER BY r."recipientSeq" DESC
427
+ LIMIT ?`, params);
428
+ const page = rows.slice(0, limit).map(feedRowToItem);
429
+ return {
430
+ items: page,
431
+ nextCursor: rows.length > limit ? page.at(-1)?.recipientSeq ?? null : null
432
+ };
433
+ },
434
+ async unreadCount() {
435
+ const subjectId = requireCurrentSubject(options.currentUser);
436
+ return unreadCount(options.db, subjectId);
437
+ },
438
+ async markRead(feedItemId) {
439
+ const subjectId = requireCurrentSubject(options.currentUser);
440
+ const now = nowIso();
441
+ await options.db.run(`UPDATE ${q(raybaseNotificationRecipientsTableName)}
442
+ SET "feedStatus" = 'read', "readAt" = COALESCE("readAt", ?)
443
+ WHERE "id" = ? AND "recipientSubjectId" = ? AND "feedStatus" != 'archived' AND "feedStatus" != 'none'`, [now, feedItemId, subjectId]);
444
+ const item = await getFeedItem(options.db, subjectId, feedItemId);
445
+ if (!item)
446
+ throw new NotificationError("Notification feed item was not found", "not-found");
447
+ await pushFeedUpdate(options, subjectId, "read", item);
448
+ return item;
449
+ },
450
+ async markAllRead() {
451
+ const subjectId = requireCurrentSubject(options.currentUser);
452
+ const now = nowIso();
453
+ const before = await unreadCount(options.db, subjectId);
454
+ await options.db.run(`UPDATE ${q(raybaseNotificationRecipientsTableName)}
455
+ SET "feedStatus" = 'read', "readAt" = COALESCE("readAt", ?)
456
+ WHERE "recipientSubjectId" = ? AND "feedStatus" = 'unread'`, [now, subjectId]);
457
+ const after = await unreadCount(options.db, subjectId);
458
+ return { updated: before.unread - after.unread, unread: after.unread, seq: after.seq };
459
+ },
460
+ async archive(feedItemId) {
461
+ const subjectId = requireCurrentSubject(options.currentUser);
462
+ const now = nowIso();
463
+ await options.db.run(`UPDATE ${q(raybaseNotificationRecipientsTableName)}
464
+ SET "feedStatus" = 'archived', "archivedAt" = COALESCE("archivedAt", ?)
465
+ WHERE "id" = ? AND "recipientSubjectId" = ? AND "feedStatus" != 'none'`, [now, feedItemId, subjectId]);
466
+ const item = await getFeedItem(options.db, subjectId, feedItemId);
467
+ if (!item)
468
+ throw new NotificationError("Notification feed item was not found", "not-found");
469
+ await pushFeedUpdate(options, subjectId, "archived", item);
470
+ return item;
471
+ }
472
+ };
473
+ return runtime;
474
+ }
475
+ async function sendNotification(options, resolved, input) {
476
+ assertEnabled(resolved);
477
+ const adapters = options.notifications?.channels ?? {};
478
+ const channels = normalizeChannels(input.channels);
479
+ const externalChannels = channels.filter((channel) => channel !== "feed");
480
+ for (const channel of externalChannels) {
481
+ if (!adapters[channel])
482
+ throw new NotificationError(`Unknown notification channel: ${channel}`, "invalid-channel");
483
+ }
484
+ if (!input.title || typeof input.title !== "string")
485
+ throw new NotificationError("Notification title is required", "invalid-input");
486
+ if (input.title.length > 200)
487
+ throw new NotificationError("Notification title must be 200 characters or fewer", "payload-too-large");
488
+ assertUtf8Bytes(input.body ?? "", resolved.maxBodyBytes, "Notification body");
489
+ const metadataJson = checkedStoredJson(redactSecrets(input.metadata), resolved.maxMetadataBytes, "Notification metadata");
490
+ const channelTargetsJson = checkedStoredJson(redactSecrets(input.channelTargets), resolved.maxChannelTargetsBytes, "Notification channel targets");
491
+ const severity = input.severity ?? "info";
492
+ if (!["info", "success", "warning", "error"].includes(severity)) {
493
+ throw new NotificationError(`Invalid notification severity: ${severity}`, "invalid-input");
494
+ }
495
+ if (input.expiresAt && !Number.isFinite(new Date(input.expiresAt).getTime())) {
496
+ throw new NotificationError("Notification expiresAt must be an ISO timestamp", "invalid-input");
497
+ }
498
+ if (input.dedupeKey) {
499
+ const existing = await options.db.first(`SELECT "id" FROM ${q(raybaseNotificationsTableName)} WHERE "dedupeKey" = ?`, [input.dedupeKey]);
500
+ if (existing)
501
+ return dedupedResult(options.db, existing.id);
502
+ }
503
+ const wantsFeed = channels.includes("feed");
504
+ const recipients = await resolveRecipients(options, input.to ?? [], resolved);
505
+ if (wantsFeed && recipients.length === 0) {
506
+ throw new NotificationError("Feed notifications require at least one authenticated recipient", "recipient-unavailable");
507
+ }
508
+ if (!wantsFeed && externalChannels.length === 0) {
509
+ throw new NotificationError("Notification channels must not be empty", "invalid-input");
510
+ }
511
+ const createdAt = nowIso();
512
+ const throttle = input.throttle ? normalizeThrottle(input.throttle) : null;
513
+ const throttleResult = throttle
514
+ ? await applyNotificationThrottle(options, throttle, recipients, input, createdAt)
515
+ : { allowed: recipients, throttled: [] };
516
+ if (recipients.length > 0 && throttleResult.allowed.length === 0) {
517
+ return { deduped: false, recipients: [], deliveries: [], throttled: throttleResult.throttled, skipped: [] };
518
+ }
519
+ const activeWebPushSubjects = externalChannels.includes("webpush")
520
+ ? await options.activeWebPushSubjectIds?.(throttleResult.allowed.map((recipient) => recipient.recipientSubjectId)) ?? new Set()
521
+ : null;
522
+ const notificationId = randomId("notif");
523
+ const actor = options.actor ?? (options.currentUser ? { kind: "user", id: options.currentUser.id } : { kind: "service", id: null });
524
+ await options.db.run(`INSERT INTO ${q(raybaseNotificationsTableName)}
525
+ ("id", "kind", "severity", "title", "body", "targetHref", "targetEntityType", "targetEntityId", "channelsJson",
526
+ "channelTargetsJson", "metadataJson", "dedupeKey", "actorKind", "actorId", "sourceType", "sourceId", "createdAt", "expiresAt")
527
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
528
+ notificationId,
529
+ input.kind ?? null,
530
+ severity,
531
+ input.title,
532
+ input.body ?? null,
533
+ input.target?.href ?? null,
534
+ input.target?.entity?.type ?? null,
535
+ input.target?.entity?.id ?? null,
536
+ stableJson(channels),
537
+ channelTargetsJson,
538
+ metadataJson,
539
+ input.dedupeKey ?? null,
540
+ actor.kind,
541
+ actor.id ?? null,
542
+ options.source?.type ?? null,
543
+ options.source?.id ?? null,
544
+ createdAt,
545
+ input.expiresAt ?? null
546
+ ]);
547
+ const result = { notificationId, deduped: false, recipients: [], deliveries: [], throttled: throttleResult.throttled, skipped: [] };
548
+ const recipientRows = [];
549
+ const explicitEmailTarget = emailChannelTarget(input.channelTargets);
550
+ for (const recipient of throttleResult.allowed) {
551
+ const seq = await nextRecipientSeq(options.db, recipient.recipientSubjectId);
552
+ const recipientId = randomId("nrec");
553
+ const feedStatus = wantsFeed ? "unread" : "none";
554
+ await options.db.run(`INSERT INTO ${q(raybaseNotificationRecipientsTableName)}
555
+ ("id", "notificationId", "recipientKind", "recipientSubjectId", "recipientRole", "recipientSeq", "feedStatus", "readAt", "archivedAt", "createdAt")
556
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)`, [
557
+ recipientId,
558
+ notificationId,
559
+ recipient.recipientKind,
560
+ recipient.recipientSubjectId,
561
+ recipient.recipientRole ?? null,
562
+ seq,
563
+ feedStatus,
564
+ createdAt
565
+ ]);
566
+ recipientRows.push({ ...recipient, id: recipientId, feedStatus, seq });
567
+ result.recipients.push({
568
+ recipientSubjectId: recipient.recipientSubjectId,
569
+ recipientKind: recipient.recipientKind,
570
+ feedItemId: wantsFeed ? recipientId : undefined
571
+ });
572
+ }
573
+ if (activeWebPushSubjects) {
574
+ for (const recipient of recipientRows) {
575
+ if (!activeWebPushSubjects.has(recipient.recipientSubjectId)) {
576
+ result.skipped.push({ recipientSubjectId: recipient.recipientSubjectId, channel: "webpush", reason: "no-subscriptions" });
577
+ }
578
+ }
579
+ }
580
+ for (const channel of externalChannels) {
581
+ const adapter = adapters[channel];
582
+ const targets = channel === "webpush"
583
+ ? recipientRows.filter((recipient) => activeWebPushSubjects?.has(recipient.recipientSubjectId))
584
+ : recipientRows.length ? recipientRows : [null];
585
+ for (const recipient of targets) {
586
+ const deliveryId = randomId("ndel");
587
+ const recipientSubjectId = recipient?.recipientSubjectId ?? null;
588
+ const recipientEmail = recipient?.recipientEmail ?? (channel === "email" ? explicitEmailTarget?.email : null) ?? null;
589
+ const recipientName = recipient?.recipientName ?? (channel === "email" ? explicitEmailTarget?.name : null) ?? null;
590
+ const idempotencyKey = input.dedupeKey ? `${input.dedupeKey}:${channel}:${recipientSubjectId ?? "target"}` : null;
591
+ await options.db.run(`INSERT INTO ${q(raybaseNotificationDeliveriesTableName)}
592
+ ("id", "notificationId", "recipientId", "recipientSubjectId", "recipientEmail", "recipientName", "channel", "adapter", "status", "attempt", "maxAttempts",
593
+ "idempotencyKey", "availableAt", "lastAttemptAt", "deliveredAt", "failedAt", "providerMessageId", "lastErrorJson", "createdAt", "updatedAt")
594
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?)`, [
595
+ deliveryId,
596
+ notificationId,
597
+ recipient?.id ?? null,
598
+ recipientSubjectId,
599
+ recipientEmail,
600
+ recipientName,
601
+ channel,
602
+ adapter.name,
603
+ resolved.delivery.maxAttempts,
604
+ idempotencyKey,
605
+ createdAt,
606
+ createdAt,
607
+ createdAt
608
+ ]);
609
+ result.deliveries.push({ deliveryId, channel, adapter: adapter.name, recipientSubjectId: recipientSubjectId ?? undefined, recipientEmail: recipientEmail ?? undefined });
610
+ }
611
+ }
612
+ if (wantsFeed) {
613
+ for (const recipient of recipientRows) {
614
+ const item = await getFeedItem(options.db, recipient.recipientSubjectId, recipient.id);
615
+ if (item)
616
+ await pushFeedUpdate(options, recipient.recipientSubjectId, "created", item);
617
+ }
618
+ }
619
+ if (result.deliveries.length)
620
+ await options.enqueueDelivery?.();
621
+ return result;
622
+ }
623
+ async function dedupedResult(db, notificationId) {
624
+ const recipients = await db.all(`SELECT "id", "recipientSubjectId", "recipientKind", "feedStatus"
625
+ FROM ${q(raybaseNotificationRecipientsTableName)}
626
+ WHERE "notificationId" = ?
627
+ ORDER BY "createdAt" ASC
628
+ LIMIT 100`, [notificationId]);
629
+ const deliveries = await db.all(`SELECT "id", "channel", "adapter", "recipientSubjectId", "recipientEmail"
630
+ FROM ${q(raybaseNotificationDeliveriesTableName)}
631
+ WHERE "notificationId" = ?
632
+ ORDER BY "createdAt" ASC
633
+ LIMIT 100`, [notificationId]);
634
+ return {
635
+ notificationId,
636
+ deduped: true,
637
+ recipients: recipients.map((row) => ({
638
+ recipientSubjectId: row.recipientSubjectId,
639
+ recipientKind: row.recipientKind,
640
+ feedItemId: row.feedStatus !== "none" ? row.id : undefined
641
+ })),
642
+ deliveries: deliveries.map((row) => ({
643
+ deliveryId: row.id,
644
+ channel: row.channel,
645
+ adapter: row.adapter,
646
+ recipientSubjectId: row.recipientSubjectId ?? undefined,
647
+ recipientEmail: row.recipientEmail ?? undefined
648
+ })),
649
+ throttled: [],
650
+ skipped: []
651
+ };
652
+ }
653
+ function normalizeThrottle(input) {
654
+ const key = typeof input.key === "string" ? input.key.trim() : "";
655
+ if (!key)
656
+ throw new NotificationError("Notification throttle key is required", "invalid-input");
657
+ if (key.length > notificationDefaults.throttleKeyMaxLength) {
658
+ throw new NotificationError(`Notification throttle key must be ${notificationDefaults.throttleKeyMaxLength} characters or fewer`, "invalid-input");
659
+ }
660
+ if (input.minInterval === undefined || input.minInterval === null || input.minInterval === "") {
661
+ throw new NotificationError("Notification throttle minInterval must be a valid duration", "invalid-input");
662
+ }
663
+ let parsedMs;
664
+ try {
665
+ parsedMs = durationMs(input.minInterval);
666
+ }
667
+ catch {
668
+ throw new NotificationError("Notification throttle minInterval must be a valid duration", "invalid-input");
669
+ }
670
+ const minIntervalMs = Math.min(notificationDefaults.throttleMaxIntervalMs, Math.max(notificationDefaults.throttleMinIntervalMs, parsedMs));
671
+ return { key, minIntervalMs };
672
+ }
673
+ async function applyNotificationThrottle(options, throttle, recipients, input, now) {
674
+ const allowed = [];
675
+ const throttled = [];
676
+ const nowMs = Date.parse(now);
677
+ const windowExpiresAt = new Date(nowMs + throttle.minIntervalMs).toISOString();
678
+ for (const recipient of recipients) {
679
+ const claimed = await options.db.first(`INSERT INTO ${q(raybaseNotificationThrottleWindowsTableName)}
680
+ ("id", "throttleKey", "recipientSubjectId", "lastSentAt", "windowExpiresAt", "suppressedCount", "firstSuppressedAt", "lastSuppressedJson", "updatedAt")
681
+ VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?)
682
+ ON CONFLICT("throttleKey", "recipientSubjectId") DO UPDATE SET
683
+ "lastSentAt" = excluded."lastSentAt",
684
+ "windowExpiresAt" = excluded."windowExpiresAt",
685
+ "suppressedCount" = 0,
686
+ "firstSuppressedAt" = NULL,
687
+ "lastSuppressedJson" = NULL,
688
+ "updatedAt" = excluded."updatedAt"
689
+ WHERE "windowExpiresAt" <= ?
690
+ RETURNING "id", "windowExpiresAt"`, [randomId("nthrottle"), throttle.key, recipient.recipientSubjectId, now, windowExpiresAt, now, now]);
691
+ if (claimed) {
692
+ allowed.push(recipient);
693
+ continue;
694
+ }
695
+ const suppressedJson = truncateJson({
696
+ title: input.title,
697
+ kind: input.kind ?? null,
698
+ createdAt: now
699
+ }, notificationDefaults.maxMetadataBytes).json;
700
+ const suppressed = await options.db.first(`UPDATE ${q(raybaseNotificationThrottleWindowsTableName)}
701
+ SET "suppressedCount" = "suppressedCount" + 1,
702
+ "firstSuppressedAt" = COALESCE("firstSuppressedAt", ?),
703
+ "lastSuppressedJson" = ?,
704
+ "updatedAt" = ?
705
+ WHERE "throttleKey" = ? AND "recipientSubjectId" = ? AND "windowExpiresAt" > ?
706
+ RETURNING "id", "windowExpiresAt"`, [now, suppressedJson, now, throttle.key, recipient.recipientSubjectId, now]);
707
+ if (suppressed) {
708
+ throttled.push({ recipientSubjectId: recipient.recipientSubjectId, until: suppressed.windowExpiresAt });
709
+ await options.audit?.("notification.throttled", raybaseNotificationThrottleWindowsTableName, suppressed.id, {
710
+ throttleKey: throttle.key,
711
+ recipientSubjectId: recipient.recipientSubjectId,
712
+ until: suppressed.windowExpiresAt
713
+ });
714
+ continue;
715
+ }
716
+ throw new NotificationError("Notification throttle window changed while sending; retry the send", "recipient-unavailable");
717
+ }
718
+ return { allowed, throttled };
719
+ }
720
+ function normalizeChannels(channels) {
721
+ const normalized = channels === undefined ? ["feed"] : Array.from(new Set(channels.map((channel) => channel.trim()).filter(Boolean)));
722
+ if (!normalized.length)
723
+ throw new NotificationError("Notification channels must not be empty", "invalid-input");
724
+ return normalized;
725
+ }
726
+ async function resolveRecipients(options, recipients, resolved) {
727
+ const output = new Map();
728
+ const directory = raybaseRuntimeProfile(options.env)?.directory?.notificationRecipients;
729
+ for (const recipient of recipients) {
730
+ if (recipient.kind === "current-user") {
731
+ if (!options.currentUser)
732
+ throw new NotificationError("current-user recipient requires a signed-in request context", "auth-required");
733
+ if (!options.currentUser.disabled) {
734
+ output.set(options.currentUser.id, {
735
+ recipientKind: "user",
736
+ recipientSubjectId: options.currentUser.id,
737
+ recipientEmail: options.currentUser.email || null,
738
+ recipientName: options.currentUser.name || null
739
+ });
740
+ }
741
+ continue;
742
+ }
743
+ const query = recipient.kind === "user"
744
+ ? { kind: "user", subjectId: recipient.userId }
745
+ : recipient.kind === "role"
746
+ ? { kind: "role", role: recipient.role }
747
+ : { kind: "operator", role: recipient.role };
748
+ if (directory) {
749
+ const users = await directory(query, options.env, raybaseRuntimeScope(options.env));
750
+ for (const user of users) {
751
+ output.set(user.subjectId, {
752
+ recipientKind: recipient.kind,
753
+ recipientSubjectId: user.subjectId,
754
+ ...(query.kind !== "user" ? { recipientRole: user.role ?? query.role ?? null } : {}),
755
+ recipientEmail: user.email ?? null,
756
+ recipientName: user.name ?? null
757
+ });
758
+ }
759
+ continue;
760
+ }
761
+ if (recipient.kind === "user") {
762
+ const user = await activeUser(options.db, recipient.userId);
763
+ if (user)
764
+ output.set(user.id, { recipientKind: "user", recipientSubjectId: user.id, recipientEmail: user.email || null, recipientName: user.name || null });
765
+ continue;
766
+ }
767
+ if (recipient.kind === "role") {
768
+ const users = await activeUsers(options.db);
769
+ for (const user of users) {
770
+ const roles = parseRoles(user.appRoles);
771
+ if (roles.includes(recipient.role)) {
772
+ output.set(user.id, { recipientKind: "role", recipientSubjectId: user.id, recipientRole: recipient.role, recipientEmail: user.email || null, recipientName: user.name || null });
773
+ }
774
+ }
775
+ continue;
776
+ }
777
+ if (recipient.kind === "operator") {
778
+ const users = await activeUsers(options.db);
779
+ for (const user of users) {
780
+ const operatorRole = normalizeStoredAdminRole(user.operatorRole);
781
+ if (hasAdminAccess(operatorRole)) {
782
+ if (!recipient.role || recipient.role === operatorRole) {
783
+ output.set(user.id, { recipientKind: "operator", recipientSubjectId: user.id, recipientRole: recipient.role ?? operatorRole, recipientEmail: user.email || null, recipientName: user.name || null });
784
+ }
785
+ }
786
+ }
787
+ }
788
+ }
789
+ if (output.size > resolved.maxRecipientsPerSend) {
790
+ throw new NotificationError(`Notification resolved ${output.size} recipients, exceeding the configured maximum ${resolved.maxRecipientsPerSend}`, "recipient-unavailable");
791
+ }
792
+ return [...output.values()];
793
+ }
794
+ async function activeUser(db, userId) {
795
+ const row = await db.first(`SELECT "id", "email", "name", "operatorRole", "appRoles", "banned" FROM "_auth_users" WHERE "id" = ?`, [userId]);
796
+ return row && !Boolean(row.banned) ? hostUserRowCeiling(row) : null;
797
+ }
798
+ async function activeUsers(db) {
799
+ const rows = await db.all(`SELECT "id", "email", "name", "operatorRole", "appRoles", "banned" FROM "_auth_users"`);
800
+ return rows.filter((row) => !Boolean(row.banned)).map(hostUserRowCeiling);
801
+ }
802
+ // Host users always hold the plain user role, so role-targeted notifications
803
+ // (admins, operators) never reach one whose row was edited.
804
+ function hostUserRowCeiling(row) {
805
+ return isHostUserId(row.id) ? { ...row, operatorRole: null, appRoles: JSON.stringify(["user"]) } : row;
806
+ }
807
+ function parseRoles(value) {
808
+ if (Array.isArray(value))
809
+ return Array.from(new Set(["user", ...value.map(String).filter(Boolean)]));
810
+ if (typeof value === "string" && value.trim()) {
811
+ try {
812
+ const parsed = JSON.parse(value);
813
+ if (Array.isArray(parsed))
814
+ return Array.from(new Set(["user", ...parsed.map(String).filter(Boolean)]));
815
+ }
816
+ catch {
817
+ return Array.from(new Set(["user", ...value.split(",").map((role) => role.trim()).filter(Boolean)]));
818
+ }
819
+ return Array.from(new Set(["user", value]));
820
+ }
821
+ return ["user"];
822
+ }
823
+ async function nextRecipientSeq(db, subjectId) {
824
+ const row = await db.first(`SELECT COALESCE(MAX("recipientSeq"), 0) + 1 AS "seq"
825
+ FROM ${q(raybaseNotificationRecipientsTableName)}
826
+ WHERE "recipientSubjectId" = ?`, [subjectId]);
827
+ return Number(row?.seq ?? 1);
828
+ }
829
+ function requireCurrentSubject(user) {
830
+ if (!user?.id)
831
+ throw new NotificationError("Notification feed requires a signed-in user", "auth-required");
832
+ return user.id;
833
+ }
834
+ async function unreadCount(db, subjectId) {
835
+ const row = await db.first(`SELECT
836
+ SUM(CASE WHEN "feedStatus" = 'unread' THEN 1 ELSE 0 END) AS "unread",
837
+ COALESCE(MAX("recipientSeq"), 0) AS "seq"
838
+ FROM ${q(raybaseNotificationRecipientsTableName)}
839
+ WHERE "recipientSubjectId" = ? AND "feedStatus" != 'none'`, [subjectId]);
840
+ return { unread: Number(row?.unread ?? 0), seq: Number(row?.seq ?? 0) };
841
+ }
842
+ async function getFeedItem(db, subjectId, feedItemId) {
843
+ const row = await db.first(`SELECT r."id" AS "feedItemId", r."notificationId", r."feedStatus", r."readAt", r."archivedAt", r."recipientSeq", r."createdAt",
844
+ n."id", n."kind", n."severity", n."title", n."body", n."targetHref", n."targetEntityType", n."targetEntityId",
845
+ n."metadataJson", n."expiresAt"
846
+ FROM ${q(raybaseNotificationRecipientsTableName)} r
847
+ INNER JOIN ${q(raybaseNotificationsTableName)} n ON n."id" = r."notificationId"
848
+ WHERE r."id" = ? AND r."recipientSubjectId" = ? AND r."feedStatus" != 'none'`, [feedItemId, subjectId]);
849
+ return row ? feedRowToItem(row) : null;
850
+ }
851
+ function feedRowToItem(row) {
852
+ return {
853
+ id: row.feedItemId,
854
+ notificationId: row.notificationId,
855
+ kind: row.kind,
856
+ severity: row.severity,
857
+ title: row.title,
858
+ body: row.body,
859
+ target: row.targetHref || row.targetEntityType || row.targetEntityId
860
+ ? {
861
+ href: row.targetHref ?? undefined,
862
+ entity: row.targetEntityType && row.targetEntityId ? { type: row.targetEntityType, id: row.targetEntityId } : undefined
863
+ }
864
+ : undefined,
865
+ metadata: safeParseJson(row.metadataJson),
866
+ feedStatus: row.feedStatus,
867
+ readAt: row.readAt,
868
+ archivedAt: row.archivedAt,
869
+ recipientSeq: Number(row.recipientSeq),
870
+ createdAt: row.createdAt,
871
+ expiresAt: row.expiresAt
872
+ };
873
+ }
874
+ async function pushFeedUpdate(options, subjectId, event, item) {
875
+ if (!options.notifyFeed)
876
+ return;
877
+ const count = await unreadCount(options.db, subjectId);
878
+ await options.notifyFeed({ subjectId, event, item, unread: count.unread, seq: count.seq });
879
+ }
880
+ function checkedStoredJson(value, maxBytes, label) {
881
+ if (value === undefined)
882
+ return null;
883
+ const json = stableJson(value);
884
+ assertUtf8Bytes(json, maxBytes, label);
885
+ return json;
886
+ }
887
+ function assertUtf8Bytes(value, maxBytes, label) {
888
+ if (new TextEncoder().encode(value).byteLength > maxBytes) {
889
+ throw new NotificationError(`${label} exceeds ${maxBytes} bytes`, "payload-too-large");
890
+ }
891
+ }
892
+ function safeParseJson(value) {
893
+ if (!value)
894
+ return undefined;
895
+ try {
896
+ return JSON.parse(value);
897
+ }
898
+ catch {
899
+ return value;
900
+ }
901
+ }
902
+ function redactSecrets(value, depth = 0) {
903
+ if (Array.isArray(value))
904
+ return value.map((item) => redactSecrets(item, depth + 1));
905
+ if (!value || typeof value !== "object")
906
+ return value;
907
+ if (depth > 8)
908
+ return "[truncated]";
909
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
910
+ key,
911
+ /secret|token|password|key|authorization|signature/i.test(key) ? "[redacted]" : redactSecrets(item, depth + 1)
912
+ ]));
913
+ }
914
+ export async function claimAndRunNotificationDeliveries(options) {
915
+ const resolved = resolveNotificationConfig(options.config, options.notifications);
916
+ assertEnabled(resolved);
917
+ const adapters = options.notifications?.channels ?? {};
918
+ const appNow = nowIso();
919
+ const wallNow = wallNowIso();
920
+ const rows = await options.db.all(`SELECT "id"
921
+ FROM ${q(raybaseNotificationDeliveriesTableName)}
922
+ WHERE ("status" = 'pending' AND "availableAt" <= ?)
923
+ OR ("status" = 'failed' AND "availableAt" <= ?)
924
+ ORDER BY "availableAt" ASC
925
+ LIMIT 25`, [appNow, wallNow]);
926
+ let processed = 0;
927
+ for (const row of rows) {
928
+ const claimedAt = nowIso();
929
+ await options.db.run(`UPDATE ${q(raybaseNotificationDeliveriesTableName)}
930
+ SET "status" = 'leased', "updatedAt" = ?
931
+ WHERE "id" = ? AND "status" IN ('pending', 'failed')`, [claimedAt, row.id]);
932
+ const delivery = await getDelivery(options.db, row.id);
933
+ if (!delivery || delivery.status !== "leased")
934
+ continue;
935
+ const adapter = adapters[delivery.channel];
936
+ if (!adapter) {
937
+ await failDelivery(options.db, delivery, new Error(`Notification adapter ${delivery.channel} is not configured`), "permanent");
938
+ processed += 1;
939
+ continue;
940
+ }
941
+ await runDelivery(options, adapter, delivery);
942
+ processed += 1;
943
+ }
944
+ return { processed };
945
+ }
946
+ async function runDelivery(options, adapter, delivery) {
947
+ const startedAt = nowIso();
948
+ const attempt = delivery.attempt + 1;
949
+ const attemptId = randomId("natt");
950
+ const startedMs = wallNowMs();
951
+ const isLocal = options.isLocal === true;
952
+ if (isLocal && !adapter.allowLocalSend) {
953
+ const requestSummary = truncateJson({ skippedLocal: true, channel: delivery.channel, adapter: adapter.name, notification: delivery.notification }, notificationDefaults.maxAttemptSummaryBytes).json;
954
+ await options.db.run(`INSERT INTO ${q(raybaseNotificationAttemptsTableName)}
955
+ ("id", "deliveryId", "attempt", "status", "startedAt", "endedAt", "durationMs", "requestSummaryJson", "responseSummaryJson", "errorJson")
956
+ VALUES (?, ?, ?, 'skipped_local', ?, ?, ?, ?, NULL, NULL)`, [attemptId, delivery.id, attempt, startedAt, nowIso(), 0, requestSummary]);
957
+ await options.db.run(`UPDATE ${q(raybaseNotificationDeliveriesTableName)}
958
+ SET "status" = 'skipped_local', "attempt" = ?, "lastAttemptAt" = ?, "updatedAt" = ?
959
+ WHERE "id" = ?`, [attempt, startedAt, nowIso(), delivery.id]);
960
+ return;
961
+ }
962
+ try {
963
+ const publicContext = {
964
+ env: options.env,
965
+ isLocal,
966
+ now: startedAt
967
+ };
968
+ const result = delivery.channel === "webpush"
969
+ ? await sendWebPushPrivately(adapter, delivery, publicContext, { db: options.db, audit: options.audit })
970
+ : await adapter.send(delivery, publicContext);
971
+ const endedAt = nowIso();
972
+ await options.db.run(`INSERT INTO ${q(raybaseNotificationAttemptsTableName)}
973
+ ("id", "deliveryId", "attempt", "status", "startedAt", "endedAt", "durationMs", "requestSummaryJson", "responseSummaryJson", "errorJson")
974
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
975
+ attemptId,
976
+ delivery.id,
977
+ attempt,
978
+ result.status === "skipped" ? "skipped_local" : "delivered",
979
+ startedAt,
980
+ endedAt,
981
+ wallNowMs() - startedMs,
982
+ truncateJson(redactSecrets(result.requestSummary), notificationDefaults.maxAttemptSummaryBytes).json,
983
+ truncateJson(redactSecrets(result.responseSummary), notificationDefaults.maxAttemptSummaryBytes).json
984
+ ]);
985
+ await options.db.run(`UPDATE ${q(raybaseNotificationDeliveriesTableName)}
986
+ SET "status" = ?, "attempt" = ?, "lastAttemptAt" = ?, "deliveredAt" = ?, "providerMessageId" = ?, "updatedAt" = ?
987
+ WHERE "id" = ?`, [result.status === "skipped" ? "skipped_local" : "delivered", attempt, startedAt, endedAt, result.providerMessageId ?? null, endedAt, delivery.id]);
988
+ }
989
+ catch (error) {
990
+ await failDelivery(options.db, { ...delivery, attempt }, error, adapter.classifyError?.(error) ?? defaultErrorClassification(error));
991
+ }
992
+ }
993
+ async function failDelivery(db, delivery, error, classification) {
994
+ const endedAt = nowIso();
995
+ const endedWallAt = wallNowIso();
996
+ const attempt = Math.max(delivery.attempt, 1);
997
+ const terminal = classification === "permanent" || attempt >= delivery.maxAttempts;
998
+ const status = terminal ? "permanent_failure" : "failed";
999
+ const errorJson = truncateEventData({
1000
+ message: error instanceof Error ? error.message : String(error),
1001
+ name: error instanceof Error ? error.name : "Error"
1002
+ });
1003
+ const errorEvidence = error && typeof error === "object" ? error : {};
1004
+ const requestSummaryJson = errorEvidence.requestSummary === undefined
1005
+ ? null
1006
+ : truncateJson(redactSecrets(errorEvidence.requestSummary), notificationDefaults.maxAttemptSummaryBytes).json;
1007
+ const responseSummaryJson = errorEvidence.responseSummary === undefined
1008
+ ? null
1009
+ : truncateJson(redactSecrets(errorEvidence.responseSummary), notificationDefaults.maxAttemptSummaryBytes).json;
1010
+ await db.run(`INSERT INTO ${q(raybaseNotificationAttemptsTableName)}
1011
+ ("id", "deliveryId", "attempt", "status", "startedAt", "endedAt", "durationMs", "requestSummaryJson", "responseSummaryJson", "errorJson")
1012
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [randomId("natt"), delivery.id, attempt, status, delivery.lastAttemptAt ?? endedAt, endedAt, 0, requestSummaryJson, responseSummaryJson, errorJson]);
1013
+ await db.run(`UPDATE ${q(raybaseNotificationDeliveriesTableName)}
1014
+ SET "status" = ?, "attempt" = ?, "lastAttemptAt" = ?, "failedAt" = ?, "availableAt" = ?, "lastErrorJson" = ?, "updatedAt" = ?
1015
+ WHERE "id" = ?`, [
1016
+ status,
1017
+ attempt,
1018
+ endedAt,
1019
+ endedAt,
1020
+ terminal ? endedAt : addDuration(endedWallAt, `${Math.min(3600, 30 * 2 ** Math.max(0, attempt - 1))} seconds`),
1021
+ errorJson,
1022
+ endedAt,
1023
+ delivery.id
1024
+ ]);
1025
+ }
1026
+ function defaultErrorClassification(error) {
1027
+ const status = typeof error === "object" && error ? Number(error.status) : 0;
1028
+ if (status === 408 || status === 409 || status === 425 || status === 429 || status >= 500 || !status)
1029
+ return "retry";
1030
+ return "permanent";
1031
+ }
1032
+ async function getDelivery(db, deliveryId) {
1033
+ const row = await db.first(`SELECT d.*, n."kind" AS "notificationKind", n."severity" AS "notificationSeverity", n."title" AS "notificationTitle",
1034
+ n."body" AS "notificationBody", n."targetHref", n."targetEntityType", n."targetEntityId", n."metadataJson",
1035
+ n."actorKind" AS "notificationActorKind", n."actorId" AS "notificationActorId", n."createdAt" AS "notificationCreatedAt"
1036
+ FROM ${q(raybaseNotificationDeliveriesTableName)} d
1037
+ INNER JOIN ${q(raybaseNotificationsTableName)} n ON n."id" = d."notificationId"
1038
+ WHERE d."id" = ?`, [deliveryId]);
1039
+ return row ? deliveryRow(row) : null;
1040
+ }
1041
+ function deliveryRow(row) {
1042
+ return {
1043
+ id: String(row.id),
1044
+ notificationId: String(row.notificationId),
1045
+ recipientId: row.recipientId ? String(row.recipientId) : null,
1046
+ recipientSubjectId: row.recipientSubjectId ? String(row.recipientSubjectId) : null,
1047
+ recipientEmail: row.recipientEmail ? String(row.recipientEmail) : null,
1048
+ recipientName: row.recipientName ? String(row.recipientName) : null,
1049
+ channel: String(row.channel),
1050
+ adapter: String(row.adapter),
1051
+ status: String(row.status),
1052
+ attempt: Number(row.attempt ?? 0),
1053
+ maxAttempts: Number(row.maxAttempts ?? 1),
1054
+ idempotencyKey: row.idempotencyKey ? String(row.idempotencyKey) : null,
1055
+ availableAt: String(row.availableAt),
1056
+ lastAttemptAt: row.lastAttemptAt ? String(row.lastAttemptAt) : null,
1057
+ deliveredAt: row.deliveredAt ? String(row.deliveredAt) : null,
1058
+ failedAt: row.failedAt ? String(row.failedAt) : null,
1059
+ providerMessageId: row.providerMessageId ? String(row.providerMessageId) : null,
1060
+ lastErrorJson: row.lastErrorJson ? String(row.lastErrorJson) : null,
1061
+ createdAt: String(row.createdAt),
1062
+ updatedAt: String(row.updatedAt),
1063
+ notification: {
1064
+ id: String(row.notificationId),
1065
+ kind: row.notificationKind ? String(row.notificationKind) : null,
1066
+ severity: String(row.notificationSeverity),
1067
+ title: String(row.notificationTitle),
1068
+ body: row.notificationBody ? String(row.notificationBody) : null,
1069
+ targetHref: row.targetHref ? String(row.targetHref) : null,
1070
+ targetEntityType: row.targetEntityType ? String(row.targetEntityType) : null,
1071
+ targetEntityId: row.targetEntityId ? String(row.targetEntityId) : null,
1072
+ metadata: safeParseJson(row.metadataJson ? String(row.metadataJson) : null),
1073
+ actorKind: String(row.notificationActorKind),
1074
+ actorId: row.notificationActorId ? String(row.notificationActorId) : null,
1075
+ createdAt: String(row.notificationCreatedAt)
1076
+ }
1077
+ };
1078
+ }
1079
+ export async function notificationAdminSummary(db, notifications) {
1080
+ const deliveryCounts = await db.all(`SELECT "status", "channel", "adapter", COUNT(*) AS "count"
1081
+ FROM ${q(raybaseNotificationDeliveriesTableName)}
1082
+ GROUP BY "status", "channel", "adapter"
1083
+ ORDER BY "status", "channel", "adapter"`);
1084
+ const inboundCounts = await db.all(`SELECT "status", "adapter", COUNT(*) AS "count"
1085
+ FROM ${q(raybaseInboundEventsTableName)}
1086
+ GROUP BY "status", "adapter"
1087
+ ORDER BY "status", "adapter"`);
1088
+ return {
1089
+ deliveryCounts: deliveryCounts.map((row) => ({ ...row, count: Number(row.count) })),
1090
+ inboundCounts: inboundCounts.map((row) => ({ ...row, count: Number(row.count) })),
1091
+ adapters: Object.values(notifications?.channels ?? {}).map((adapter) => ({
1092
+ channel: adapter.channel,
1093
+ adapter: adapter.name,
1094
+ description: adapter.description
1095
+ }))
1096
+ };
1097
+ }
1098
+ export async function notificationThrottleWindowsQuery(db, options = {}) {
1099
+ await compactNotificationThrottleWindows(db);
1100
+ const where = [];
1101
+ const params = [];
1102
+ if (options.activeOnly !== false) {
1103
+ where.push(`"windowExpiresAt" > ?`);
1104
+ params.push(nowIso());
1105
+ }
1106
+ params.push(Math.min(Math.max(1, options.limit ?? 50), 200), Math.max(0, options.offset ?? 0));
1107
+ const rows = await db.all(`SELECT *
1108
+ FROM ${q(raybaseNotificationThrottleWindowsTableName)}
1109
+ ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
1110
+ ORDER BY "windowExpiresAt" ASC, "updatedAt" DESC
1111
+ LIMIT ? OFFSET ?`, params);
1112
+ return { throttles: rows.map(notificationThrottleWindowRow) };
1113
+ }
1114
+ export async function resetNotificationThrottleWindow(db, id) {
1115
+ const row = await db.first(`SELECT * FROM ${q(raybaseNotificationThrottleWindowsTableName)} WHERE "id" = ?`, [id]);
1116
+ if (!row)
1117
+ throw new NotificationError("Notification throttle window was not found", "not-found");
1118
+ await db.run(`DELETE FROM ${q(raybaseNotificationThrottleWindowsTableName)} WHERE "id" = ?`, [id]);
1119
+ return { ok: true, throttle: notificationThrottleWindowRow(row) };
1120
+ }
1121
+ export async function compactNotificationThrottleWindows(db, now = nowIso()) {
1122
+ const expired = await db.all(`SELECT "id"
1123
+ FROM ${q(raybaseNotificationThrottleWindowsTableName)}
1124
+ WHERE "windowExpiresAt" <= ? AND "suppressedCount" = 0
1125
+ LIMIT 500`, [now]);
1126
+ for (const row of expired) {
1127
+ await db.run(`DELETE FROM ${q(raybaseNotificationThrottleWindowsTableName)} WHERE "id" = ?`, [row.id]);
1128
+ }
1129
+ return { deleted: expired.length };
1130
+ }
1131
+ function notificationThrottleWindowRow(row) {
1132
+ const subjectId = String(row.recipientSubjectId ?? "");
1133
+ return {
1134
+ id: String(row.id),
1135
+ throttleKey: String(row.throttleKey),
1136
+ recipientSubjectId: subjectId,
1137
+ maskedRecipientSubjectId: maskSubjectId(subjectId),
1138
+ lastSentAt: String(row.lastSentAt),
1139
+ windowExpiresAt: String(row.windowExpiresAt),
1140
+ suppressedCount: Number(row.suppressedCount ?? 0),
1141
+ firstSuppressedAt: row.firstSuppressedAt ? String(row.firstSuppressedAt) : null,
1142
+ lastSuppressed: safeParseJson(row.lastSuppressedJson),
1143
+ updatedAt: String(row.updatedAt)
1144
+ };
1145
+ }
1146
+ export async function notificationDeliveriesQuery(db, options = {}) {
1147
+ const where = [];
1148
+ const params = [];
1149
+ if (options.status) {
1150
+ where.push(`"status" = ?`);
1151
+ params.push(options.status);
1152
+ }
1153
+ if (options.channel) {
1154
+ where.push(`"channel" = ?`);
1155
+ params.push(options.channel);
1156
+ }
1157
+ if (options.adapter) {
1158
+ where.push(`"adapter" = ?`);
1159
+ params.push(options.adapter);
1160
+ }
1161
+ params.push(Math.min(Math.max(1, options.limit ?? 50), 200), Math.max(0, options.offset ?? 0));
1162
+ const rows = await db.all(`SELECT * FROM ${q(raybaseNotificationDeliveriesTableName)}
1163
+ ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
1164
+ ORDER BY "createdAt" DESC
1165
+ LIMIT ? OFFSET ?`, params);
1166
+ return { deliveries: rows.map(redactRow) };
1167
+ }
1168
+ export async function notificationDeliveryDetail(db, deliveryId) {
1169
+ const delivery = await getDelivery(db, deliveryId);
1170
+ if (!delivery)
1171
+ return null;
1172
+ const attempts = await db.all(`SELECT * FROM ${q(raybaseNotificationAttemptsTableName)} WHERE "deliveryId" = ? ORDER BY "attempt" ASC`, [deliveryId]);
1173
+ return { delivery: redactRow(delivery), attempts: attempts.map(redactRow) };
1174
+ }
1175
+ export async function retryNotificationDelivery(db, deliveryId) {
1176
+ const row = await db.first(`SELECT "status" FROM ${q(raybaseNotificationDeliveriesTableName)} WHERE "id" = ?`, [deliveryId]);
1177
+ if (!row)
1178
+ throw new NotificationError("Notification delivery was not found", "not-found");
1179
+ if (row.status !== "failed" && row.status !== "permanent_failure") {
1180
+ throw new NotificationError(`Cannot retry notification delivery in status ${row.status}`, "invalid-input");
1181
+ }
1182
+ await db.run(`UPDATE ${q(raybaseNotificationDeliveriesTableName)}
1183
+ SET "status" = 'pending', "availableAt" = ?, "failedAt" = NULL, "updatedAt" = ?
1184
+ WHERE "id" = ?`, [nowIso(), nowIso(), deliveryId]);
1185
+ return { ok: true, previousStatus: row.status };
1186
+ }
1187
+ export async function notificationInboundEventsQuery(db, options = {}) {
1188
+ const where = [];
1189
+ const params = [];
1190
+ if (options.status) {
1191
+ where.push(`"status" = ?`);
1192
+ params.push(options.status);
1193
+ }
1194
+ if (options.adapter) {
1195
+ where.push(`"adapter" = ?`);
1196
+ params.push(options.adapter);
1197
+ }
1198
+ params.push(Math.min(Math.max(1, options.limit ?? 50), 200), Math.max(0, options.offset ?? 0));
1199
+ const rows = await db.all(`SELECT "id", "adapter", "path", "status", "verified", "idempotencyKey", "providerEventId", "actorKind", "actorId", "receivedAt", "processedAt"
1200
+ FROM ${q(raybaseInboundEventsTableName)}
1201
+ ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
1202
+ ORDER BY "receivedAt" DESC
1203
+ LIMIT ? OFFSET ?`, params);
1204
+ return { events: rows.map(redactRow) };
1205
+ }
1206
+ export async function notificationInboundEventDetail(db, eventId) {
1207
+ const row = await db.first(`SELECT * FROM ${q(raybaseInboundEventsTableName)} WHERE "id" = ?`, [eventId]);
1208
+ return row ? redactRow(row) : null;
1209
+ }
1210
+ function redactRow(row) {
1211
+ const redacted = redactSecrets(row);
1212
+ if (typeof redacted.recipientEmail === "string")
1213
+ redacted.recipientEmail = maskEmail(redacted.recipientEmail);
1214
+ if (redacted.delivery && typeof redacted.delivery === "object" && typeof redacted.delivery.recipientEmail === "string") {
1215
+ redacted.delivery.recipientEmail = maskEmail(String(redacted.delivery.recipientEmail));
1216
+ }
1217
+ return redacted;
1218
+ }
1219
+ function emailChannelTarget(targets) {
1220
+ const emailTarget = targets?.email;
1221
+ if (typeof emailTarget === "string")
1222
+ return normalizeEmailTarget(emailTarget);
1223
+ if (!emailTarget || typeof emailTarget !== "object" || Array.isArray(emailTarget))
1224
+ return null;
1225
+ const target = emailTarget;
1226
+ const to = target.to;
1227
+ if (typeof to === "string") {
1228
+ const normalized = normalizeEmailTarget(to);
1229
+ const name = typeof target.name === "string" && target.name.trim() ? target.name.trim() : normalized?.name;
1230
+ return normalized ? { email: normalized.email, name } : null;
1231
+ }
1232
+ if (to && typeof to === "object" && !Array.isArray(to)) {
1233
+ const value = to;
1234
+ if (typeof value.email === "string") {
1235
+ const normalized = normalizeEmailTarget(value.email);
1236
+ const name = typeof value.name === "string" && value.name.trim() ? value.name.trim() : undefined;
1237
+ return normalized ? { email: normalized.email, name } : null;
1238
+ }
1239
+ }
1240
+ return null;
1241
+ }
1242
+ function normalizeEmailTarget(value) {
1243
+ const email = value.trim().toLowerCase();
1244
+ if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email))
1245
+ return null;
1246
+ return { email };
1247
+ }
1248
+ function maskEmail(value) {
1249
+ const [local = "", domain = ""] = value.split("@");
1250
+ if (!domain)
1251
+ return "[redacted-email]";
1252
+ return `${local.slice(0, 2)}${local.length > 2 ? "***" : "*"}@${domain}`;
1253
+ }
1254
+ function maskSubjectId(value) {
1255
+ if (!value)
1256
+ return "";
1257
+ if (value.length <= 12)
1258
+ return "redacted";
1259
+ return `${value.slice(0, 6)}...${value.slice(-4)}`;
1260
+ }
1261
+ export function createNotificationsOperatorEvidenceProvider(dbForContext, notifications) {
1262
+ return {
1263
+ id: "notifications",
1264
+ label: "Notifications",
1265
+ scopes: ["operator:notifications:read"],
1266
+ tools: [
1267
+ {
1268
+ name: "notification_health_summary",
1269
+ description: "Summarize notification adapters, delivery counts, and inbound webhook health.",
1270
+ scopes: ["operator:notifications:read"],
1271
+ inputSchema: z.object({}).strict(),
1272
+ handler: async (_args, ctx) => {
1273
+ const summary = await notificationAdminSummary(dbForContext({ env: ctx.env }), notifications);
1274
+ return {
1275
+ content: [{ type: "text", text: "Notification health summary." }],
1276
+ structuredContent: summary
1277
+ };
1278
+ }
1279
+ },
1280
+ {
1281
+ name: "notification_deliveries_query",
1282
+ description: "Query notification deliveries with redacted payload and error evidence.",
1283
+ scopes: ["operator:notifications:read"],
1284
+ inputSchema: z.object({ status: z.string().optional(), channel: z.string().optional(), adapter: z.string().optional(), limit: z.number().optional(), offset: z.number().optional() }).strict(),
1285
+ handler: async (args, ctx) => ({
1286
+ content: [{ type: "text", text: "Notification deliveries." }],
1287
+ structuredContent: await notificationDeliveriesQuery(dbForContext({ env: ctx.env }), args)
1288
+ })
1289
+ },
1290
+ {
1291
+ name: "notification_delivery_detail",
1292
+ description: "Inspect a notification delivery and its attempts.",
1293
+ scopes: ["operator:notifications:read"],
1294
+ inputSchema: z.object({ deliveryId: z.string().min(1) }).strict(),
1295
+ handler: async (args, ctx) => {
1296
+ const detail = await notificationDeliveryDetail(dbForContext({ env: ctx.env }), String(args.deliveryId));
1297
+ return {
1298
+ content: [{ type: "text", text: detail ? "Notification delivery detail." : "Notification delivery not found." }],
1299
+ structuredContent: { detail }
1300
+ };
1301
+ }
1302
+ },
1303
+ {
1304
+ name: "notification_inbound_events_query",
1305
+ description: "Query inbound notification/webhook receipts.",
1306
+ scopes: ["operator:notifications:read"],
1307
+ inputSchema: z.object({ status: z.string().optional(), adapter: z.string().optional(), limit: z.number().optional(), offset: z.number().optional() }).strict(),
1308
+ handler: async (args, ctx) => ({
1309
+ content: [{ type: "text", text: "Inbound notification events." }],
1310
+ structuredContent: await notificationInboundEventsQuery(dbForContext({ env: ctx.env }), args)
1311
+ })
1312
+ },
1313
+ {
1314
+ name: "notification_inbound_event_detail",
1315
+ description: "Inspect an inbound notification/webhook receipt.",
1316
+ scopes: ["operator:notifications:read"],
1317
+ inputSchema: z.object({ eventId: z.string().min(1) }).strict(),
1318
+ handler: async (args, ctx) => {
1319
+ const detail = await notificationInboundEventDetail(dbForContext({ env: ctx.env }), String(args.eventId));
1320
+ return {
1321
+ content: [{ type: "text", text: detail ? "Inbound notification event detail." : "Inbound notification event not found." }],
1322
+ structuredContent: { detail }
1323
+ };
1324
+ }
1325
+ }
1326
+ ],
1327
+ resources: [
1328
+ {
1329
+ uri: "raybase://notifications/summary",
1330
+ name: "Notification Summary",
1331
+ description: "Notification adapter and delivery health summary.",
1332
+ scopes: ["operator:notifications:read"],
1333
+ read: async (ctx) => ({
1334
+ uri: "raybase://notifications/summary",
1335
+ mimeType: "application/json",
1336
+ text: JSON.stringify(await notificationAdminSummary(dbForContext({ env: ctx.env }), notifications), null, 2)
1337
+ })
1338
+ },
1339
+ {
1340
+ uri: "raybase://notifications/failures",
1341
+ name: "Notification Failures",
1342
+ description: "Recent failed notification delivery evidence.",
1343
+ scopes: ["operator:notifications:read"],
1344
+ read: async (ctx) => {
1345
+ const db = dbForContext({ env: ctx.env });
1346
+ const failed = await notificationDeliveriesQuery(db, { status: "failed", limit: 50 });
1347
+ const permanent = await notificationDeliveriesQuery(db, { status: "permanent_failure", limit: 50 });
1348
+ return {
1349
+ uri: "raybase://notifications/failures",
1350
+ mimeType: "application/json",
1351
+ text: JSON.stringify({ failed: failed.deliveries, permanentFailures: permanent.deliveries }, null, 2)
1352
+ };
1353
+ }
1354
+ },
1355
+ {
1356
+ uri: "raybase://notifications/inbound/recent",
1357
+ name: "Recent Inbound Notification Events",
1358
+ description: "Recent inbound webhook receipts and statuses.",
1359
+ scopes: ["operator:notifications:read"],
1360
+ read: async (ctx) => ({
1361
+ uri: "raybase://notifications/inbound/recent",
1362
+ mimeType: "application/json",
1363
+ text: JSON.stringify(await notificationInboundEventsQuery(dbForContext({ env: ctx.env }), { limit: 50 }), null, 2)
1364
+ })
1365
+ }
1366
+ ]
1367
+ };
1368
+ }
1369
+ export async function handleInboundNotification(options) {
1370
+ const resolved = resolveNotificationConfig(options.config, options.notifications);
1371
+ assertEnabled(resolved);
1372
+ const adapter = options.adapter;
1373
+ const rawBody = new Uint8Array(await options.request.arrayBuffer());
1374
+ if (rawBody.byteLength > resolved.maxInboundBodyBytes) {
1375
+ return Response.json({ error: "Inbound notification body is too large" }, { status: 413 });
1376
+ }
1377
+ const receivedAt = nowIso();
1378
+ const ctx = { env: options.env, request: options.request };
1379
+ const requestFor = () => new Request(options.request.url, { method: options.request.method, headers: options.request.headers, body: rawBody });
1380
+ try {
1381
+ await adapter.verify(requestFor(), rawBody, ctx);
1382
+ }
1383
+ catch (error) {
1384
+ const eventId = randomId("inbound");
1385
+ await options.db.run(`INSERT INTO ${q(raybaseInboundEventsTableName)}
1386
+ ("id", "adapter", "path", "status", "verified", "idempotencyKey", "providerEventId", "actorKind", "actorId", "payloadJson", "resultJson", "errorJson", "receivedAt", "processedAt")
1387
+ VALUES (?, ?, ?, 'verification_failed', 0, NULL, NULL, 'service', ?, NULL, NULL, ?, ?, ?)`, [
1388
+ eventId,
1389
+ adapter.name,
1390
+ new URL(options.request.url).pathname,
1391
+ `webhook:${adapter.name}`,
1392
+ truncateEventData({ message: error instanceof Error ? error.message : String(error), headers: redactedHeaders(options.request.headers) }),
1393
+ receivedAt,
1394
+ nowIso()
1395
+ ]);
1396
+ return Response.json({ error: "Webhook verification failed" }, { status: 401 });
1397
+ }
1398
+ let parsed;
1399
+ try {
1400
+ parsed = await adapter.parse(requestFor(), rawBody, ctx);
1401
+ }
1402
+ catch (error) {
1403
+ const eventId = randomId("inbound");
1404
+ await options.db.run(`INSERT INTO ${q(raybaseInboundEventsTableName)}
1405
+ ("id", "adapter", "path", "status", "verified", "idempotencyKey", "providerEventId", "actorKind", "actorId", "payloadJson", "resultJson", "errorJson", "receivedAt", "processedAt")
1406
+ VALUES (?, ?, ?, 'parse_failed', 1, NULL, NULL, 'service', ?, NULL, NULL, ?, ?, ?)`, [eventId, adapter.name, new URL(options.request.url).pathname, `webhook:${adapter.name}`, truncateEventData({ message: error instanceof Error ? error.message : String(error) }), receivedAt, nowIso()]);
1407
+ return Response.json({ error: "Webhook parse failed" }, { status: 400 });
1408
+ }
1409
+ const idempotencyKey = parsed.idempotencyKey || (await sha256Hex(rawBody));
1410
+ const duplicate = await options.db.first(`SELECT * FROM ${q(raybaseInboundEventsTableName)} WHERE "adapter" = ? AND "idempotencyKey" = ?`, [adapter.name, idempotencyKey]);
1411
+ if (duplicate?.processedAt)
1412
+ return Response.json({ ok: true, duplicate: true });
1413
+ const eventId = duplicate?.id ? String(duplicate.id) : randomId("inbound");
1414
+ const payloadJson = truncateJson(redactSecrets(parsed.payload), resolved.maxMetadataBytes).json;
1415
+ if (!duplicate) {
1416
+ await options.db.run(`INSERT INTO ${q(raybaseInboundEventsTableName)}
1417
+ ("id", "adapter", "path", "status", "verified", "idempotencyKey", "providerEventId", "actorKind", "actorId", "payloadJson", "resultJson", "errorJson", "receivedAt", "processedAt")
1418
+ VALUES (?, ?, ?, 'verified', 1, ?, ?, ?, ?, ?, NULL, NULL, ?, NULL)`, [
1419
+ eventId,
1420
+ adapter.name,
1421
+ new URL(options.request.url).pathname,
1422
+ idempotencyKey,
1423
+ parsed.providerEventId ?? null,
1424
+ parsed.actorKind ?? "service",
1425
+ parsed.actorId ?? `webhook:${adapter.name}`,
1426
+ payloadJson,
1427
+ receivedAt
1428
+ ]);
1429
+ }
1430
+ const event = {
1431
+ id: eventId,
1432
+ adapter: adapter.name,
1433
+ path: new URL(options.request.url).pathname,
1434
+ status: "verified",
1435
+ verified: true,
1436
+ idempotencyKey,
1437
+ providerEventId: parsed.providerEventId ?? null,
1438
+ actorKind: parsed.actorKind ?? "service",
1439
+ actorId: parsed.actorId ?? `webhook:${adapter.name}`,
1440
+ payload: parsed.payload,
1441
+ receivedAt,
1442
+ processedAt: null
1443
+ };
1444
+ try {
1445
+ const result = await adapter.handle(event, {
1446
+ env: options.env,
1447
+ request: options.request,
1448
+ event,
1449
+ notifications: createNotificationsRuntime({ ...options, actor: { kind: "service", id: `webhook:${adapter.name}` }, currentUser: null }),
1450
+ jobs: {
1451
+ enqueue: options.enqueueJob ?? (async () => {
1452
+ throw new Error("Jobs are not configured for inbound notification handlers");
1453
+ })
1454
+ },
1455
+ audit: options.audit ?? (async () => { })
1456
+ });
1457
+ await options.db.run(`UPDATE ${q(raybaseInboundEventsTableName)}
1458
+ SET "status" = 'processed', "resultJson" = ?, "processedAt" = ?
1459
+ WHERE "id" = ?`, [truncateEventData(result), nowIso(), eventId]);
1460
+ await options.audit?.("notification.inbound.processed", raybaseInboundEventsTableName, eventId, { adapter: adapter.name });
1461
+ return Response.json({ ok: true, eventId });
1462
+ }
1463
+ catch (error) {
1464
+ await options.db.run(`UPDATE ${q(raybaseInboundEventsTableName)}
1465
+ SET "status" = 'handler_failed', "errorJson" = ?, "processedAt" = ?
1466
+ WHERE "id" = ?`, [truncateEventData({ message: error instanceof Error ? error.message : String(error), name: error instanceof Error ? error.name : "Error" }), nowIso(), eventId]);
1467
+ return Response.json({ error: "Webhook handler failed", eventId }, { status: 500 });
1468
+ }
1469
+ }
1470
+ function redactedHeaders(headers) {
1471
+ const output = {};
1472
+ for (const [key, value] of headers.entries()) {
1473
+ output[key] = /authorization|cookie|signature|token|secret/i.test(key) ? "[redacted]" : value.slice(0, 200);
1474
+ }
1475
+ return output;
1476
+ }
1477
+ export function webhookOutboundAdapter(options) {
1478
+ return {
1479
+ name: options.name,
1480
+ channel: "webhook",
1481
+ description: "Signed Raybase webhook delivery",
1482
+ allowLocalSend: options.allowLocalSend,
1483
+ async send(delivery, ctx) {
1484
+ const url = options.url(ctx.env);
1485
+ const secret = options.signingSecret(ctx.env);
1486
+ if (!url)
1487
+ throw Object.assign(new Error("Webhook URL is missing"), { status: 400 });
1488
+ if (!secret)
1489
+ throw Object.assign(new Error("Webhook signing secret is missing"), { status: 400 });
1490
+ const body = stableJson({
1491
+ notification: delivery.notification,
1492
+ delivery: {
1493
+ id: delivery.id,
1494
+ channel: delivery.channel,
1495
+ recipientSubjectId: delivery.recipientSubjectId,
1496
+ attempt: delivery.attempt + 1
1497
+ }
1498
+ });
1499
+ const timestamp = String(Math.floor(wallNowMs() / 1000));
1500
+ const signature = await hmacHex(secret, `${timestamp}.${body}`);
1501
+ const controller = new AbortController();
1502
+ const timeout = setTimeout(() => controller.abort("timeout"), options.timeoutMs ?? notificationDefaults.deliveryTimeoutMs);
1503
+ try {
1504
+ const response = await fetch(url, {
1505
+ method: "POST",
1506
+ headers: {
1507
+ "content-type": "application/json",
1508
+ "x-raybase-webhook-id": delivery.notificationId,
1509
+ "x-raybase-delivery-id": delivery.id,
1510
+ "x-raybase-timestamp": timestamp,
1511
+ "x-raybase-signature": `sha256=${signature}`,
1512
+ "idempotency-key": delivery.idempotencyKey ?? delivery.id,
1513
+ ...(options.headers?.(delivery, ctx) ?? {})
1514
+ },
1515
+ body,
1516
+ signal: controller.signal
1517
+ });
1518
+ const responseBody = await response.text().catch(() => "");
1519
+ if (!response.ok) {
1520
+ throw Object.assign(new Error(`Webhook responded with ${response.status}`), {
1521
+ status: response.status,
1522
+ responseBody: responseBody.slice(0, 2000)
1523
+ });
1524
+ }
1525
+ return {
1526
+ status: "delivered",
1527
+ providerMessageId: response.headers.get("x-request-id") ?? response.headers.get("x-message-id") ?? undefined,
1528
+ requestSummary: { url: redactUrl(url), bytes: body.length },
1529
+ responseSummary: { status: response.status, body: responseBody.slice(0, 1000) }
1530
+ };
1531
+ }
1532
+ finally {
1533
+ clearTimeout(timeout);
1534
+ }
1535
+ },
1536
+ classifyError(error) {
1537
+ return defaultErrorClassification(error);
1538
+ },
1539
+ async health(ctx) {
1540
+ return {
1541
+ ok: Boolean(options.url(ctx.env) && options.signingSecret(ctx.env)),
1542
+ label: options.name,
1543
+ details: {
1544
+ urlConfigured: Boolean(options.url(ctx.env)),
1545
+ signingSecretConfigured: Boolean(options.signingSecret(ctx.env))
1546
+ }
1547
+ };
1548
+ }
1549
+ };
1550
+ }
1551
+ export function webhookInboundAdapter(options) {
1552
+ return {
1553
+ name: options.name,
1554
+ path: options.path,
1555
+ async verify(request, rawBody, ctx) {
1556
+ const secret = options.signingSecret(ctx.env);
1557
+ if (!secret)
1558
+ throw new Error("Webhook signing secret is missing");
1559
+ const timestamp = request.headers.get("x-raybase-timestamp") ?? "";
1560
+ const signature = request.headers.get("x-raybase-signature") ?? "";
1561
+ if (!timestamp || !signature)
1562
+ throw new Error("Missing webhook signature");
1563
+ const timestampMs = Number(timestamp) * 1000;
1564
+ if (!Number.isFinite(timestampMs))
1565
+ throw new Error("Invalid webhook timestamp");
1566
+ const replayWindowMs = (options.replayWindowSeconds ?? 300) * 1000;
1567
+ if (Math.abs(wallNowMs() - timestampMs) > replayWindowMs)
1568
+ throw new Error("Webhook timestamp is outside the replay window");
1569
+ const rawBodyText = new TextDecoder().decode(rawBody);
1570
+ const expected = await hmacHex(secret, `${timestamp}.${rawBodyText}`);
1571
+ if (!constantTimeEqual(normalizeSignature(signature), expected))
1572
+ throw new Error("Invalid webhook signature");
1573
+ return true;
1574
+ },
1575
+ async parse(request, rawBody) {
1576
+ if (options.parse)
1577
+ return options.parse(request);
1578
+ const idempotencyKey = request.headers.get("x-raybase-webhook-id") ??
1579
+ request.headers.get("idempotency-key") ??
1580
+ await sha256Hex(rawBody);
1581
+ const payload = await request.json().catch(() => ({}));
1582
+ return { idempotencyKey, providerEventId: request.headers.get("x-raybase-webhook-id") ?? undefined, payload };
1583
+ },
1584
+ handle: options.handle
1585
+ };
1586
+ }
1587
+ function normalizeSignature(signature) {
1588
+ return signature.startsWith("sha256=") ? signature.slice("sha256=".length) : signature;
1589
+ }
1590
+ function constantTimeEqual(left, right) {
1591
+ const a = new TextEncoder().encode(left);
1592
+ const b = new TextEncoder().encode(right);
1593
+ if (a.length !== b.length)
1594
+ return false;
1595
+ let diff = 0;
1596
+ for (let index = 0; index < a.length; index += 1)
1597
+ diff |= a[index] ^ b[index];
1598
+ return diff === 0;
1599
+ }
1600
+ async function hmacHex(secret, value) {
1601
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
1602
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
1603
+ return hex(new Uint8Array(signature));
1604
+ }
1605
+ function hex(bytes) {
1606
+ return Array.from(bytes)
1607
+ .map((byte) => byte.toString(16).padStart(2, "0"))
1608
+ .join("");
1609
+ }
1610
+ function redactUrl(value) {
1611
+ try {
1612
+ const url = new URL(value);
1613
+ url.username = "";
1614
+ url.password = "";
1615
+ url.search = "";
1616
+ return url.toString();
1617
+ }
1618
+ catch {
1619
+ return "[invalid-url]";
1620
+ }
1621
+ }
1622
+ export function notificationJobPrincipal() {
1623
+ return { kind: "service", service: raybaseNotificationServicePrincipal };
1624
+ }
1625
+ //# sourceMappingURL=notifications.js.map