@cosmicdrift/kumiko-bundled-features 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,79 @@
1
+ // increment-rolling — Rolling-Window-Counter-Increment via Custom-
2
+ // Event statt CRUD/projection. Designed für Caps deren Wert sich
3
+ // kontinuierlich erneuern soll (KI-Tokens-7-Tage, Egress pro 24h).
4
+ //
5
+ // **Warum kein r.entity / CRUD wie der Calendar-Counter:** Der
6
+ // Calendar-Counter speichert den kumulierten value in seiner
7
+ // projection-row. Beim Period-Rollover wechselt die aggregate-id auf
8
+ // den neuen Period-Start, das ist der "Reset". Bei Rolling-Window
9
+ // gibt es keinen Rollover-Punkt — der Counter "rollt" kontinuierlich
10
+ // raus. Ein einzelner kumulativer value wäre falsch (würde monoton
11
+ // wachsen ohne Expiration). Die korrekte Read-Semantik ist: SUM aller
12
+ // Increment-Amounts der letzten N Tage. Dafür brauchen wir die
13
+ // einzelnen Increments als separate Events mit ihrem eigenen
14
+ // `amount`-Feld; CRUD-Events tragen nur den kumulativen value.
15
+ //
16
+ // **Aggregate-Stream:** ein Stream pro (tenant, capName) — siehe
17
+ // `rollingCapAggregateId`. Alle Increments hängen am selben Stream
18
+ // in monoton-steigender version. enforceRollingCap liest die letzten
19
+ // N Tage aus diesem Stream.
20
+
21
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { z } from "zod";
23
+ import { rollingCapAggregateId } from "../aggregate-id";
24
+ import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "../constants";
25
+
26
+ const incrementRollingSchema = z.object({
27
+ /** App-defined cap-name. e.g. "ai-tokens-7day", "egress-bytes-24h". */
28
+ capName: z.string().min(1).max(100),
29
+ /** Increment-amount. Default 1 (count-events) — pass exact size for
30
+ * byte/token-counters. Stored verbatim in the event payload so the
31
+ * Window-Sum is exact. */
32
+ amount: z.number().int().positive().default(1),
33
+ });
34
+ type IncrementRollingPayload = z.infer<typeof incrementRollingSchema>;
35
+
36
+ /** Schema des emittierten Custom-Events. Identisch zum Input-Schema:
37
+ * der Caller zahlt amount, wir hängen es 1:1 an den Stream. */
38
+ export const rollingIncrementedSchema = incrementRollingSchema;
39
+
40
+ // Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
41
+ // Increments für (tenant, cap) hängen sich am selben aggregate-stream
42
+ // in unterschiedlichen versions auf, das event-store ordert.
43
+ //
44
+ // **Kein version_conflict-Pfad** wie beim Calendar-Counter: hier
45
+ // liest niemand den projection-state vor dem Schreiben. Der
46
+ // expectedVersion ist implizit "next-after-current", was der event-
47
+ // store atomar ermittelt.
48
+ export const incrementRollingCapHandler: WriteHandlerDef = {
49
+ name: "increment-rolling",
50
+ schema: incrementRollingSchema,
51
+ // Internal handler — System-Caller (Plattform-foundations nach
52
+ // erfolgreichem Side-Effect) ruft das auf. Tenant-end-users niemals
53
+ // direkt. Audit-row zeigt welche subsystem-call-site zugehängt hat.
54
+ access: { roles: ["SystemAdmin"] },
55
+ handler: async (event, ctx) => {
56
+ // @cast-boundary engine-payload — dispatcher hands handler the
57
+ // already-Zod-validated payload as `unknown`; cast to the typed
58
+ // shape we declared via incrementRollingSchema. Mirror der
59
+ // existing increment.write.ts-Cast — gleiche dispatcher-boundary.
60
+ const payload = event.payload as IncrementRollingPayload;
61
+ const aggregateId = rollingCapAggregateId(event.user.tenantId, payload.capName);
62
+
63
+ // appendEventUnsafe — bundled-features-Pfad (apps mit yarn kumiko
64
+ // codegen kriegen den strict-typed appendEvent-Wrapper). Schema-
65
+ // Validation läuft trotzdem, weil r.defineEvent das Schema
66
+ // registriert hat.
67
+ await ctx.appendEventUnsafe({
68
+ aggregateId,
69
+ aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
70
+ type: ROLLING_INCREMENTED_EVENT_QN,
71
+ payload: {
72
+ capName: payload.capName,
73
+ amount: payload.amount,
74
+ },
75
+ });
76
+
77
+ return { isSuccess: true, data: { aggregateId, amount: payload.amount } };
78
+ },
79
+ };
@@ -0,0 +1,92 @@
1
+ import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { eq } from "drizzle-orm";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import { z } from "zod";
5
+ import { capCounterAggregateId } from "../aggregate-id";
6
+ import { capCounterEntity } from "../entity";
7
+
8
+ const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
9
+
10
+ const incrementSchema = z.object({
11
+ /** App-defined cap-name. e.g. "platform-mails", "ai-tokens-7day". */
12
+ capName: z.string().min(1).max(100),
13
+ /** Increment-amount. Default 1 (count-events) — pass exact size for
14
+ * byte/token-counters (file-upload size, llm-token-count). */
15
+ amount: z.number().int().positive().default(1),
16
+ /** Period-start ISO. Caller is responsible: monthly counters use
17
+ * "first-of-current-month" (computed once per request via
18
+ * `Temporal.Now.zonedDateTimeISO("UTC").startOfMonth().toString()`),
19
+ * rolling-window counters pass a fixed sentinel ("1970-01-01"). */
20
+ periodStartIso: z.string().min(1),
21
+ });
22
+ type IncrementPayload = z.infer<typeof incrementSchema>;
23
+
24
+ // increment-cap — atomic counter increment via the event-store's
25
+ // optimistic-lock. Two parallel increments for the same (tenant, cap,
26
+ // period) go to the same aggregate; the second one's append fails with
27
+ // version_conflict — caller retries (the dispatcher already handles
28
+ // that for write-handlers, see version_conflict-retry-policy).
29
+ //
30
+ // **Two paths:**
31
+ // 1. Aggregate doesn't exist yet (first increment of the period) →
32
+ // executor.create with deterministic id, value = amount.
33
+ // 2. Aggregate exists → executor.update with current value + amount.
34
+ //
35
+ // **Why no `r.systemScope`:** counters are tenant-scoped (one row per
36
+ // tenant per cap per period). The dispatcher's tenant-filter on ctx.db
37
+ // ensures a tenant can only see/increment their own counters. Cross-
38
+ // tenant cap-rebuild for ops uses raw DB-access at the framework layer,
39
+ // not this handler.
40
+ export const incrementCapHandler: WriteHandlerDef = {
41
+ name: "increment",
42
+ schema: incrementSchema,
43
+ // Internal handler — only system-callers (Plattform-foundations after
44
+ // a successful side-effect) drive this. Tenant-end-users never call
45
+ // it directly. SystemAdmin-access leaves a clear audit row showing
46
+ // which subsystem incremented.
47
+ access: { roles: ["SystemAdmin"] },
48
+ handler: async (event, ctx) => {
49
+ const payload = event.payload as IncrementPayload;
50
+ const aggregateId = capCounterAggregateId(
51
+ event.user.tenantId,
52
+ payload.capName,
53
+ payload.periodStartIso,
54
+ );
55
+
56
+ // Read existing aggregate's projection-row to decide create vs update.
57
+ // ctx.db is auto-tenant-scoped — id-lookup is unique per tenant.
58
+ const existing = await ctx.db.select().from(table).where(eq(table["id"], aggregateId)).limit(1);
59
+
60
+ if (existing.length === 0) {
61
+ return executor.create(
62
+ {
63
+ id: aggregateId,
64
+ capName: payload.capName,
65
+ value: payload.amount,
66
+ periodStart: Temporal.Instant.from(payload.periodStartIso),
67
+ lastSoftWarnedAt: null,
68
+ },
69
+ event.user,
70
+ ctx.db,
71
+ );
72
+ }
73
+
74
+ const currentRow = existing[0];
75
+ if (!currentRow) {
76
+ // Defensive — length-check above means this is unreachable. Throws
77
+ // clearer than a possibly-null deref later.
78
+ throw new Error("cap-counter:increment: row vanished between length-check and read");
79
+ }
80
+ const currentValue = currentRow["value"] as number;
81
+ const currentVersion = currentRow["version"] as number;
82
+ return executor.update(
83
+ {
84
+ id: aggregateId,
85
+ version: currentVersion,
86
+ changes: { value: currentValue + payload.amount },
87
+ },
88
+ event.user,
89
+ ctx.db,
90
+ );
91
+ },
92
+ };
@@ -0,0 +1,57 @@
1
+ import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { eq } from "drizzle-orm";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import { z } from "zod";
5
+ import { capCounterAggregateId } from "../aggregate-id";
6
+ import { capCounterEntity } from "../entity";
7
+
8
+ const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
9
+
10
+ // mark-soft-warned — sets lastSoftWarnedAt on the counter so subsequent
11
+ // soft-cap-hits in the same period don't re-trigger notifications.
12
+ // Anti-Notification-Storm-Schutz aus Memory `project_pricing_byok_caps`.
13
+ //
14
+ // **Caller-Pattern:** enforceCap-Helper checks if value crosses the
15
+ // soft threshold AND lastSoftWarnedAt is null → calls this handler →
16
+ // emits whatever notification (delivery-feature, ops-alert, etc.). The
17
+ // emit-side is app-specific; this handler only sets the flag.
18
+ const markSoftWarnedSchema = z.object({
19
+ capName: z.string().min(1).max(100),
20
+ periodStartIso: z.string().min(1),
21
+ });
22
+
23
+ export const markSoftWarnedHandler: WriteHandlerDef = {
24
+ name: "mark-soft-warned",
25
+ schema: markSoftWarnedSchema,
26
+ access: { roles: ["SystemAdmin"] },
27
+ handler: async (event, ctx) => {
28
+ const payload = event.payload as z.infer<typeof markSoftWarnedSchema>;
29
+ const aggregateId = capCounterAggregateId(
30
+ event.user.tenantId,
31
+ payload.capName,
32
+ payload.periodStartIso,
33
+ );
34
+
35
+ const existing = await ctx.db.select().from(table).where(eq(table["id"], aggregateId)).limit(1);
36
+ if (existing.length === 0) {
37
+ throw new Error(
38
+ `cap-counter: cannot mark-soft-warned, no counter found for tenant=${event.user.tenantId} cap=${payload.capName} period=${payload.periodStartIso}`,
39
+ );
40
+ }
41
+ const row = existing[0];
42
+ if (!row) {
43
+ throw new Error("cap-counter:mark-soft-warned: row vanished between length-check and read");
44
+ }
45
+ const currentVersion = row["version"] as number;
46
+
47
+ return executor.update(
48
+ {
49
+ id: aggregateId,
50
+ version: currentVersion,
51
+ changes: { lastSoftWarnedAt: Temporal.Now.instant() },
52
+ },
53
+ event.user,
54
+ ctx.db,
55
+ );
56
+ },
57
+ };
@@ -0,0 +1,34 @@
1
+ // Public API of the cap-counter bundled-feature.
2
+
3
+ export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id";
4
+ export {
5
+ CAP_COUNTER_FEATURE,
6
+ CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
7
+ CapCounterHandlers,
8
+ CapCounterQueries,
9
+ ROLLING_INCREMENTED_EVENT_QN,
10
+ ROLLING_INCREMENTED_EVENT_SHORT,
11
+ } from "./constants";
12
+ export {
13
+ CAP_TOLERANCES,
14
+ CapExceededError,
15
+ type CapToleranceProfile,
16
+ type CapToleranceProfileName,
17
+ currentCalendarMonthStartIso,
18
+ type EnforceCapResult,
19
+ enforceCap,
20
+ enforceCapAndMaybeNotify,
21
+ enforceRollingCap,
22
+ enforceRollingCapAndMaybeNotify,
23
+ type SoftHitNotifier,
24
+ } from "./enforce-cap";
25
+ export { capCounterEntity } from "./entity";
26
+ export { capCounterFeature } from "./feature";
27
+ export {
28
+ type CalendarCapDef,
29
+ type CalendarCapResolver,
30
+ type RollingCapDef,
31
+ type RollingCapResolver,
32
+ withCapEnforcement,
33
+ withRollingCapEnforcement,
34
+ } from "./with-cap-enforcement";
@@ -0,0 +1,179 @@
1
+ // withCapEnforcement / withRollingCapEnforcement — handler-wrapper die
2
+ // pre-call enforceCap-And-Notify + post-call increment um den
3
+ // gewrappten Handler legen.
4
+ //
5
+ // **Warum Wrapper statt manuelle Calls im Handler:**
6
+ // Pattern-konsistenz. Wer einen cap-bedingten Handler schreibt,
7
+ // darf nicht vergessen den counter zu incrementen oder den enforce-
8
+ // pre-call zu machen — beides ist atomic-mit-dem-Handler-zusammen.
9
+ // Wrapper macht das Pattern explizit + co-located.
10
+ //
11
+ // **Atomicity-Vorbehalt:** Pre-enforce + Handler + Post-Increment
12
+ // laufen in DREI getrennten Transaktionen (Dispatcher öffnet jede
13
+ // ctx.write-call eine eigene). Bei einem Crash zwischen Handler-
14
+ // Success und Post-Increment kommt der Counter unter — Tenant
15
+ // kriegt 1-2 Mails extra. Akzeptabel weil Cap-Toleranzen (110/120%)
16
+ // genau für solche Drift-Fälle gebaut sind.
17
+ //
18
+ // **Kein automatic markSoftWarned:** das passiert in
19
+ // enforceCapAndMaybeNotify drin (siehe enforce-cap.ts). Wrapper ruft
20
+ // nur den Helper, der den write dispatched.
21
+
22
+ import type {
23
+ HandlerContext,
24
+ WriteEvent,
25
+ WriteHandlerDef,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { CapCounterHandlers } from "./constants";
28
+ import {
29
+ type CapToleranceProfileName,
30
+ enforceCapAndMaybeNotify,
31
+ enforceRollingCapAndMaybeNotify,
32
+ type SoftHitNotifier,
33
+ } from "./enforce-cap";
34
+
35
+ // =============================================================================
36
+ // Calendar-Period-Wrapper
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Pro-call dynamische Cap-Definition. Wird vor jedem Handler-Aufruf
41
+ * neu evaluiert — typischer Caller liest hier den Tenant-Tier aus
42
+ * dem ctx, mappt ihn auf einen Limit-Wert. `amount` default 1 (count-
43
+ * events); für byte/token-cap übergibt der Caller die Größe aus
44
+ * `event.payload`.
45
+ */
46
+ export type CalendarCapDef = {
47
+ readonly capName: string;
48
+ readonly periodStartIso: string;
49
+ readonly limit: number;
50
+ readonly profile: CapToleranceProfileName;
51
+ /** Increment-amount post-success. Default 1. */
52
+ readonly amount?: number;
53
+ readonly notify: SoftHitNotifier;
54
+ };
55
+
56
+ /** Resolver-fn that the wrapper calls before each handler invocation
57
+ * to compute the cap-spec for THIS request (e.g. limit derived from
58
+ * tenant-tier). Sync OR async — async lets the caller fetch the
59
+ * tier from DB. */
60
+ export type CalendarCapResolver = (
61
+ event: WriteEvent,
62
+ ctx: HandlerContext,
63
+ ) => Promise<CalendarCapDef> | CalendarCapDef;
64
+
65
+ /**
66
+ * Wrap a write-handler with calendar-period cap-enforcement.
67
+ *
68
+ * Flow:
69
+ * 1. resolve cap-spec via `capResolver(event, ctx)`
70
+ * 2. pre-call: `enforceCapAndMaybeNotify` — throws CapExceededError
71
+ * on hard-hit (handler never runs), notifies on soft-hit-crossing
72
+ * 3. invoke the wrapped handler
73
+ * 4. post-success: dispatch `cap-counter:write:increment` with `amount`
74
+ *
75
+ * The returned handler-def keeps the original name/schema/access
76
+ * untouched — only the handler-fn is wrapped. The dispatcher sees
77
+ * the same external contract.
78
+ */
79
+ export function withCapEnforcement(
80
+ handler: WriteHandlerDef,
81
+ capResolver: CalendarCapResolver,
82
+ ): WriteHandlerDef {
83
+ return {
84
+ name: handler.name,
85
+ schema: handler.schema,
86
+ access: handler.access,
87
+ handler: async (event, ctx) => {
88
+ const cap = await capResolver(event, ctx);
89
+
90
+ // Pre-enforce. Hard-hit throws CapExceededError; the dispatcher
91
+ // maps it to HTTP 429 + cap_exceeded code. Soft-hit-crossing
92
+ // notifies via the supplied notifier + flips lastSoftWarnedAt.
93
+ await enforceCapAndMaybeNotify(ctx, {
94
+ capName: cap.capName,
95
+ periodStartIso: cap.periodStartIso,
96
+ limit: cap.limit,
97
+ profile: cap.profile,
98
+ notify: cap.notify,
99
+ });
100
+
101
+ const result = await handler.handler(event, ctx);
102
+
103
+ // Post-success increment. Skip on failure so a failed write
104
+ // doesn't burn cap-quota. amount default 1.
105
+ if (result.isSuccess) {
106
+ await ctx.write(CapCounterHandlers.increment, {
107
+ capName: cap.capName,
108
+ amount: cap.amount ?? 1,
109
+ periodStartIso: cap.periodStartIso,
110
+ });
111
+ }
112
+
113
+ return result;
114
+ },
115
+ };
116
+ }
117
+
118
+ // =============================================================================
119
+ // Rolling-Window-Wrapper
120
+ // =============================================================================
121
+
122
+ export type RollingCapDef = {
123
+ readonly capName: string;
124
+ readonly windowDays: number;
125
+ readonly limit: number;
126
+ readonly profile: CapToleranceProfileName;
127
+ readonly amount?: number;
128
+ readonly notify: SoftHitNotifier;
129
+ };
130
+
131
+ export type RollingCapResolver = (
132
+ event: WriteEvent,
133
+ ctx: HandlerContext,
134
+ ) => Promise<RollingCapDef> | RollingCapDef;
135
+
136
+ /**
137
+ * Wrap a write-handler with rolling-window cap-enforcement.
138
+ *
139
+ * Same flow as `withCapEnforcement` but uses
140
+ * `enforceRollingCapAndMaybeNotify` + dispatches
141
+ * `cap-counter:write:increment-rolling` post-success.
142
+ *
143
+ * **Notification-Storm-Caveat:** rolling-counter trackt KEIN
144
+ * lastSoftWarnedAt — der Notifier feuert bei JEDEM Call solange
145
+ * der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
146
+ * (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
147
+ */
148
+ export function withRollingCapEnforcement(
149
+ handler: WriteHandlerDef,
150
+ capResolver: RollingCapResolver,
151
+ ): WriteHandlerDef {
152
+ return {
153
+ name: handler.name,
154
+ schema: handler.schema,
155
+ access: handler.access,
156
+ handler: async (event, ctx) => {
157
+ const cap = await capResolver(event, ctx);
158
+
159
+ await enforceRollingCapAndMaybeNotify(ctx, {
160
+ capName: cap.capName,
161
+ windowDays: cap.windowDays,
162
+ limit: cap.limit,
163
+ profile: cap.profile,
164
+ notify: cap.notify,
165
+ });
166
+
167
+ const result = await handler.handler(event, ctx);
168
+
169
+ if (result.isSuccess) {
170
+ await ctx.write(CapCounterHandlers.incrementRolling, {
171
+ capName: cap.capName,
172
+ amount: cap.amount ?? 1,
173
+ });
174
+ }
175
+
176
+ return result;
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,48 @@
1
+ import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { DeliveryChannel, NotificationRenderer } from "../delivery";
4
+ import type { EmailTransport } from "./types";
5
+
6
+ export type EmailChannelOptions = {
7
+ readonly transport: EmailTransport;
8
+ readonly renderer: NotificationRenderer;
9
+ readonly resolveEmail: (
10
+ userId: string,
11
+ ctx: { db: unknown; tenantId: TenantId },
12
+ ) => Promise<string | null>;
13
+ };
14
+
15
+ export function createEmailChannel(options: EmailChannelOptions): DeliveryChannel {
16
+ const { transport, renderer, resolveEmail } = options;
17
+
18
+ return {
19
+ name: "email",
20
+
21
+ async resolve(userId, ctx) {
22
+ return resolveEmail(userId, ctx);
23
+ },
24
+
25
+ async send(address, message, _ctx) {
26
+ // Build renderer input: per-channel template data (if any) or fall back
27
+ // to title/body from the message. Renderer handles both cases.
28
+ const variables = (message.data as DbRow) ?? {
29
+ title: message.title,
30
+ body: message.body,
31
+ };
32
+
33
+ const html = await renderer.render({
34
+ template: message.notificationType,
35
+ variables,
36
+ });
37
+ const subject = (variables["subject"] as string) ?? message.title;
38
+
39
+ await transport.send({
40
+ to: address,
41
+ subject,
42
+ html,
43
+ });
44
+
45
+ return { status: "sent", address };
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,15 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createEmailChannel, type EmailChannelOptions } from "./email-channel";
3
+
4
+ export function createChannelEmailFeature(options: EmailChannelOptions): FeatureDefinition {
5
+ const channel = createEmailChannel(options);
6
+
7
+ return defineFeature("channelEmail", (r) => {
8
+ r.requires("delivery");
9
+
10
+ r.useExtension("deliveryChannel", "email", {
11
+ resolve: channel.resolve,
12
+ send: channel.send,
13
+ });
14
+ });
15
+ }
@@ -0,0 +1,4 @@
1
+ export { createEmailChannel, type EmailChannelOptions } from "./email-channel";
2
+ export { createChannelEmailFeature } from "./feature";
3
+ export { createSmtpTransport, type SmtpTransportOptions } from "./smtp-transport";
4
+ export { createInMemoryTransport, type EmailMessage, type EmailTransport } from "./types";
@@ -0,0 +1,65 @@
1
+ // SMTP-Transport für EmailTransport-Interface. nodemailer-basiert
2
+ // (battle-tested, TLS-AUTH-Pool-Reconnect handled). Universaler default
3
+ // für apps die keinen Vendor-spezifischen Sender wollen — funktioniert
4
+ // gegen jeden SMTP-Server (Gmail, eigener Postfix, Brevo-SMTP-relay,
5
+ // Office 365, Mailhog für lokales testing).
6
+ //
7
+ // Why SMTP statt Vendor-API als default:
8
+ // 1. EU-Story: kein Daten an US-Vendor, App-Owner wählt Server.
9
+ // 2. Self-Hosting: Customer kann eigenen Mailserver nutzen, kein
10
+ // external account.
11
+ // 3. Universalität: jeder Vendor (Brevo, Resend, Mailgun, Postmark)
12
+ // bietet auch SMTP — wer Brevo will, setzt Brevos SMTP-Credentials.
13
+ //
14
+ // Transport-Pool: nodemailer.createTransport({pool: true}) hält bis zu
15
+ // 5 Verbindungen offen + reused. Bei kleinen Apps reicht das; für High-
16
+ // Volume-Apps muss der Caller eine eigene Implementation mit
17
+ // dedizierter Queue (BullMQ + retry) drüberlegen.
18
+
19
+ import { createTransport, type Transporter } from "nodemailer";
20
+ import type { EmailMessage, EmailTransport } from "./types";
21
+
22
+ export type SmtpTransportOptions = {
23
+ /** SMTP-Server-Host (z.B. "smtp.gmail.com", "in-v3.mailjet.com",
24
+ * "localhost" für Mailhog/MailCatcher in dev). */
25
+ readonly host: string;
26
+ /** Default 587 (STARTTLS). 465 für implicit-TLS, 25 für unencrypted
27
+ * (nur lokal/intern, nie public). */
28
+ readonly port?: number;
29
+ /** TLS-Mode: true = implicit TLS auf port 465, false = STARTTLS auf
30
+ * 587 (oder plain auf 25). Default false (STARTTLS standard). */
31
+ readonly secure?: boolean;
32
+ /** Optional auth — manche internal-relays nehmen IP-whitelisting statt
33
+ * Login. Wenn gesetzt, beide felder pflicht. */
34
+ readonly auth?: {
35
+ readonly user: string;
36
+ readonly pass: string;
37
+ };
38
+ /** Standard-From-Adresse für jede Mail. EmailMessage hat kein from-
39
+ * Feld — die Auswahl gehört zur Transport-Konfig (App-weit, nicht
40
+ * pro Mail). Format akzeptiert beides: "noreply@ex.com" oder
41
+ * "Name <noreply@ex.com>". */
42
+ readonly from: string;
43
+ };
44
+
45
+ export function createSmtpTransport(options: SmtpTransportOptions): EmailTransport {
46
+ const transporter: Transporter = createTransport({
47
+ host: options.host,
48
+ port: options.port ?? 587,
49
+ secure: options.secure ?? false,
50
+ ...(options.auth && { auth: options.auth }),
51
+ pool: true,
52
+ maxConnections: 5,
53
+ });
54
+
55
+ return {
56
+ async send(message: EmailMessage): Promise<void> {
57
+ await transporter.sendMail({
58
+ from: options.from,
59
+ to: message.to,
60
+ subject: message.subject,
61
+ html: message.html,
62
+ });
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,34 @@
1
+ // Transport interface — SMTP in prod, InMemory in tests
2
+ export type EmailMessage = {
3
+ readonly to: string;
4
+ readonly subject: string;
5
+ readonly html: string;
6
+ };
7
+
8
+ export type EmailTransport = {
9
+ send(message: EmailMessage): Promise<void>;
10
+ };
11
+
12
+ // InMemory transport for testing — collects sent emails.
13
+ // `failNext` lets a test simulate a transient SMTP failure without
14
+ // rebuilding the whole stack: set it before a single `notify()` call and
15
+ // the transport throws once, then auto-resets.
16
+ export function createInMemoryTransport(): EmailTransport & {
17
+ readonly sent: EmailMessage[];
18
+ failNext: null | { message: string };
19
+ } {
20
+ const sent: EmailMessage[] = [];
21
+ const transport = {
22
+ sent,
23
+ failNext: null as null | { message: string },
24
+ async send(message: EmailMessage) {
25
+ if (transport.failNext) {
26
+ const err = new Error(transport.failNext.message);
27
+ transport.failNext = null;
28
+ throw err;
29
+ }
30
+ sent.push(message);
31
+ },
32
+ };
33
+ return transport;
34
+ }
@@ -0,0 +1,11 @@
1
+ export const CHANNEL_IN_APP_FEATURE = "channelInApp" as const;
2
+
3
+ export const InAppHandlers = {
4
+ markRead: "channel-in-app:write:mark-read",
5
+ markAllRead: "channel-in-app:write:mark-all-read",
6
+ } as const;
7
+
8
+ export const InAppQueries = {
9
+ inbox: "channel-in-app:query:inbox",
10
+ unreadCount: "channel-in-app:query:unread-count",
11
+ } as const;
@@ -0,0 +1,30 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { inboxQuery } from "./handlers/inbox.query";
3
+ import { markAllReadWrite } from "./handlers/mark-all-read.write";
4
+ import { markReadWrite } from "./handlers/mark-read.write";
5
+ import { unreadCountQuery } from "./handlers/unread-count.query";
6
+ import { inAppChannel } from "./in-app-channel";
7
+
8
+ export function createChannelInAppFeature(): FeatureDefinition {
9
+ return defineFeature("channelInApp", (r) => {
10
+ r.requires("delivery");
11
+
12
+ // Register as delivery channel via extension system
13
+ r.useExtension("deliveryChannel", "inApp", {
14
+ resolve: inAppChannel.resolve,
15
+ send: inAppChannel.send,
16
+ });
17
+
18
+ const handlers = {
19
+ markRead: r.writeHandler(markReadWrite),
20
+ markAllRead: r.writeHandler(markAllReadWrite),
21
+ };
22
+
23
+ const queries = {
24
+ inbox: r.queryHandler(inboxQuery),
25
+ unreadCount: r.queryHandler(unreadCountQuery),
26
+ };
27
+
28
+ return { handlers, queries };
29
+ });
30
+ }