@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
@@ -4,6 +4,8 @@ import type { Context, Handler, MiddlewareHandler } from 'hono'
4
4
  import type { Actor } from '../lib/actor.ts'
5
5
  import { requireWorkspaceId } from '../lib/actor.ts'
6
6
  import type { Environment } from '../lib/config.ts'
7
+ import { createLogEmailSender } from '../lib/email.ts'
8
+ import type { EmailMessage, EmailSender } from '../lib/email.ts'
7
9
  import { AppError, describeThrown, describeValidationIssue, toErrorDetails } from '../lib/errors.ts'
8
10
  import type { Logger } from '../lib/logger.ts'
9
11
  import { createEntitlementRegistry, requireCapability } from './entitlements.ts'
@@ -103,6 +105,28 @@ export interface ModuleRuntimeOptions {
103
105
  readonly entitlements?: EntitlementRegistry | undefined
104
106
  /** The database, transaction scope, and collaborators every module builds on. */
105
107
  readonly services: ModuleServices
108
+ /**
109
+ * Picks the transactional-mail provider. `provider` is a name a module
110
+ * registered via `context.provideEmailSender(name, sender)`. `'log'` is a
111
+ * built-in the runtime always registers using its own logger and `from`, so
112
+ * a bare install boots without a provider module.
113
+ *
114
+ * `from` is the address on every outgoing message. The log provider uses it
115
+ * verbatim; other providers may read the same value through their own
116
+ * `context.config` if they need to.
117
+ */
118
+ readonly email: {
119
+ readonly provider: string
120
+ readonly from: string
121
+ }
122
+ /**
123
+ * Extra named providers seeded into the registry before any module runs.
124
+ * Modules still register through `context.provideEmailSender`; these are for
125
+ * tests that want to inspect what was sent without building a whole module.
126
+ * A name collision with a module (or with `'log'`) fails boot exactly as if
127
+ * two modules had registered it.
128
+ */
129
+ readonly additionalEmailProviders?: ReadonlyMap<string, EmailSender> | undefined
106
130
  /**
107
131
  * The deploy-time module override (`lib/moduleConfig.ts`), parsed. A locked
108
132
  * module id wins over whatever a workspace's own settings say.
@@ -132,6 +156,45 @@ interface Accumulator {
132
156
  readonly mcpTools: McpTool[]
133
157
  }
134
158
 
159
+ /**
160
+ * The email sender every module writes through. `target` is set once, after
161
+ * all modules have registered, from the provider `email.provider` picked. Until
162
+ * then a `.send()` throws: consumer modules capture the proxy at register time
163
+ * but only reach it at request time, so this is unreachable in normal flow.
164
+ */
165
+ class EmailSenderProxy implements EmailSender {
166
+ private target: EmailSender | undefined = undefined
167
+
168
+ setTarget(sender: EmailSender): void {
169
+ this.target = sender
170
+ }
171
+
172
+ send(message: EmailMessage): Promise<void> {
173
+ if (this.target === undefined) {
174
+ return Promise.reject(
175
+ new Error('email sender used before boot resolved a provider. Set email.provider in kelpie.config.ts.'),
176
+ )
177
+ }
178
+
179
+ return this.target.send(message)
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The name the runtime uses for its built-in log sender. Reserved: a module
185
+ * that tries to register under this name fails boot.
186
+ */
187
+ const LOG_PROVIDER_NAME = 'log'
188
+
189
+ /**
190
+ * Records which module registered a given provider name, so a collision error
191
+ * can name both parties. `build` is invoked once, only for the chosen provider.
192
+ */
193
+ interface RegisteredProvider {
194
+ readonly build: () => EmailSender
195
+ readonly registeredBy: string
196
+ }
197
+
135
198
  function createModuleContext(
136
199
  module: KelpieModule,
137
200
  accumulator: Accumulator,
@@ -139,9 +202,30 @@ function createModuleContext(
139
202
  events: EventBus,
140
203
  entitlements: EntitlementRegistry,
141
204
  moduleCatalog: readonly ModuleCatalogEntry[],
205
+ emailProxy: EmailSenderProxy,
206
+ providers: Map<string, RegisteredProvider>,
142
207
  ): ModuleContext {
143
208
  return {
144
209
  ...options.services,
210
+ email: emailProxy,
211
+
212
+ provideEmailSender(name, build) {
213
+ if (name === LOG_PROVIDER_NAME) {
214
+ throw new ModuleBootError([
215
+ `module "${module.id}" registers email provider "${name}", which is reserved by the runtime`,
216
+ ])
217
+ }
218
+
219
+ const existing = providers.get(name)
220
+
221
+ if (existing !== undefined) {
222
+ throw new ModuleBootError([
223
+ `module "${module.id}" registers email provider "${name}", but module "${existing.registeredBy}" already did`,
224
+ ])
225
+ }
226
+
227
+ providers.set(name, { build, registeredBy: module.id })
228
+ },
145
229
 
146
230
  routes(mount) {
147
231
  const router = new Hono()
@@ -262,6 +346,33 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
262
346
 
263
347
  const events = options.events ?? createEventBus(options.logger)
264
348
  const entitlements = options.entitlements ?? createEntitlementRegistry()
349
+ const emailProxy = new EmailSenderProxy()
350
+ const emailProviders = new Map<string, RegisteredProvider>()
351
+
352
+ // The built-in log provider is always available, no module required. Named
353
+ // 'log' in kelpie.config.ts's email.provider picks this one. Built eagerly
354
+ // because it has no env to parse and never fails.
355
+ const logSender = createLogEmailSender(options.logger, options.email.from)
356
+
357
+ emailProviders.set(LOG_PROVIDER_NAME, {
358
+ build: () => logSender,
359
+ registeredBy: '<runtime>',
360
+ })
361
+
362
+ // Seed test-supplied providers before any module registers, so a module
363
+ // collision with one of them fails boot the same way two modules would.
364
+ if (options.additionalEmailProviders !== undefined) {
365
+ for (const [name, sender] of options.additionalEmailProviders) {
366
+ if (emailProviders.has(name)) {
367
+ throw new ModuleBootError([
368
+ `additionalEmailProviders names "${name}", which is already registered by the runtime`,
369
+ ])
370
+ }
371
+
372
+ emailProviders.set(name, { build: () => sender, registeredBy: '<additionalEmailProviders>' })
373
+ }
374
+ }
375
+
265
376
  const accumulator: Accumulator = {
266
377
  routers: [],
267
378
  publicRouters: [],
@@ -295,8 +406,30 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
295
406
  entitlements.provide(createModuleConfigProvider(options.moduleConfig))
296
407
  }
297
408
 
409
+ // Register every module's event catalog before any `register` runs, so a
410
+ // module that subscribes to another module's event during its own registration
411
+ // finds a validated name.
298
412
  for (const module of ordered) {
299
- const context = createModuleContext(module, accumulator, options, events, entitlements, moduleCatalog)
413
+ if (module.events !== undefined) {
414
+ try {
415
+ events.registerCatalog({ moduleId: module.id, events: module.events })
416
+ } catch (error: unknown) {
417
+ throw new ModuleBootError([describeThrown(error)], { cause: error })
418
+ }
419
+ }
420
+ }
421
+
422
+ for (const module of ordered) {
423
+ const context = createModuleContext(
424
+ module,
425
+ accumulator,
426
+ options,
427
+ events,
428
+ entitlements,
429
+ moduleCatalog,
430
+ emailProxy,
431
+ emailProviders,
432
+ )
300
433
 
301
434
  try {
302
435
  await module.register(context)
@@ -313,9 +446,44 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
313
446
  context.log.debug('module registered')
314
447
  }
315
448
 
449
+ // Resolve the configured provider. The lookup happens after every module has
450
+ // registered, so a module registering a provider late in the list still wins
451
+ // if the config names it. An unknown name is a boot error whose message lists
452
+ // every registered name, so a self-hoster sees what they had to pick from.
453
+ const chosen = emailProviders.get(options.email.provider)
454
+
455
+ if (chosen === undefined) {
456
+ const available = [...emailProviders.keys()].sort()
457
+
458
+ throw new ModuleBootError([
459
+ `email.provider is "${options.email.provider}", which no module registered. Available: ${available.join(', ')}`,
460
+ ])
461
+ }
462
+
463
+ // Only the chosen provider is built. A registered-but-unused provider never
464
+ // runs its factory, so a self-hoster who leaves a module in `modules:` but
465
+ // picks a different provider is not asked for the unused module's env.
466
+ let chosenSender: EmailSender
467
+
468
+ try {
469
+ chosenSender = chosen.build()
470
+ } catch (error: unknown) {
471
+ if (error instanceof ModuleBootError) {
472
+ throw error
473
+ }
474
+
475
+ throw new ModuleBootError(
476
+ [`email provider "${options.email.provider}" (${chosen.registeredBy}) failed to build: ${describeThrown(error)}`],
477
+ { cause: error },
478
+ )
479
+ }
480
+
481
+ emailProxy.setTarget(chosenSender)
482
+
316
483
  options.logger.info('modules registered', {
317
484
  count: ordered.length,
318
485
  ids: ordered.map((module) => module.id),
486
+ emailProvider: options.email.provider,
319
487
  })
320
488
 
321
489
  return {
@@ -1,6 +1,10 @@
1
+ import type { EventActor, EventTarget, KelpieEvent } from '@kelpie/schemas'
2
+
1
3
  import type { Database } from '../lib/database.ts'
4
+ import type { IdFactory } from '../lib/ids.ts'
2
5
  import type { Logger } from '../lib/logger.ts'
3
- import type { DomainEventName, DomainEvents, EventBus } from './events.ts'
6
+ import { checkEventCycle, currentEventChain } from './events.ts'
7
+ import type { EventBus, EventName, KelpieEventMap } from './events.ts'
4
8
 
5
9
  /**
6
10
  * One service call, one transaction, per `architecture.md`.
@@ -19,9 +23,21 @@ export type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
19
23
  */
20
24
  export type Queryable = Database | Transaction
21
25
 
22
- /** Collects events during the transaction. Nothing leaves until commit. */
26
+ type EventMapFor<Name extends EventName> = keyof KelpieEventMap extends never
27
+ ? unknown
28
+ : Name extends keyof KelpieEventMap
29
+ ? KelpieEventMap[Name]
30
+ : unknown
31
+
32
+ /**
33
+ * Collects events during the transaction. Nothing leaves until commit.
34
+ *
35
+ * `emit` stamps the envelope (id, workspaceId, actor, occurredAt, causedBy) so
36
+ * services pass only what they know: the name, the target the event refers to,
37
+ * and the module-defined payload.
38
+ */
23
39
  export interface BufferedEvents {
24
- emit<Name extends DomainEventName>(name: Name, payload: DomainEvents[Name]): void
40
+ emit<Name extends EventName>(name: Name, target: EventTarget, data: EventMapFor<Name>): void
25
41
  }
26
42
 
27
43
  export interface TransactionContext {
@@ -29,48 +45,100 @@ export interface TransactionContext {
29
45
  readonly events: BufferedEvents
30
46
  }
31
47
 
48
+ export interface TransactionOptions {
49
+ /**
50
+ * The tenant this work belongs to. Every event emitted from the scope stamps
51
+ * it into the envelope. Required when any event is emitted; a scope that
52
+ * emits nothing may omit it.
53
+ */
54
+ readonly workspaceId?: string
55
+ /** Who caused this work. Defaults to `system`. */
56
+ readonly actor?: EventActor
57
+ }
58
+
32
59
  /** Runs `work` in one transaction and publishes its events after commit. */
33
60
  export type TransactionScope = <Result>(
34
61
  work: (context: TransactionContext) => Promise<Result>,
62
+ options?: TransactionOptions,
35
63
  ) => Promise<Result>
36
64
 
37
- /**
38
- * A buffered event keeps its publication as a closure. The name and payload were
39
- * correlated at the `emit` call site; storing them as a pair would lose that and
40
- * force a cast at publication.
41
- */
42
65
  interface BufferedEvent {
43
- readonly name: DomainEventName
44
- readonly publish: () => Promise<void>
66
+ readonly name: string
67
+ readonly envelope: KelpieEvent<string, unknown>
45
68
  }
46
69
 
70
+ const SYSTEM_ACTOR: EventActor = { kind: 'system' }
71
+
47
72
  export interface TransactionScopeDependencies {
48
73
  readonly db: Database
49
74
  readonly bus: EventBus
50
75
  readonly logger: Logger
76
+ readonly createId: IdFactory
77
+ /** Injected so a test can pin timestamps. Defaults to `Date.now`. */
78
+ readonly now?: () => Date
79
+ /** Chain-depth cap. Reads `KELPIE_EVENT_MAX_DEPTH`; otherwise 8. */
80
+ readonly maxDepth?: number
51
81
  }
52
82
 
53
83
  export function createTransactionScope(dependencies: TransactionScopeDependencies): TransactionScope {
54
- return async function runInTransaction(work) {
84
+ const now = dependencies.now ?? ((): Date => new Date())
85
+ const maxDepth = dependencies.maxDepth ?? readMaxDepthFromEnv() ?? 8
86
+
87
+ return async function runInTransaction(work, options) {
88
+ const actor = options?.actor ?? SYSTEM_ACTOR
89
+ const workspaceId = options?.workspaceId
55
90
  const buffered: BufferedEvent[] = []
56
91
 
57
92
  const result = await dependencies.db.transaction((tx) =>
58
93
  work({
59
94
  tx,
60
95
  events: {
61
- emit(name, payload) {
62
- buffered.push({ name, publish: () => dependencies.bus.publish(name, payload) })
96
+ emit(name, target, data) {
97
+ if (workspaceId === undefined || workspaceId.length === 0) {
98
+ throw new Error(
99
+ `event "${name}" was emitted without a workspaceId: pass it via TransactionOptions`,
100
+ )
101
+ }
102
+
103
+ const chain = currentEventChain()
104
+ const outcome = checkEventCycle(chain, name, target, maxDepth)
105
+
106
+ if (outcome.kind !== 'ok') {
107
+ dependencies.logger.error('event cycle guard dropped emit', {
108
+ event: name,
109
+ target,
110
+ reason: outcome.kind,
111
+ chain: chain.map((entry) => ({
112
+ id: entry.id,
113
+ name: entry.name,
114
+ target: { type: entry.targetType, id: entry.targetId },
115
+ })),
116
+ })
117
+ return
118
+ }
119
+
120
+ const envelope: KelpieEvent<string, unknown> = {
121
+ id: dependencies.createId('event'),
122
+ name,
123
+ workspaceId,
124
+ actor,
125
+ occurredAt: now().toISOString(),
126
+ target,
127
+ ...(chain.length > 0 ? { causedBy: chain[chain.length - 1]!.id } : {}),
128
+ data: data as unknown,
129
+ }
130
+
131
+ buffered.push({ name, envelope })
63
132
  },
64
133
  },
65
134
  }),
66
135
  )
67
136
 
68
137
  // Past this line the transaction has committed. Publishing is deliberately
69
- // not awaited: the webhooks engine is a consumer, and an outbound HTTP
70
- // delivery must not sit inside the request that triggered it. Tests and
71
- // graceful shutdown wait via `bus.drain()`.
138
+ // not awaited: an outbound HTTP delivery must not sit inside the request
139
+ // that triggered it. Tests and graceful shutdown wait via `bus.drain()`.
72
140
  for (const event of buffered) {
73
- void event.publish().catch((error: unknown) => {
141
+ void dependencies.bus.publish(event.envelope as never).catch((error: unknown) => {
74
142
  dependencies.logger.error('event publication failed', { event: event.name, error })
75
143
  })
76
144
  }
@@ -78,3 +146,12 @@ export function createTransactionScope(dependencies: TransactionScopeDependencie
78
146
  return result
79
147
  }
80
148
  }
149
+
150
+ function readMaxDepthFromEnv(): number | undefined {
151
+ const raw = process.env.KELPIE_EVENT_MAX_DEPTH
152
+ if (raw === undefined || raw === '') {
153
+ return undefined
154
+ }
155
+ const parsed = Number.parseInt(raw, 10)
156
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
157
+ }
@@ -5,7 +5,7 @@ import type { AppBindings } from '../app.ts'
5
5
  import type { Actor } from '../lib/actor.ts'
6
6
  import type { Environment } from '../lib/config.ts'
7
7
  import type { DatabaseProbe } from '../lib/database.ts'
8
- import { createLogger } from '../lib/logger.ts'
8
+ import { createCaptureTransport, createLogger } from '../lib/logger.ts'
9
9
  import { rateLimitConfigFrom, rateLimitConfigSchema } from '../lib/rateLimit.ts'
10
10
  import type { RateLimitConfig } from '../lib/rateLimit.ts'
11
11
  import type { KelpieModule } from '../runtime/module.ts'
@@ -13,7 +13,7 @@ import type { ModuleContributions } from '../runtime/registry.ts'
13
13
  import type { EntitlementRegistry } from '../runtime/entitlements.ts'
14
14
  import { registerModules } from '../runtime/registry.ts'
15
15
  import { TEST_ENVIRONMENT } from './environment.ts'
16
- import { createTestServices } from './services.ts'
16
+ import { TEST_EMAIL_FROM, TEST_EMAIL_PROVIDER, createTestServices } from './services.ts'
17
17
  import type { TestServices } from './services.ts'
18
18
 
19
19
  /** The same defaults `loadConfig` produces from an empty environment: one source of numbers for both. */
@@ -71,14 +71,22 @@ const reachableDatabase = (): Promise<DatabaseProbe> => Promise.resolve({ reacha
71
71
 
72
72
  export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
73
73
  const logLines: string[] = []
74
- const logger = createLogger('debug', (line) => logLines.push(line))
74
+ const logger = createLogger({
75
+ level: 'debug',
76
+ transports: [createCaptureTransport((line) => logLines.push(line))],
77
+ })
75
78
 
76
79
  const services = options.services ?? createTestServices()
80
+ // Seed the test collecting sender under a stable name and point the email
81
+ // config at it. A test that wants a real provider module in the mix registers
82
+ // it as usual; picking that name in `email.provider` overrides this default.
77
83
  const contributions = await registerModules({
78
84
  modules: options.modules ?? [],
79
85
  environment: options.environment ?? TEST_ENVIRONMENT,
80
86
  logger,
81
87
  events: services.events,
88
+ email: { provider: TEST_EMAIL_PROVIDER, from: TEST_EMAIL_FROM },
89
+ additionalEmailProviders: new Map([[TEST_EMAIL_PROVIDER, services.emailSender]]),
82
90
  ...(options.entitlements === undefined ? {} : { entitlements: options.entitlements }),
83
91
  ...(options.moduleConfig === undefined ? {} : { moduleConfig: options.moduleConfig }),
84
92
  ...(options.resolveActor === undefined ? {} : { resolveActor: options.resolveActor }),
@@ -9,7 +9,7 @@ import { coreModules } from '../modules/core.ts'
9
9
  import { runMigrations } from '../runtime/migrate.ts'
10
10
  import { registerModules } from '../runtime/registry.ts'
11
11
  import { TEST_ENVIRONMENT } from './environment.ts'
12
- import { createTestServices } from './services.ts'
12
+ import { TEST_EMAIL_FROM, TEST_EMAIL_PROVIDER, createTestServices } from './services.ts'
13
13
 
14
14
  /**
15
15
  * A migrated database for integration tests.
@@ -20,7 +20,7 @@ import { createTestServices } from './services.ts'
20
20
  * magnitude faster and just as isolated.
21
21
  */
22
22
 
23
- const silentLogger = createLogger('error', () => undefined)
23
+ const silentLogger = createLogger({ level: 'error', transports: [] })
24
24
 
25
25
  export interface TestDatabase extends DatabaseConnection {
26
26
  /** Empties every table. Cheaper than re-running migrations between tests. */
@@ -72,12 +72,15 @@ export async function connectTestDatabase(connectionString: string): Promise<Tes
72
72
  await ensureDatabaseExists(connectionString)
73
73
 
74
74
  const connection = connectDatabase(connectionString, silentLogger)
75
+ const services = createTestServices({ db: connection.db })
75
76
  const contributions = await registerModules({
76
77
  modules: coreModules,
77
78
  // Enough for core modules to configure themselves; no test reads it further.
78
79
  environment: TEST_ENVIRONMENT,
79
80
  logger: silentLogger,
80
- services: createTestServices({ db: connection.db }),
81
+ services,
82
+ email: { provider: TEST_EMAIL_PROVIDER, from: TEST_EMAIL_FROM },
83
+ additionalEmailProviders: new Map([[TEST_EMAIL_PROVIDER, services.emailSender]]),
81
84
  })
82
85
 
83
86
  await runMigrations(connection.db, contributions.schemas, silentLogger)
@@ -4,6 +4,7 @@ import type { EmailMessage, EmailSender } from '../lib/email.ts'
4
4
  import { createIdFactory } from '../lib/ids.ts'
5
5
  import type { IdFactory } from '../lib/ids.ts'
6
6
  import { createLogger } from '../lib/logger.ts'
7
+ import type { SecretEncryptionConfig } from '../lib/secrets.ts'
7
8
  import { createEventBus } from '../runtime/events.ts'
8
9
  import type { EventBus } from '../runtime/events.ts'
9
10
  import type { ModuleServices } from '../runtime/module.ts'
@@ -20,14 +21,35 @@ import { createTransactionScope } from '../runtime/transaction.ts'
20
21
  /** Never queried. A unit test that reaches the database fails here, loudly. */
21
22
  const UNUSED_DATABASE_URL = 'postgres://unused:unused@127.0.0.1:1/unused'
22
23
 
24
+ /**
25
+ * The provider name `createTestApp` seeds and points `email.provider` at.
26
+ * Exported so a test spawning `registerModules` directly can pass the same
27
+ * name and reach the same collecting sender.
28
+ */
29
+ export const TEST_EMAIL_PROVIDER = 'test'
30
+
31
+ /** The from address the test email config uses. Arbitrary but real. */
32
+ export const TEST_EMAIL_FROM = 'kelpie-test@example.com'
33
+
23
34
  export interface TestServicesOptions {
24
35
  readonly db?: Database
25
36
  readonly now?: () => Date
26
37
  readonly createId?: IdFactory
27
38
  readonly events?: EventBus
39
+ /** Threaded through to `ModuleServices.appBaseUrl`, so a test exercises the preferred path. */
40
+ readonly appBaseUrl?: string
41
+ /** Threaded through to `ModuleServices.secretEncryption`, so a test exercises the preferred path. */
42
+ readonly secretEncryption?: SecretEncryptionConfig
28
43
  }
29
44
 
30
45
  export interface TestServices extends ModuleServices {
46
+ /**
47
+ * The collecting sender `createTestApp` seeds under `TEST_EMAIL_PROVIDER`.
48
+ * `services.emailSender` (this field) is what actually receives calls; a
49
+ * consumer module reaches it through `context.email`, which the runtime's
50
+ * proxy points at whichever provider `email.provider` picked.
51
+ */
52
+ readonly emailSender: EmailSender
31
53
  /** Everything the email port was asked to send, in order. */
32
54
  readonly sentEmails: readonly EmailMessage[]
33
55
  readonly events: EventBus
@@ -50,18 +72,25 @@ function createCollectingEmailSender(): { sender: EmailSender; sent: EmailMessag
50
72
  }
51
73
 
52
74
  export function createTestServices(options: TestServicesOptions = {}): TestServices {
53
- const logger = createLogger('error', () => undefined)
75
+ const logger = createLogger({ level: 'error', transports: [] })
54
76
  const db = options.db ?? connectDatabase(UNUSED_DATABASE_URL, logger).db
55
77
  const events = options.events ?? createEventBus(logger)
78
+ const createId = options.createId ?? createIdFactory()
79
+ const now = options.now ?? ((): Date => new Date())
56
80
  const { sender, sent } = createCollectingEmailSender()
57
81
 
58
82
  return {
59
83
  db,
60
- transaction: createTransactionScope({ db, bus: events, logger }),
61
- email: sender,
62
- createId: options.createId ?? createIdFactory(),
63
- now: options.now ?? ((): Date => new Date()),
84
+ transaction: createTransactionScope({ db, bus: events, logger, createId, now }),
85
+ createId,
86
+ now,
87
+ emailSender: sender,
64
88
  sentEmails: sent,
65
89
  events,
90
+ // Spread conditionally so the field is absent (not `undefined`) when the
91
+ // caller does not opt in, keeping the fallback path (context.config) live
92
+ // for the many suites that never touch these fields.
93
+ ...(options.appBaseUrl === undefined ? {} : { appBaseUrl: options.appBaseUrl }),
94
+ ...(options.secretEncryption === undefined ? {} : { secretEncryption: options.secretEncryption }),
66
95
  }
67
96
  }