@kelpie/server 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (347) hide show
  1. package/README.md +5 -2
  2. package/dist/index.d.ts +17 -8
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +8 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/actor.d.ts +9 -1
  7. package/dist/lib/actor.d.ts.map +1 -1
  8. package/dist/lib/actor.js +13 -0
  9. package/dist/lib/actor.js.map +1 -1
  10. package/dist/lib/config.d.ts +44 -6
  11. package/dist/lib/config.d.ts.map +1 -1
  12. package/dist/lib/config.js +9 -5
  13. package/dist/lib/config.js.map +1 -1
  14. package/dist/lib/email.d.ts +15 -63
  15. package/dist/lib/email.d.ts.map +1 -1
  16. package/dist/lib/email.js +13 -65
  17. package/dist/lib/email.js.map +1 -1
  18. package/dist/lib/fromEnv.d.ts +60 -0
  19. package/dist/lib/fromEnv.d.ts.map +1 -0
  20. package/dist/lib/fromEnv.js +94 -0
  21. package/dist/lib/fromEnv.js.map +1 -0
  22. package/dist/lib/ids.d.ts +6 -0
  23. package/dist/lib/ids.d.ts.map +1 -1
  24. package/dist/lib/ids.js +6 -0
  25. package/dist/lib/ids.js.map +1 -1
  26. package/dist/lib/kelpieConfigFile.d.ts +123 -0
  27. package/dist/lib/kelpieConfigFile.d.ts.map +1 -0
  28. package/dist/lib/kelpieConfigFile.js +111 -0
  29. package/dist/lib/kelpieConfigFile.js.map +1 -0
  30. package/dist/lib/logger.d.ts +38 -7
  31. package/dist/lib/logger.d.ts.map +1 -1
  32. package/dist/lib/logger.js +58 -20
  33. package/dist/lib/logger.js.map +1 -1
  34. package/dist/modules/agent-tasks/index.d.ts.map +1 -1
  35. package/dist/modules/agent-tasks/index.js +4 -1
  36. package/dist/modules/agent-tasks/index.js.map +1 -1
  37. package/dist/modules/agent-tasks/prompt.d.ts +36 -6
  38. package/dist/modules/agent-tasks/prompt.d.ts.map +1 -1
  39. package/dist/modules/agent-tasks/prompt.js +33 -4
  40. package/dist/modules/agent-tasks/prompt.js.map +1 -1
  41. package/dist/modules/agent-tasks/service.d.ts.map +1 -1
  42. package/dist/modules/agent-tasks/service.js +3 -2
  43. package/dist/modules/agent-tasks/service.js.map +1 -1
  44. package/dist/modules/agent-tasks/wire.d.ts +11 -0
  45. package/dist/modules/agent-tasks/wire.d.ts.map +1 -1
  46. package/dist/modules/agent-tasks/wire.js +1 -0
  47. package/dist/modules/agent-tasks/wire.js.map +1 -1
  48. package/dist/modules/auth/index.d.ts.map +1 -1
  49. package/dist/modules/auth/index.js +8 -7
  50. package/dist/modules/auth/index.js.map +1 -1
  51. package/dist/modules/companies/events.d.ts +22 -0
  52. package/dist/modules/companies/events.d.ts.map +1 -0
  53. package/dist/modules/companies/events.js +8 -0
  54. package/dist/modules/companies/events.js.map +1 -0
  55. package/dist/modules/companies/index.d.ts.map +1 -1
  56. package/dist/modules/companies/index.js +2 -0
  57. package/dist/modules/companies/index.js.map +1 -1
  58. package/dist/modules/companies/service.d.ts +1 -0
  59. package/dist/modules/companies/service.d.ts.map +1 -1
  60. package/dist/modules/companies/service.js +8 -11
  61. package/dist/modules/companies/service.js.map +1 -1
  62. package/dist/modules/core.d.ts.map +1 -1
  63. package/dist/modules/core.js +4 -0
  64. package/dist/modules/core.js.map +1 -1
  65. package/dist/modules/deals/events.d.ts +31 -0
  66. package/dist/modules/deals/events.d.ts.map +1 -0
  67. package/dist/modules/deals/events.js +12 -0
  68. package/dist/modules/deals/events.js.map +1 -0
  69. package/dist/modules/deals/index.d.ts.map +1 -1
  70. package/dist/modules/deals/index.js +2 -0
  71. package/dist/modules/deals/index.js.map +1 -1
  72. package/dist/modules/deals/service.d.ts +1 -0
  73. package/dist/modules/deals/service.d.ts.map +1 -1
  74. package/dist/modules/deals/service.js +9 -18
  75. package/dist/modules/deals/service.js.map +1 -1
  76. package/dist/modules/decisions/events.d.ts +21 -0
  77. package/dist/modules/decisions/events.d.ts.map +1 -0
  78. package/dist/modules/decisions/events.js +11 -0
  79. package/dist/modules/decisions/events.js.map +1 -0
  80. package/dist/modules/decisions/index.d.ts.map +1 -1
  81. package/dist/modules/decisions/index.js +2 -0
  82. package/dist/modules/decisions/index.js.map +1 -1
  83. package/dist/modules/decisions/service.d.ts +1 -0
  84. package/dist/modules/decisions/service.d.ts.map +1 -1
  85. package/dist/modules/decisions/service.js +4 -7
  86. package/dist/modules/decisions/service.js.map +1 -1
  87. package/dist/modules/forms/events.d.ts +37 -0
  88. package/dist/modules/forms/events.d.ts.map +1 -0
  89. package/dist/modules/forms/events.js +15 -0
  90. package/dist/modules/forms/events.js.map +1 -0
  91. package/dist/modules/forms/index.d.ts.map +1 -1
  92. package/dist/modules/forms/index.js +2 -0
  93. package/dist/modules/forms/index.js.map +1 -1
  94. package/dist/modules/forms/service.d.ts +1 -0
  95. package/dist/modules/forms/service.d.ts.map +1 -1
  96. package/dist/modules/forms/service.js +8 -11
  97. package/dist/modules/forms/service.js.map +1 -1
  98. package/dist/modules/forms/submission.d.ts +5 -0
  99. package/dist/modules/forms/submission.d.ts.map +1 -1
  100. package/dist/modules/forms/submission.js +54 -28
  101. package/dist/modules/forms/submission.js.map +1 -1
  102. package/dist/modules/handbook/events.d.ts +28 -0
  103. package/dist/modules/handbook/events.d.ts.map +1 -0
  104. package/dist/modules/handbook/events.js +14 -0
  105. package/dist/modules/handbook/events.js.map +1 -0
  106. package/dist/modules/handbook/index.d.ts.map +1 -1
  107. package/dist/modules/handbook/index.js +2 -0
  108. package/dist/modules/handbook/index.js.map +1 -1
  109. package/dist/modules/handbook/service.d.ts +1 -0
  110. package/dist/modules/handbook/service.d.ts.map +1 -1
  111. package/dist/modules/handbook/service.js +8 -15
  112. package/dist/modules/handbook/service.js.map +1 -1
  113. package/dist/modules/hiring/candidates.d.ts +1 -0
  114. package/dist/modules/hiring/candidates.d.ts.map +1 -1
  115. package/dist/modules/hiring/candidates.js +8 -11
  116. package/dist/modules/hiring/candidates.js.map +1 -1
  117. package/dist/modules/hiring/events.d.ts +35 -0
  118. package/dist/modules/hiring/events.d.ts.map +1 -0
  119. package/dist/modules/hiring/events.js +11 -0
  120. package/dist/modules/hiring/events.js.map +1 -0
  121. package/dist/modules/hiring/index.d.ts.map +1 -1
  122. package/dist/modules/hiring/index.js +2 -0
  123. package/dist/modules/hiring/index.js.map +1 -1
  124. package/dist/modules/hiring/roles.d.ts +1 -0
  125. package/dist/modules/hiring/roles.d.ts.map +1 -1
  126. package/dist/modules/hiring/roles.js +8 -11
  127. package/dist/modules/hiring/roles.js.map +1 -1
  128. package/dist/modules/import-export/events.d.ts +22 -0
  129. package/dist/modules/import-export/events.d.ts.map +1 -0
  130. package/dist/modules/import-export/events.js +12 -0
  131. package/dist/modules/import-export/events.js.map +1 -0
  132. package/dist/modules/import-export/index.d.ts.map +1 -1
  133. package/dist/modules/import-export/index.js +2 -0
  134. package/dist/modules/import-export/index.js.map +1 -1
  135. package/dist/modules/import-export/service.d.ts +5 -0
  136. package/dist/modules/import-export/service.d.ts.map +1 -1
  137. package/dist/modules/import-export/service.js +53 -14
  138. package/dist/modules/import-export/service.js.map +1 -1
  139. package/dist/modules/notes/events.d.ts +22 -0
  140. package/dist/modules/notes/events.d.ts.map +1 -0
  141. package/dist/modules/notes/events.js +12 -0
  142. package/dist/modules/notes/events.js.map +1 -0
  143. package/dist/modules/notes/index.d.ts.map +1 -1
  144. package/dist/modules/notes/index.js +2 -0
  145. package/dist/modules/notes/index.js.map +1 -1
  146. package/dist/modules/notes/service.d.ts +1 -0
  147. package/dist/modules/notes/service.d.ts.map +1 -1
  148. package/dist/modules/notes/service.js +4 -7
  149. package/dist/modules/notes/service.js.map +1 -1
  150. package/dist/modules/opportunities/events.d.ts +31 -0
  151. package/dist/modules/opportunities/events.d.ts.map +1 -0
  152. package/dist/modules/opportunities/events.js +12 -0
  153. package/dist/modules/opportunities/events.js.map +1 -0
  154. package/dist/modules/opportunities/index.d.ts.map +1 -1
  155. package/dist/modules/opportunities/index.js +2 -0
  156. package/dist/modules/opportunities/index.js.map +1 -1
  157. package/dist/modules/opportunities/service.d.ts +1 -0
  158. package/dist/modules/opportunities/service.d.ts.map +1 -1
  159. package/dist/modules/opportunities/service.js +9 -18
  160. package/dist/modules/opportunities/service.js.map +1 -1
  161. package/dist/modules/partnerships/events.d.ts +31 -0
  162. package/dist/modules/partnerships/events.d.ts.map +1 -0
  163. package/dist/modules/partnerships/events.js +12 -0
  164. package/dist/modules/partnerships/events.js.map +1 -0
  165. package/dist/modules/partnerships/index.d.ts.map +1 -1
  166. package/dist/modules/partnerships/index.js +2 -0
  167. package/dist/modules/partnerships/index.js.map +1 -1
  168. package/dist/modules/partnerships/service.d.ts +1 -0
  169. package/dist/modules/partnerships/service.d.ts.map +1 -1
  170. package/dist/modules/partnerships/service.js +9 -18
  171. package/dist/modules/partnerships/service.js.map +1 -1
  172. package/dist/modules/people/events.d.ts +28 -0
  173. package/dist/modules/people/events.d.ts.map +1 -0
  174. package/dist/modules/people/events.js +18 -0
  175. package/dist/modules/people/events.js.map +1 -0
  176. package/dist/modules/people/index.d.ts.map +1 -1
  177. package/dist/modules/people/index.js +2 -0
  178. package/dist/modules/people/index.js.map +1 -1
  179. package/dist/modules/people/service.d.ts +1 -0
  180. package/dist/modules/people/service.d.ts.map +1 -1
  181. package/dist/modules/people/service.js +8 -11
  182. package/dist/modules/people/service.js.map +1 -1
  183. package/dist/modules/pipelines/service.d.ts +4 -0
  184. package/dist/modules/pipelines/service.d.ts.map +1 -1
  185. package/dist/modules/pipelines/service.js +31 -8
  186. package/dist/modules/pipelines/service.js.map +1 -1
  187. package/dist/modules/plans/events.d.ts +21 -0
  188. package/dist/modules/plans/events.d.ts.map +1 -0
  189. package/dist/modules/plans/events.js +11 -0
  190. package/dist/modules/plans/events.js.map +1 -0
  191. package/dist/modules/plans/index.d.ts.map +1 -1
  192. package/dist/modules/plans/index.js +2 -0
  193. package/dist/modules/plans/index.js.map +1 -1
  194. package/dist/modules/plans/service.d.ts +1 -0
  195. package/dist/modules/plans/service.d.ts.map +1 -1
  196. package/dist/modules/plans/service.js +6 -14
  197. package/dist/modules/plans/service.js.map +1 -1
  198. package/dist/modules/positions/events.d.ts +22 -0
  199. package/dist/modules/positions/events.d.ts.map +1 -0
  200. package/dist/modules/positions/events.js +8 -0
  201. package/dist/modules/positions/events.js.map +1 -0
  202. package/dist/modules/positions/index.d.ts.map +1 -1
  203. package/dist/modules/positions/index.js +2 -0
  204. package/dist/modules/positions/index.js.map +1 -1
  205. package/dist/modules/positions/service.d.ts +1 -0
  206. package/dist/modules/positions/service.d.ts.map +1 -1
  207. package/dist/modules/positions/service.js +8 -11
  208. package/dist/modules/positions/service.js.map +1 -1
  209. package/dist/modules/raises/events.d.ts +31 -0
  210. package/dist/modules/raises/events.d.ts.map +1 -0
  211. package/dist/modules/raises/events.js +12 -0
  212. package/dist/modules/raises/events.js.map +1 -0
  213. package/dist/modules/raises/index.d.ts.map +1 -1
  214. package/dist/modules/raises/index.js +2 -0
  215. package/dist/modules/raises/index.js.map +1 -1
  216. package/dist/modules/raises/service.d.ts +1 -0
  217. package/dist/modules/raises/service.d.ts.map +1 -1
  218. package/dist/modules/raises/service.js +9 -18
  219. package/dist/modules/raises/service.js.map +1 -1
  220. package/dist/modules/reseal.d.ts +55 -0
  221. package/dist/modules/reseal.d.ts.map +1 -1
  222. package/dist/modules/reseal.js +90 -1
  223. package/dist/modules/reseal.js.map +1 -1
  224. package/dist/modules/smtp-email/index.d.ts +69 -0
  225. package/dist/modules/smtp-email/index.d.ts.map +1 -0
  226. package/dist/modules/smtp-email/index.js +82 -0
  227. package/dist/modules/smtp-email/index.js.map +1 -0
  228. package/dist/modules/webhooks/index.d.ts.map +1 -1
  229. package/dist/modules/webhooks/index.js +5 -1
  230. package/dist/modules/webhooks/index.js.map +1 -1
  231. package/dist/modules/webhooks/payloads.d.ts +22 -7
  232. package/dist/modules/webhooks/payloads.d.ts.map +1 -1
  233. package/dist/modules/webhooks/payloads.js +63 -44
  234. package/dist/modules/webhooks/payloads.js.map +1 -1
  235. package/dist/modules/workspace/events.d.ts +52 -0
  236. package/dist/modules/workspace/events.d.ts.map +1 -0
  237. package/dist/modules/workspace/events.js +16 -0
  238. package/dist/modules/workspace/events.js.map +1 -0
  239. package/dist/modules/workspace/index.d.ts.map +1 -1
  240. package/dist/modules/workspace/index.js +6 -2
  241. package/dist/modules/workspace/index.js.map +1 -1
  242. package/dist/modules/workspace/service.d.ts +1 -0
  243. package/dist/modules/workspace/service.d.ts.map +1 -1
  244. package/dist/modules/workspace/service.js +13 -15
  245. package/dist/modules/workspace/service.js.map +1 -1
  246. package/dist/modules/workspace/workspaceAccessMiddleware.d.ts.map +1 -1
  247. package/dist/modules/workspace/workspaceAccessMiddleware.js +11 -3
  248. package/dist/modules/workspace/workspaceAccessMiddleware.js.map +1 -1
  249. package/dist/runtime/events.d.ts +86 -108
  250. package/dist/runtime/events.d.ts.map +1 -1
  251. package/dist/runtime/events.js +123 -52
  252. package/dist/runtime/events.js.map +1 -1
  253. package/dist/runtime/module.d.ts +54 -2
  254. package/dist/runtime/module.d.ts.map +1 -1
  255. package/dist/runtime/registry.d.ts +23 -0
  256. package/dist/runtime/registry.d.ts.map +1 -1
  257. package/dist/runtime/registry.js +102 -2
  258. package/dist/runtime/registry.js.map +1 -1
  259. package/dist/runtime/transaction.d.ts +29 -4
  260. package/dist/runtime/transaction.d.ts.map +1 -1
  261. package/dist/runtime/transaction.js +48 -7
  262. package/dist/runtime/transaction.js.map +1 -1
  263. package/dist/testing/app.d.ts.map +1 -1
  264. package/dist/testing/app.js +11 -3
  265. package/dist/testing/app.js.map +1 -1
  266. package/dist/testing/database.d.ts.map +1 -1
  267. package/dist/testing/database.js +6 -3
  268. package/dist/testing/database.js.map +1 -1
  269. package/dist/testing/services.d.ts +21 -1
  270. package/dist/testing/services.d.ts.map +1 -1
  271. package/dist/testing/services.js +20 -5
  272. package/dist/testing/services.js.map +1 -1
  273. package/package.json +5 -2
  274. package/src/index.ts +44 -9
  275. package/src/lib/actor.ts +15 -1
  276. package/src/lib/config.ts +56 -7
  277. package/src/lib/email.ts +22 -92
  278. package/src/lib/fromEnv.ts +143 -0
  279. package/src/lib/ids.ts +6 -0
  280. package/src/lib/kelpieConfigFile.ts +283 -0
  281. package/src/lib/logger.ts +92 -34
  282. package/src/modules/agent-tasks/index.ts +4 -1
  283. package/src/modules/agent-tasks/prompt.ts +57 -9
  284. package/src/modules/agent-tasks/service.ts +3 -2
  285. package/src/modules/agent-tasks/wire.ts +12 -0
  286. package/src/modules/auth/index.ts +11 -9
  287. package/src/modules/companies/events.ts +25 -0
  288. package/src/modules/companies/index.ts +2 -0
  289. package/src/modules/companies/service.ts +8 -11
  290. package/src/modules/core.ts +4 -0
  291. package/src/modules/deals/events.ts +34 -0
  292. package/src/modules/deals/index.ts +2 -0
  293. package/src/modules/deals/service.ts +17 -18
  294. package/src/modules/decisions/events.ts +24 -0
  295. package/src/modules/decisions/index.ts +2 -0
  296. package/src/modules/decisions/service.ts +8 -7
  297. package/src/modules/forms/events.ts +37 -0
  298. package/src/modules/forms/index.ts +2 -0
  299. package/src/modules/forms/service.ts +12 -11
  300. package/src/modules/forms/submission.ts +80 -29
  301. package/src/modules/handbook/events.ts +31 -0
  302. package/src/modules/handbook/index.ts +2 -0
  303. package/src/modules/handbook/service.ts +8 -15
  304. package/src/modules/hiring/candidates.ts +8 -11
  305. package/src/modules/hiring/events.ts +36 -0
  306. package/src/modules/hiring/index.ts +2 -0
  307. package/src/modules/hiring/roles.ts +8 -11
  308. package/src/modules/import-export/events.ts +25 -0
  309. package/src/modules/import-export/index.ts +2 -0
  310. package/src/modules/import-export/service.ts +83 -15
  311. package/src/modules/notes/events.ts +25 -0
  312. package/src/modules/notes/index.ts +2 -0
  313. package/src/modules/notes/service.ts +8 -7
  314. package/src/modules/opportunities/events.ts +34 -0
  315. package/src/modules/opportunities/index.ts +2 -0
  316. package/src/modules/opportunities/service.ts +17 -18
  317. package/src/modules/partnerships/events.ts +34 -0
  318. package/src/modules/partnerships/index.ts +2 -0
  319. package/src/modules/partnerships/service.ts +17 -18
  320. package/src/modules/people/events.ts +36 -0
  321. package/src/modules/people/index.ts +2 -0
  322. package/src/modules/people/service.ts +97 -87
  323. package/src/modules/pipelines/service.ts +47 -8
  324. package/src/modules/plans/events.ts +24 -0
  325. package/src/modules/plans/index.ts +2 -0
  326. package/src/modules/plans/service.ts +14 -14
  327. package/src/modules/positions/events.ts +25 -0
  328. package/src/modules/positions/index.ts +2 -0
  329. package/src/modules/positions/service.ts +8 -11
  330. package/src/modules/raises/events.ts +34 -0
  331. package/src/modules/raises/index.ts +2 -0
  332. package/src/modules/raises/service.ts +17 -18
  333. package/src/modules/reseal.ts +144 -1
  334. package/src/modules/smtp-email/index.ts +119 -0
  335. package/src/modules/webhooks/index.ts +5 -1
  336. package/src/modules/webhooks/payloads.ts +94 -48
  337. package/src/modules/workspace/events.ts +46 -0
  338. package/src/modules/workspace/index.ts +6 -2
  339. package/src/modules/workspace/service.ts +37 -15
  340. package/src/modules/workspace/workspaceAccessMiddleware.ts +12 -3
  341. package/src/runtime/events.ts +268 -107
  342. package/src/runtime/module.ts +55 -2
  343. package/src/runtime/registry.ts +169 -1
  344. package/src/runtime/transaction.ts +94 -17
  345. package/src/testing/app.ts +11 -3
  346. package/src/testing/database.ts +6 -3
  347. package/src/testing/services.ts +34 -5
@@ -9,7 +9,9 @@ import type { EntitlementRegistry } from '../../runtime/entitlements.ts'
9
9
  import { limitFor } from '../../runtime/entitlements.ts'
10
10
  import { moduleCapabilityName } from '../../runtime/moduleConfig.ts'
11
11
  import type { TransactionScope } from '../../runtime/transaction.ts'
12
+ import { toEventActor } from '../../lib/actor.ts'
12
13
  import type { Actor, SessionActor } from '../auth/actor.ts'
14
+ import './events.ts'
13
15
  import * as authRepository from '../auth/repository.ts'
14
16
  import { SEATS_LIMIT } from './capabilities.ts'
15
17
  import * as repository from './repository.ts'
@@ -460,11 +462,19 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
460
462
  // The session that created the workspace should land in it.
461
463
  await authRepository.setActiveWorkspace(tx, actor.sessionId, workspaceId)
462
464
 
463
- events.emit('workspace.created', { workspaceId, slug: workspace.slug })
464
- events.emit('member.joined', { workspaceId, memberId, userId: actor.userId })
465
+ events.emit(
466
+ 'workspace.workspace.created',
467
+ { type: 'workspace', id: workspaceId },
468
+ { slug: workspace.slug },
469
+ )
470
+ events.emit(
471
+ 'workspace.member.joined',
472
+ { type: 'member', id: memberId },
473
+ { userId: actor.userId },
474
+ )
465
475
 
466
476
  return toWorkspaceView(workspace)
467
- })
477
+ }, { workspaceId, actor: toEventActor(actor) })
468
478
  },
469
479
 
470
480
  async get(actor, workspaceId) {
@@ -526,8 +536,12 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
526
536
  await dependencies.transaction(async ({ tx, events }) => {
527
537
  await repository.deleteWorkspace(tx, workspaceId)
528
538
 
529
- events.emit('workspace.deleted', { workspaceId, slug: workspace.slug })
530
- })
539
+ events.emit(
540
+ 'workspace.workspace.deleted',
541
+ { type: 'workspace', id: workspaceId },
542
+ { slug: workspace.slug },
543
+ )
544
+ }, { workspaceId, actor: toEventActor(actor) })
531
545
  },
532
546
 
533
547
  async listMembers(actor, workspaceId) {
@@ -602,8 +616,12 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
602
616
  await dependencies.transaction(async ({ tx, events }) => {
603
617
  await repository.deleteMember(tx, target.id)
604
618
 
605
- events.emit('member.removed', { workspaceId, memberId: target.id, userId: target.userId })
606
- })
619
+ events.emit(
620
+ 'workspace.member.removed',
621
+ { type: 'member', id: target.id },
622
+ { userId: target.userId },
623
+ )
624
+ }, { workspaceId, actor: toEventActor(actor) })
607
625
  } catch (error: unknown) {
608
626
  // A record assigned to them between the count and this delete. The
609
627
  // constraint answers the same question the count did, so the caller gets
@@ -658,10 +676,14 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
658
676
  expiresAt: new Date(now.getTime() + INVITE_LIFETIME_MS),
659
677
  })
660
678
 
661
- events.emit('member.invited', { workspaceId, inviteId: created.id, email: created.email })
679
+ events.emit(
680
+ 'workspace.member.invited',
681
+ { type: 'invite', id: created.id },
682
+ { inviteId: created.id, email: created.email },
683
+ )
662
684
 
663
685
  return created
664
- })
686
+ }, { workspaceId, actor: toEventActor(actor) })
665
687
 
666
688
  // Sent after commit, so a rolled-back invite never reaches an inbox.
667
689
  await sendInviteEmail(invite.email, token)
@@ -790,14 +812,14 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
790
812
  throw AppError.notFound('Workspace not found')
791
813
  }
792
814
 
793
- events.emit('member.joined', {
794
- workspaceId: invite.workspaceId,
795
- memberId,
796
- userId: actor.userId,
797
- })
815
+ events.emit(
816
+ 'workspace.member.joined',
817
+ { type: 'member', id: memberId },
818
+ { userId: actor.userId },
819
+ )
798
820
 
799
821
  return toWorkspaceView(workspace)
800
- })
822
+ }, { workspaceId: invite.workspaceId, actor: toEventActor(actor) })
801
823
  },
802
824
 
803
825
  async listModuleSettings(actor, workspaceId) {
@@ -27,9 +27,18 @@ import { WORKSPACE_ACCESS } from './capabilities.ts'
27
27
  * chance of understanding why. There is no `/v1` endpoint to switch a
28
28
  * session to a different workspace membership — a session is bound to one
29
29
  * at login — so there is nothing further to exempt for that case.
30
+ *
31
+ * Also exempts `DELETE /v1/workspaces/:id` itself, and only that exact
32
+ * path: a suspension must never trap an owner into a workspace they can no
33
+ * longer read or write but also cannot leave by deleting. Every other verb
34
+ * and every sub-resource under `/v1/workspaces/:id` (members, invites,
35
+ * modules) stays gated.
30
36
  */
31
- function isExempt(path: string): boolean {
32
- return path.startsWith('/v1/auth/') || path.startsWith('/v1/account')
37
+ const WORKSPACE_DELETE_PATH = /^\/v1\/workspaces\/[^/]+$/u
38
+
39
+ function isExempt(method: string, path: string): boolean {
40
+ return path.startsWith('/v1/auth/') || path.startsWith('/v1/account') ||
41
+ (method === 'DELETE' && WORKSPACE_DELETE_PATH.test(path))
33
42
  }
34
43
 
35
44
  export interface WorkspaceAccessMiddlewareDependencies extends CredentialDependencies {
@@ -40,7 +49,7 @@ export function createWorkspaceAccessMiddleware(
40
49
  dependencies: WorkspaceAccessMiddlewareDependencies,
41
50
  ): MiddlewareHandler {
42
51
  return async (context: Context, next) => {
43
- if (isExempt(context.req.path)) {
52
+ if (isExempt(context.req.method, context.req.path)) {
44
53
  await next()
45
54
  return
46
55
  }
@@ -1,3 +1,9 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ import type { EventTarget, KelpieEvent } from '@kelpie/schemas'
4
+ import Emittery from 'emittery'
5
+ import type { ZodType } from 'zod'
6
+
1
7
  import { describeThrown } from '../lib/errors.ts'
2
8
  import type { Logger } from '../lib/logger.ts'
3
9
 
@@ -7,15 +13,16 @@ import type { Logger } from '../lib/logger.ts'
7
13
  * Delivery is at-least-once within the process, with no durable queue. A crash
8
14
  * between commit and dispatch loses the event. A durable outbox is a known
9
15
  * follow-up; do not build it before a consumer cannot tolerate the loss.
16
+ *
17
+ * Every event carries the envelope defined in `@kelpie/schemas`
18
+ * (`KelpieEvent<Name, Data>`). Names and payload schemas live in per-module
19
+ * catalogs the bus registers at boot; two modules cannot declare the same name.
10
20
  */
11
21
 
12
22
  /**
13
- * CRM object types that `record.*` events carry. Every object the API writes,
14
- * including the link and content records.
15
- *
16
- * Wider than `EXTENSIBLE_RECORD_TYPES` in `@kelpie/schemas`, which is the
17
- * shorter list of detail pages a UI module can contribute to. A Position, a
18
- * Form, and a Handbook page each publish events but have no page to extend.
23
+ * CRM object types the webhooks bridge treats as deliverable records. Kept
24
+ * here (rather than folded into a module) because import and webhooks both
25
+ * read it, and neither owns the list.
19
26
  */
20
27
  export const RECORD_OBJECT_TYPES = [
21
28
  'person',
@@ -33,87 +40,134 @@ export const RECORD_OBJECT_TYPES = [
33
40
 
34
41
  export type RecordObjectType = (typeof RECORD_OBJECT_TYPES)[number]
35
42
 
36
- /** Objects whose stage or status moves through a pipeline. */
37
- export type StagedObjectType = 'deal' | 'opportunity' | 'raise' | 'partnership'
38
-
39
43
  /**
40
- * The event catalog. Every payload carries `workspaceId`: a consumer must never
41
- * have to look up which tenant an event belongs to.
44
+ * The compile-time event catalog.
42
45
  *
43
- * Adding an event means adding a key here. The name is the key, so a typo is a
44
- * compile error rather than a subscription that never fires.
46
+ * Modules extend this interface via TypeScript declaration merging, one
47
+ * declaration per module. The runtime catalog (see `registerCatalog`) is the
48
+ * authority; this interface is what gives autocomplete and payload typing at
49
+ * every `publish` and `subscribe` call site.
45
50
  */
46
- export interface DomainEvents {
47
- 'workspace.created': { workspaceId: string; slug: string }
48
- /** Fires after the cascade, so nothing the workspace owned still exists. */
49
- 'workspace.deleted': { workspaceId: string; slug: string }
50
- 'member.invited': { workspaceId: string; inviteId: string; email: string }
51
- 'member.joined': { workspaceId: string; memberId: string; userId: string }
52
- /** Pairs with `member.joined`, which is what a seat count subscribes to. */
53
- 'member.removed': { workspaceId: string; memberId: string; userId: string }
54
- 'record.created': { workspaceId: string; objectType: RecordObjectType; recordId: string }
55
- 'record.updated': {
56
- workspaceId: string
57
- objectType: RecordObjectType
58
- recordId: string
59
- changedFields: readonly string[]
60
- }
61
- 'record.deleted': { workspaceId: string; objectType: RecordObjectType; recordId: string }
62
- 'stage.changed': {
63
- workspaceId: string
64
- objectType: StagedObjectType
65
- recordId: string
66
- fromStageId: string | null
67
- toStageId: string
68
- }
69
- 'note.added': { workspaceId: string; noteId: string; targetType: string; targetId: string }
70
- 'decision.added': { workspaceId: string; decisionId: string; targetType: string; targetId: string }
71
- 'plan.completed': { workspaceId: string; planItemId: string; targetType: string; targetId: string }
72
- 'form.submitted': { workspaceId: string; formId: string; submissionId: string }
73
- 'import.completed': { workspaceId: string; importJobId: string; object: string }
74
- 'agent_run.finished': { workspaceId: string; agentRunId: string; status: 'succeeded' | 'failed' }
51
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
52
+ export interface KelpieEventMap {}
53
+
54
+ /**
55
+ * When `KelpieEventMap` is empty (no module has declared an event yet) `keyof`
56
+ * is `never`, which would make every `EventHandler` signature unusable. The
57
+ * fallback keeps types working during boot and in tests that build the bus
58
+ * without any modules registered.
59
+ */
60
+ type EventMapWithFallback = keyof KelpieEventMap extends never
61
+ ? Readonly<Record<string, unknown>>
62
+ : KelpieEventMap
63
+
64
+ export type EventName = string & keyof EventMapWithFallback
65
+
66
+ /** Handlers are async and must be idempotent: the same event may arrive twice. */
67
+ export type EventHandler<Name extends EventName> = (
68
+ event: KelpieEvent<Name, EventMapWithFallback[Name]>,
69
+ ) => Promise<void> | void
70
+
71
+ /** Prefix subscribers see events they cannot name statically. */
72
+ export type AnyEventHandler = (event: KelpieEvent<string, unknown>) => Promise<void> | void
73
+
74
+ /**
75
+ * What a module hands the runtime at registration: the module's id and the Zod
76
+ * schema for every event name the module publishes.
77
+ */
78
+ export interface EventCatalog {
79
+ readonly moduleId: string
80
+ readonly events: Readonly<Record<string, ZodType>>
75
81
  }
76
82
 
77
- export type DomainEventName = keyof DomainEvents
83
+ /** One frame in the causation chain that produced an event. */
84
+ export interface EventChainEntry {
85
+ readonly id: string
86
+ readonly name: string
87
+ readonly targetType: string
88
+ readonly targetId: string
89
+ }
78
90
 
79
91
  /**
80
- * The catalog as data. Webhooks needs the deliverable event names at runtime, and
81
- * `satisfies` keeps this list from drifting from the interface above.
92
+ * The chain of ancestor events currently being handled, keyed against the async
93
+ * context that started them. `undefined` outside any handler.
82
94
  *
83
- * An event added to `DomainEvents` but forgotten here is caught by
84
- * `createHandlerRegistry`, which must produce an entry for every name.
95
+ * The bus wraps every `publish` in `run(child, ...)`, so a handler that opens a
96
+ * transaction and calls `emit(...)` inherits the chain. The transaction scope
97
+ * reads this to stamp `causedBy` and to run the cycle guard.
85
98
  */
86
- export const DOMAIN_EVENT_NAMES = [
87
- 'workspace.created',
88
- 'workspace.deleted',
89
- 'member.invited',
90
- 'member.joined',
91
- 'member.removed',
92
- 'record.created',
93
- 'record.updated',
94
- 'record.deleted',
95
- 'stage.changed',
96
- 'note.added',
97
- 'decision.added',
98
- 'plan.completed',
99
- 'form.submitted',
100
- 'import.completed',
101
- 'agent_run.finished',
102
- ] as const satisfies readonly DomainEventName[]
99
+ const chainStorage = new AsyncLocalStorage<readonly EventChainEntry[]>()
103
100
 
104
- /** Handlers are async and must be idempotent: the same event may arrive twice. */
105
- export type EventHandler<Name extends DomainEventName> = (payload: DomainEvents[Name]) => Promise<void>
101
+ export function currentEventChain(): readonly EventChainEntry[] {
102
+ return chainStorage.getStore() ?? []
103
+ }
104
+
105
+ /**
106
+ * Runs the cycle guard against the current chain. Called from the transaction
107
+ * scope's `emit` before it buffers the event.
108
+ */
109
+ export function checkEventCycle(
110
+ chain: readonly EventChainEntry[],
111
+ name: string,
112
+ target: EventTarget,
113
+ maxDepth: number,
114
+ ): CycleGuardOutcome {
115
+ if (chain.length >= maxDepth) {
116
+ return { kind: 'depth' }
117
+ }
118
+ for (const entry of chain) {
119
+ if (entry.name === name && entry.targetType === target.type && entry.targetId === target.id) {
120
+ return { kind: 'repeat' }
121
+ }
122
+ }
123
+ return { kind: 'ok' }
124
+ }
125
+
126
+ export type CycleGuardOutcome =
127
+ | { readonly kind: 'ok' }
128
+ | { readonly kind: 'depth' }
129
+ | { readonly kind: 'repeat' }
130
+
131
+ export interface SubscribeOptions {
132
+ /** Shown in logs when the handler fails or times out. Defaults to the event name. */
133
+ readonly label?: string
134
+ /** Overrides the bus-wide default. */
135
+ readonly timeoutMs?: number
136
+ }
106
137
 
107
138
  export interface EventBus {
108
- /** Registers a handler. Handlers for one event run concurrently, not in order. */
109
- subscribe<Name extends DomainEventName>(name: Name, handler: EventHandler<Name>): void
139
+ /**
140
+ * Registers a module's event catalog. Called by `registerModules` at boot,
141
+ * once per module that declares events. Two modules cannot declare the same
142
+ * event name; the second call throws.
143
+ */
144
+ registerCatalog(catalog: EventCatalog): void
145
+ /** True if any module has declared this event name. */
146
+ hasEvent(name: string): boolean
147
+ /** The payload schema registered for this event, or `undefined` if unknown. */
148
+ getSchema(name: string): ZodType | undefined
149
+ /**
150
+ * Subscribe to an event by exact name. Handlers for one event run
151
+ * concurrently, each in its own error and timeout boundary.
152
+ */
153
+ subscribe<Name extends EventName>(
154
+ name: Name,
155
+ handler: EventHandler<Name>,
156
+ options?: SubscribeOptions,
157
+ ): void
158
+ /**
159
+ * Subscribe to every event whose name starts with `prefix`. Runs concurrently
160
+ * with exact-name subscribers. Match is a plain `startsWith`; there is no
161
+ * pattern language.
162
+ */
163
+ subscribePrefix(prefix: string, handler: AnyEventHandler, options?: SubscribeOptions): void
110
164
  /**
111
165
  * Dispatches immediately. Services should not call this directly: they emit
112
166
  * through the transaction scope so nothing fires before the commit.
113
- *
114
- * @returns A promise that settles when every handler for this event has settled.
115
167
  */
116
- publish<Name extends DomainEventName>(name: Name, payload: DomainEvents[Name]): Promise<void>
168
+ publish<Name extends EventName>(
169
+ event: KelpieEvent<Name, EventMapWithFallback[Name]>,
170
+ ): Promise<void>
117
171
  /**
118
172
  * Settles once every in-flight publication has finished, including any a
119
173
  * handler started. Used by tests, and by shutdown to flush before exit.
@@ -121,57 +175,164 @@ export interface EventBus {
121
175
  drain(): Promise<void>
122
176
  }
123
177
 
124
- /**
125
- * A handler that throws must not break the emitting request, and must not stop
126
- * its siblings. Failures are logged and dropped.
127
- */
128
- /** One handler list per event, keyed so each list stays tied to its payload type. */
129
- type HandlerRegistry = { [Name in DomainEventName]: EventHandler<Name>[] }
178
+ export interface EventBusOptions {
179
+ /** Default per-handler timeout in ms. Defaults to 5000. */
180
+ readonly defaultHandlerTimeoutMs?: number
181
+ }
182
+
183
+ const DEFAULT_HANDLER_TIMEOUT_MS = 5000
184
+
185
+ interface PrefixHandler {
186
+ readonly prefix: string
187
+ readonly label: string
188
+ readonly run: (event: KelpieEvent<string, unknown>) => Promise<void>
189
+ }
130
190
 
131
191
  /**
132
- * Every name gets a list up front. TypeScript cannot check a write through a
133
- * generic key, so pre-seeding turns `subscribe` into a read plus a push, which it
134
- * can check. The one assertion is building a complete record from its own key list.
192
+ * Emittery v2 passes each listener an `{ name, data }` object rather than the
193
+ * raw payload. This shape stays internal so nothing outside this file sees the
194
+ * extra envelope on top of ours.
135
195
  */
136
- function createHandlerRegistry(): HandlerRegistry {
137
- const registry: Partial<HandlerRegistry> = {}
138
-
139
- for (const name of DOMAIN_EVENT_NAMES) {
140
- registry[name] = []
141
- }
196
+ interface EmitteryListenerEvent {
197
+ readonly name: string
198
+ readonly data: unknown
199
+ }
142
200
 
143
- return registry as HandlerRegistry
201
+ interface EmitterySignature {
202
+ readonly on: (
203
+ name: string,
204
+ listener: (event: EmitteryListenerEvent) => Promise<void> | void,
205
+ ) => () => void
206
+ readonly emit: (name: string, data: unknown) => Promise<void>
144
207
  }
145
208
 
146
- export function createEventBus(logger: Logger): EventBus {
147
- const handlers = createHandlerRegistry()
148
- const inFlight = new Set<Promise<void>>()
209
+ export function createEventBus(logger: Logger, options: EventBusOptions = {}): EventBus {
210
+ const emittery = new Emittery() as unknown as EmitterySignature
211
+ const schemas = new Map<string, ZodType>()
212
+ const owners = new Map<string, string>()
213
+ const prefixHandlers: PrefixHandler[] = []
214
+ const inFlight = new Set<Promise<unknown>>()
215
+ const defaultTimeoutMs = options.defaultHandlerTimeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS
216
+
217
+ function wrap<Value>(
218
+ label: string,
219
+ timeoutMs: number,
220
+ handler: (event: Value) => Promise<void> | void,
221
+ context: (event: Value) => Readonly<Record<string, unknown>>,
222
+ ): (event: Value) => Promise<void> {
223
+ return async (event) => {
224
+ let timerHandle: NodeJS.Timeout | undefined
225
+ const timedOut = Symbol('timeout')
226
+ const timer = new Promise<typeof timedOut>((resolve) => {
227
+ timerHandle = setTimeout(() => resolve(timedOut), timeoutMs)
228
+ timerHandle.unref?.()
229
+ })
230
+
231
+ try {
232
+ const outcome = await Promise.race([
233
+ Promise.resolve().then(() => handler(event)),
234
+ timer,
235
+ ])
236
+ if (outcome === timedOut) {
237
+ logger.error('event handler timed out', { label, timeoutMs, ...context(event) })
238
+ }
239
+ } catch (error: unknown) {
240
+ logger.error('event handler failed', {
241
+ label,
242
+ error: describeThrown(error),
243
+ ...context(event),
244
+ })
245
+ } finally {
246
+ if (timerHandle !== undefined) {
247
+ clearTimeout(timerHandle)
248
+ }
249
+ }
250
+ }
251
+ }
149
252
 
150
253
  return {
151
- subscribe(name, handler) {
152
- handlers[name].push(handler)
254
+ registerCatalog(catalog) {
255
+ for (const [name, schema] of Object.entries(catalog.events)) {
256
+ const existing = owners.get(name)
257
+ if (existing !== undefined) {
258
+ throw new Error(
259
+ `event "${name}" is declared by both module "${existing}" and module "${catalog.moduleId}"`,
260
+ )
261
+ }
262
+ owners.set(name, catalog.moduleId)
263
+ schemas.set(name, schema)
264
+ }
265
+ },
266
+
267
+ hasEvent(name) {
268
+ return schemas.has(name)
153
269
  },
154
270
 
155
- publish(name, payload) {
156
- const registered = handlers[name]
157
-
158
- const settled = Promise.allSettled(registered.map((handler) => handler(payload))).then(
159
- (outcomes) => {
160
- for (const outcome of outcomes) {
161
- if (outcome.status === 'rejected') {
162
- logger.error('event handler failed', {
163
- event: name,
164
- error: describeThrown(outcome.reason),
165
- })
166
- }
167
- }
271
+ getSchema(name) {
272
+ return schemas.get(name)
273
+ },
274
+
275
+ subscribe(name, handler, subscribeOptions) {
276
+ const label = subscribeOptions?.label ?? name
277
+ const timeoutMs = subscribeOptions?.timeoutMs ?? defaultTimeoutMs
278
+ const wrapped = wrap<EmitteryListenerEvent>(
279
+ label,
280
+ timeoutMs,
281
+ (event) => {
282
+ const envelope = event.data as KelpieEvent<string, unknown>
283
+ return handler(envelope as never)
168
284
  },
285
+ (event) => {
286
+ const envelope = event.data as KelpieEvent<string, unknown>
287
+ return { event: envelope.name, eventId: envelope.id }
288
+ },
289
+ )
290
+ emittery.on(name, wrapped)
291
+ },
292
+
293
+ subscribePrefix(prefix, handler, subscribeOptions) {
294
+ const label = subscribeOptions?.label ?? `${prefix}*`
295
+ const timeoutMs = subscribeOptions?.timeoutMs ?? defaultTimeoutMs
296
+ prefixHandlers.push({
297
+ prefix,
298
+ label,
299
+ run: wrap<KelpieEvent<string, unknown>>(
300
+ label,
301
+ timeoutMs,
302
+ handler,
303
+ (event) => ({ event: event.name, eventId: event.id }),
304
+ ),
305
+ })
306
+ },
307
+
308
+ publish(event) {
309
+ const parent = chainStorage.getStore() ?? []
310
+ const child: EventChainEntry[] = [
311
+ ...parent,
312
+ {
313
+ id: event.id,
314
+ name: event.name,
315
+ targetType: event.target.type,
316
+ targetId: event.target.id,
317
+ },
318
+ ]
319
+
320
+ const matching = prefixHandlers.filter((entry) => event.name.startsWith(entry.prefix))
321
+ const typedEvent = event as KelpieEvent<string, unknown>
322
+
323
+ const settled = chainStorage.run(child, () =>
324
+ Promise.allSettled([
325
+ emittery.emit(event.name, typedEvent),
326
+ ...matching.map((entry) => entry.run(typedEvent)),
327
+ ]),
169
328
  )
170
329
 
171
330
  inFlight.add(settled)
172
- void settled.finally(() => inFlight.delete(settled))
331
+ void settled.finally(() => {
332
+ inFlight.delete(settled)
333
+ })
173
334
 
174
- return settled
335
+ return settled.then(() => undefined)
175
336
  },
176
337
 
177
338
  async drain() {
@@ -6,6 +6,7 @@ import type { Database } from '../lib/database.ts'
6
6
  import type { EmailSender } from '../lib/email.ts'
7
7
  import type { IdFactory } from '../lib/ids.ts'
8
8
  import type { Logger } from '../lib/logger.ts'
9
+ import type { SecretEncryptionConfig } from '../lib/secrets.ts'
9
10
  import type { EntitlementRegistry } from './entitlements.ts'
10
11
  import type { EventBus } from './events.ts'
11
12
  import type { TransactionScope } from './transaction.ts'
@@ -69,17 +70,55 @@ export interface ModuleServices {
69
70
  readonly db: Database
70
71
  /** Runs work in one transaction and publishes its events after commit. */
71
72
  readonly transaction: TransactionScope
72
- /** Transactional mail only: invites and password resets. */
73
- readonly email: EmailSender
74
73
  /** Generates `<prefix>_<ulid>` ids. Injected so tests can pin them. */
75
74
  readonly createId: IdFactory
76
75
  /** The current time, injected so expiry logic is testable. */
77
76
  readonly now: () => Date
77
+ /**
78
+ * The deployment's base URL, when the assembly declares `appBaseUrl` in
79
+ * `kelpie.config.ts`. Modules that need it prefer this over
80
+ * `context.config(appUrlConfigSchema)`, which they fall back to when this is
81
+ * undefined (older assemblies).
82
+ */
83
+ readonly appBaseUrl?: string | undefined
84
+ /**
85
+ * The keys that seal stored secrets, when the assembly declares
86
+ * `secretEncryption`. Same fallback pattern as `appBaseUrl`.
87
+ */
88
+ readonly secretEncryption?: SecretEncryptionConfig | undefined
78
89
  }
79
90
 
80
91
  export interface ModuleContext extends ModuleServices {
81
92
  /** Registers routes. They mount under `/v1` and are public API like any other. */
82
93
  routes(mount: (router: Hono) => void): void
94
+ /**
95
+ * Registers a named transactional-mail provider. `name` is what the assembly
96
+ * puts in `kelpie.config.ts`'s `email.provider`; the runtime resolves that
97
+ * field against the registry after every module has registered.
98
+ *
99
+ * `build` is a factory the runtime calls exactly once, and only for the
100
+ * provider `email.provider` picked. A provider that is registered but never
101
+ * picked never has its factory called, so a module can register itself
102
+ * without demanding its own env up front: a self-hoster who leaves the
103
+ * built-in `smtp-email` core module in `coreModules` while running on
104
+ * `EMAIL_PROVIDER=log` will not be asked for SMTP credentials at boot.
105
+ * The factory may throw; the runtime wraps the error with the module id.
106
+ *
107
+ * Any number of modules may register different names side by side. Two
108
+ * modules registering the same name fails boot. `'log'` is registered by
109
+ * the runtime itself, so a module that tries to shadow it fails boot too.
110
+ *
111
+ * `context.email` is a proxy: every module receives the same object, and
112
+ * its `send` delegates to whichever provider `email.provider` picked.
113
+ * Consumer modules capture the proxy at register time; registration order
114
+ * does not matter.
115
+ */
116
+ provideEmailSender(name: string, build: () => EmailSender): void
117
+ /**
118
+ * Transactional mail. Every module receives the same proxy; `.send()`
119
+ * delegates to the provider `email.provider` picked.
120
+ */
121
+ readonly email: EmailSender
83
122
  /**
84
123
  * Registers routes that mount under `/v1/public`, take no credentials, and
85
124
  * answer cross-origin requests from any site.
@@ -153,6 +192,15 @@ export interface ModuleCatalogEntry {
153
192
  readonly structural: boolean
154
193
  }
155
194
 
195
+ /**
196
+ * A module's event catalog: the Zod schema for every event name the module
197
+ * publishes. Registered with the bus at boot; two modules cannot claim the same
198
+ * name.
199
+ */
200
+ export interface ModuleEventCatalog {
201
+ readonly [eventName: string]: ZodType
202
+ }
203
+
156
204
  export interface KelpieModule {
157
205
  readonly id: string
158
206
  /** Ids of modules that must register first. Missing ones fail boot. */
@@ -167,5 +215,10 @@ export interface KelpieModule {
167
215
  * without its author having to opt in.
168
216
  */
169
217
  readonly structural?: boolean
218
+ /**
219
+ * The events this module publishes, as a runtime catalog. Optional: a module
220
+ * that only consumes events, or contributes none, may omit it.
221
+ */
222
+ readonly events?: ModuleEventCatalog
170
223
  register(context: ModuleContext): Promise<void>
171
224
  }