@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,46 @@
1
+ // feature.ts contract tests — pin the public surface of the
2
+ // Plugin-API-shaped mail-foundation. Provider-specific configs/secrets
3
+ // are tested in their own provider-feature (mail-transport-smtp/__tests__).
4
+ //
5
+ // **Pattern-Vorbild:** mirrors delivery-feature shape — the foundation
6
+ // declares an extension-point + a single selector config-key, nothing
7
+ // provider-concrete.
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { mailFoundationFeature } from "../feature";
11
+
12
+ describe("mailFoundationFeature — shape", () => {
13
+ test("has the expected name", () => {
14
+ expect(mailFoundationFeature.name).toBe("mail-foundation");
15
+ });
16
+
17
+ test("declares config as a hard requirement (provider-selector lives there)", () => {
18
+ expect(mailFoundationFeature.requires).toContain("config");
19
+ });
20
+
21
+ test("does NOT require secrets — provider-plugins own their own secrets", () => {
22
+ // The foundation knows nothing about SMTP-passwords; only the SMTP
23
+ // plugin-feature requires secrets. This separation lets a Brevo-
24
+ // API-only deployment skip the secrets-feature if Brevo's provider
25
+ // uses tenant-config text-keys instead.
26
+ expect(mailFoundationFeature.requires).not.toContain("secrets");
27
+ });
28
+ });
29
+
30
+ describe("mailFoundationFeature.exports — typed handles", () => {
31
+ test("exposes only the provider-selector config-key", () => {
32
+ const keys = mailFoundationFeature.exports.configKeys;
33
+ expect(keys.provider).toBeDefined();
34
+ // No host/port/from/authUser — those live in the provider-plugin.
35
+ expect((keys as Record<string, unknown>)["host"]).toBeUndefined();
36
+ expect((keys as Record<string, unknown>)["port"]).toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe("mailFoundationFeature — registers extension-point", () => {
41
+ test("declares the 'mailTransport' extension-point that providers register against", () => {
42
+ // r.extendsRegistrar("mailTransport", ...) lands in
43
+ // feature.registrarExtensions keyed by extension-name.
44
+ expect(mailFoundationFeature.registrarExtensions["mailTransport"]).toBeDefined();
45
+ });
46
+ });
@@ -0,0 +1,247 @@
1
+ // Full-stack integration test for mail-foundation. Drives the
2
+ // transport-factory through the dispatcher so the real config-resolver +
3
+ // secrets-context + tenant-scoped reads are exercised — the same path
4
+ // production handlers will hit when sending mail.
5
+ //
6
+ // **Test-Handler-Pattern:** we register a tiny one-off feature with a
7
+ // write-handler that calls createTransportForTenant + reports back what
8
+ // it saw. That's the cheapest way to get a real `HandlerContext` in a
9
+ // test without re-implementing the dispatcher.
10
+
11
+ import { randomBytes } from "node:crypto";
12
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
+ import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
+ import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
16
+ import {
17
+ createEntityTable,
18
+ createTestUser,
19
+ pushTables,
20
+ setupTestStack,
21
+ type TestStack,
22
+ testTenantId,
23
+ } from "@cosmicdrift/kumiko-framework/stack";
24
+ import {
25
+ createMutableMasterKeyProvider,
26
+ type MutableMasterKeyProvider,
27
+ } from "@cosmicdrift/kumiko-framework/testing";
28
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
29
+ import { z } from "zod";
30
+ import { createConfigFeature } from "../../config";
31
+ import { ConfigHandlers } from "../../config/constants";
32
+ import { createConfigAccessorFactory } from "../../config/feature";
33
+ import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
34
+ import { configValuesTable } from "../../config/table";
35
+ import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../../mail-transport-smtp";
36
+ import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
37
+ import { createTenantFeature } from "../../tenant/feature";
38
+ import { tenantEntity } from "../../tenant/schema/tenant";
39
+ import { createTransportForTenant, mailFoundationFeature } from "../feature";
40
+
41
+ // --- Test-Handler that exercises the factory end-to-end ---
42
+
43
+ const TEST_HANDLER_QN = "mail-test:write:build-transport";
44
+ const testProbeFeature = defineFeature("mail-test", (r) => {
45
+ r.requires("config");
46
+ r.requires("secrets");
47
+ r.writeHandler(
48
+ defineWriteHandler({
49
+ name: "build-transport",
50
+ schema: z.object({}),
51
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
52
+ handler: async (event, ctx) => {
53
+ const transport = await createTransportForTenant(ctx, event.user.tenantId, TEST_HANDLER_QN);
54
+ return {
55
+ isSuccess: true,
56
+ data: { hasSend: typeof transport.send === "function" },
57
+ };
58
+ },
59
+ }),
60
+ );
61
+ });
62
+
63
+ // --- Setup ---
64
+
65
+ let stack: TestStack;
66
+ let db: DbConnection;
67
+ let resolver: ConfigResolver;
68
+ let providerRef: MutableMasterKeyProvider;
69
+
70
+ const testEncryptionKey = randomBytes(32).toString("base64");
71
+
72
+ beforeAll(async () => {
73
+ const encryption = createEncryptionProvider(testEncryptionKey);
74
+ resolver = createConfigResolver({ encryption });
75
+
76
+ // Master-key for the secrets-feature. Production env shape:
77
+ // KUMIKO_SECRETS_MASTER_KEY_V1=<base64 32 bytes>
78
+ // KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION=1
79
+ const initialKp = createEnvMasterKeyProvider({
80
+ env: {
81
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
82
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
83
+ },
84
+ });
85
+ providerRef = createMutableMasterKeyProvider(initialKp);
86
+
87
+ stack = await setupTestStack({
88
+ features: [
89
+ createConfigFeature(),
90
+ createTenantFeature(),
91
+ createSecretsFeature(),
92
+ mailFoundationFeature,
93
+ mailTransportSmtpFeature,
94
+ testProbeFeature,
95
+ ],
96
+ masterKeyProvider: providerRef,
97
+ extraContext: ({ db, registry }) => ({
98
+ configResolver: resolver,
99
+ configEncryption: encryption,
100
+ // _configAccessorFactory wires `ctx.config(handle)` for every
101
+ // dispatched handler. Without it createTransportForTenant fails
102
+ // with "ctx.config is missing".
103
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
104
+ secrets: createSecretsContext({ db, masterKeyProvider: providerRef }),
105
+ }),
106
+ });
107
+ db = stack.db;
108
+
109
+ await createEntityTable(db, tenantEntity);
110
+ await pushTables(db, { configValuesTable, tenant_secrets: tenantSecretsTable });
111
+ await createEventsTable(db);
112
+ });
113
+
114
+ afterAll(async () => {
115
+ await stack.cleanup();
116
+ });
117
+
118
+ function adminFor(tenantNumber: number) {
119
+ return createTestUser({
120
+ id: tenantNumber,
121
+ tenantId: testTenantId(tenantNumber),
122
+ roles: ["TenantAdmin", "SystemAdmin"],
123
+ });
124
+ }
125
+
126
+ async function setConfig(admin: ReturnType<typeof adminFor>, key: string, value: unknown) {
127
+ await stack.http.writeOk(ConfigHandlers.set, { key, value }, admin);
128
+ }
129
+
130
+ /** Set the mail-foundation provider-selector to "smtp". Plugin-API
131
+ * needs this — without it the foundation-factory doesn't know which
132
+ * registered transport to use. */
133
+ async function selectSmtpProvider(admin: ReturnType<typeof adminFor>) {
134
+ await setConfig(admin, "mail-foundation:config:provider", "smtp");
135
+ }
136
+
137
+ // --- Scenario 1: full happy-path roundtrip ---
138
+
139
+ describe("scenario 1: happy path", () => {
140
+ test("admin sets config + secret → factory builds working transport", async () => {
141
+ const admin = adminFor(401);
142
+
143
+ // Plugin-API: select "smtp" — foundation looks it up in the registry.
144
+ await selectSmtpProvider(admin);
145
+
146
+ // Tenant configures their SMTP — Mailhog-style local test server.
147
+ await setConfig(admin, "mail-transport-smtp:config:host", "localhost");
148
+ await setConfig(admin, "mail-transport-smtp:config:port", 1025);
149
+ await setConfig(admin, "mail-transport-smtp:config:from", "noreply@test.local");
150
+ await setConfig(admin, "mail-transport-smtp:config:auth-user", "admin@test.local");
151
+
152
+ // Sensitive: SMTP password via the secrets-write handler.
153
+ await stack.http.writeOk(
154
+ "secrets:write:set",
155
+ { key: SMTP_PASSWORD.name, value: "test-password-123" },
156
+ admin,
157
+ );
158
+
159
+ // Drive the factory through a dispatcher-real test-handler.
160
+ // writeOk returns the handler's TData (custom-shaped). For the
161
+ // crud-pattern (tenant/user features) TData is a SaveContext
162
+ // (`{ data, isNew, ... }`); our test-handler returns plain
163
+ // `{ hasSend }` so writeOk's response is just `{ hasSend }`.
164
+ const result = (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<
165
+ string,
166
+ unknown
167
+ >;
168
+ expect(result["hasSend"]).toBe(true);
169
+ });
170
+ });
171
+
172
+ // --- Scenario 2: missing host config is rejected with a clear error ---
173
+
174
+ describe("scenario 2: validation errors", () => {
175
+ test("missing host → factory throws with hint instead of a cryptic SMTP error", async () => {
176
+ const admin = adminFor(402);
177
+
178
+ await selectSmtpProvider(admin);
179
+ // Set everything EXCEPT host. The plugin should fail with a
180
+ // message naming the missing key before touching nodemailer.
181
+ await setConfig(admin, "mail-transport-smtp:config:port", 587);
182
+ await setConfig(admin, "mail-transport-smtp:config:from", "noreply@test.local");
183
+ await setConfig(admin, "mail-transport-smtp:config:auth-user", "admin@test.local");
184
+ await stack.http.writeOk("secrets:write:set", { key: SMTP_PASSWORD.name, value: "pw" }, admin);
185
+
186
+ // writeOk would throw an assertion-error; use writeRaw + check status.
187
+ const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
188
+ expect(JSON.stringify(error)).toMatch(/'host' is empty/);
189
+ });
190
+
191
+ test("missing password secret → factory throws naming the secret", async () => {
192
+ const admin = adminFor(403);
193
+
194
+ await selectSmtpProvider(admin);
195
+ await setConfig(admin, "mail-transport-smtp:config:host", "localhost");
196
+ await setConfig(admin, "mail-transport-smtp:config:port", 587);
197
+ await setConfig(admin, "mail-transport-smtp:config:from", "noreply@test.local");
198
+ await setConfig(admin, "mail-transport-smtp:config:auth-user", "admin@test.local");
199
+ // Skip the secret. requireSecretsContext.get returns undefined,
200
+ // factory throws referencing SMTP_PASSWORD.name.
201
+
202
+ const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
203
+ expect(JSON.stringify(error)).toMatch(/smtp-password/);
204
+ });
205
+ });
206
+
207
+ // --- Scenario 3: tenant isolation (config + secret stay per-tenant) ---
208
+
209
+ describe("scenario 3: tenant isolation", () => {
210
+ test("tenant A's SMTP config doesn't bleed into tenant B's transport", async () => {
211
+ const adminA = adminFor(404);
212
+ const adminB = adminFor(405);
213
+
214
+ await selectSmtpProvider(adminA);
215
+ await selectSmtpProvider(adminB);
216
+
217
+ // Tenant A configures their SMTP.
218
+ await setConfig(adminA, "mail-transport-smtp:config:host", "smtp.tenant-a.test");
219
+ await setConfig(adminA, "mail-transport-smtp:config:port", 587);
220
+ await setConfig(adminA, "mail-transport-smtp:config:from", "a@tenant-a.test");
221
+ await setConfig(adminA, "mail-transport-smtp:config:auth-user", "a-user");
222
+ await stack.http.writeOk(
223
+ "secrets:write:set",
224
+ { key: SMTP_PASSWORD.name, value: "pw-a" },
225
+ adminA,
226
+ );
227
+
228
+ // Tenant B has their OWN SMTP — different host on purpose.
229
+ await setConfig(adminB, "mail-transport-smtp:config:host", "smtp.tenant-b.test");
230
+ await setConfig(adminB, "mail-transport-smtp:config:port", 465);
231
+ await setConfig(adminB, "mail-transport-smtp:config:from", "b@tenant-b.test");
232
+ await setConfig(adminB, "mail-transport-smtp:config:auth-user", "b-user");
233
+ await stack.http.writeOk(
234
+ "secrets:write:set",
235
+ { key: SMTP_PASSWORD.name, value: "pw-b" },
236
+ adminB,
237
+ );
238
+
239
+ // Both factories should succeed — that's the per-tenant promise.
240
+ // The actual host-validation is on the SMTP transport build (object
241
+ // allocation), not a network call.
242
+ const a = (await stack.http.writeOk(TEST_HANDLER_QN, {}, adminA)) as Record<string, unknown>;
243
+ const b = (await stack.http.writeOk(TEST_HANDLER_QN, {}, adminB)) as Record<string, unknown>;
244
+ expect(a["hasSend"]).toBe(true);
245
+ expect(b["hasSend"]).toBe(true);
246
+ });
247
+ });
@@ -0,0 +1,160 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // mail-foundation as a Kumiko bundled feature.
4
+ //
5
+ // **Was diese Feature liefert:**
6
+ // 1. **Plugin-API** für Mail-Transport-Provider via `r.extendsRegistrar
7
+ // ("mailTransport", ...)`. Provider-Features (mail-transport-smtp,
8
+ // mail-transport-brevo-api, ...) registrieren sich namentlich.
9
+ // 2. **Tenant-Config-key** `provider`: select-Wert der zur Runtime
10
+ // bestimmt welcher registrierte Plugin verwendet wird.
11
+ // 3. **createTransportForTenant(ctx, tenantId)** Factory die den
12
+ // gewählten Plugin im Registry sucht und seine `build`-Methode
13
+ // aufruft.
14
+ //
15
+ // **Was diese Foundation NICHT mehr macht (im Vergleich zur ersten
16
+ // Iteration):**
17
+ // - Keine SMTP/Brevo/Postmark-spezifischen Config-Keys mehr in
18
+ // mail-foundation. Provider-spezifische Config (host/port/from/
19
+ // authUser für SMTP, apiUrl/accountId für Brevo etc.) lebt im
20
+ // jeweiligen Provider-Plugin-Feature.
21
+ // - Kein direkter Import von `createSmtpTransport`. Die Foundation
22
+ // kennt nur das `EmailTransport`-Interface (Type-Import, kein
23
+ // runtime-coupling), nicht die konkrete Implementation.
24
+ //
25
+ // **Pattern-Vorbild:** identisch zu `delivery` + `channel-email`. Die
26
+ // delivery-feature deklariert `r.extendsRegistrar("deliveryChannel")`,
27
+ // channel-email registriert sich via `r.useExtension("deliveryChannel",
28
+ // "email", {...})`. Selbe Trennung Foundation ↔ Provider.
29
+ //
30
+ // **Standalone:** Foundation ist ohne tier-engine nutzbar. Existing
31
+ // `channel-email` (App-wide-Mail-Sender via delivery) bleibt unangetastet
32
+ // — additive Feature.
33
+
34
+ import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
35
+ import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
36
+ import {
37
+ access,
38
+ createTenantConfig,
39
+ defineFeature,
40
+ type HandlerContext,
41
+ } from "@cosmicdrift/kumiko-framework/engine";
42
+
43
+ const FEATURE_NAME = "mail-foundation";
44
+
45
+ // =============================================================================
46
+ // Plugin-Interface — what a Provider-Plugin must implement
47
+ // =============================================================================
48
+
49
+ /**
50
+ * Mail-Transport-Plugin contract. Each provider-feature (mail-transport-
51
+ * smtp, mail-transport-brevo-api, ...) registers an implementation via
52
+ * `r.useExtension("mailTransport", "<name>", { build })`.
53
+ *
54
+ * `build(ctx, tenantId)` reads the plugin's own config-keys + secrets
55
+ * (the plugin owns its provider-specific config schema) and constructs
56
+ * an EmailTransport. Per-call construction so a tenant editing config
57
+ * sees the change on the next mail.
58
+ */
59
+ export type MailTransportPlugin = {
60
+ readonly build: (ctx: HandlerContext, tenantId: string) => Promise<EmailTransport>;
61
+ };
62
+
63
+ // =============================================================================
64
+ // Feature-definition
65
+ // =============================================================================
66
+
67
+ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
68
+ r.requires("config");
69
+
70
+ // Plugin extension-point. Provider-features register here. The
71
+ // entityName at registration time becomes the value tenants pick in
72
+ // `provider` config-key (e.g. "smtp", "brevo-api").
73
+ r.extendsRegistrar("mailTransport", {
74
+ onRegister: () => {
75
+ // No side-effects at register-time — the registry stores the
76
+ // usage, factory looks it up at request-time. Same shape as
77
+ // delivery's extendsRegistrar.
78
+ },
79
+ });
80
+
81
+ const configKeys = r.config({
82
+ keys: {
83
+ // Provider-selector. Default empty so the boot-validator throws
84
+ // if a tenant tries to send mail without first picking + setting
85
+ // up a provider — better than a silent fallback.
86
+ // The actual list of valid values lives in the registered plugins,
87
+ // not here — Designer-UI can render `getExtensionUsages
88
+ // ("mailTransport").map(u => u.entityName)` as the option-list.
89
+ provider: createTenantConfig("text", {
90
+ default: "",
91
+ write: access.roles("TenantAdmin", "SystemAdmin"),
92
+ read: access.roles("TenantAdmin", "SystemAdmin", "User"),
93
+ }),
94
+ },
95
+ });
96
+
97
+ return {
98
+ /** Config-key-handle for the provider-selector. */
99
+ configKeys,
100
+ };
101
+ });
102
+
103
+ // =============================================================================
104
+ // Transport-factory — looks up the registered plugin + delegates
105
+ // =============================================================================
106
+
107
+ /**
108
+ * Resolves the tenant's mail-transport. Reads the `provider` config-key,
109
+ * looks up the matching plugin in the registry, calls its `build(ctx,
110
+ * tenantId)`-method.
111
+ *
112
+ * **Caller pattern:**
113
+ * const transport = await createTransportForTenant(ctx, event.user.tenantId);
114
+ * await transport.send({ to, subject, html });
115
+ */
116
+ export async function createTransportForTenant(
117
+ ctx: HandlerContext,
118
+ tenantId: string,
119
+ handlerName = "mail-foundation:transport-factory",
120
+ ): Promise<EmailTransport> {
121
+ const ctxConfig = ctx.config;
122
+ if (!ctxConfig) {
123
+ throw new Error(
124
+ `${handlerName}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
125
+ );
126
+ }
127
+ if (!ctx.registry) {
128
+ throw new Error(
129
+ `${handlerName}: ctx.registry is missing — required to look up registered mail-transport plugins`,
130
+ );
131
+ }
132
+
133
+ const provider = requireDefined(
134
+ await ctxConfig(mailFoundationFeature.exports.configKeys.provider),
135
+ FEATURE_NAME,
136
+ "provider",
137
+ ) as string;
138
+ if (provider.length === 0) {
139
+ const usages = ctx.registry.getExtensionUsages("mailTransport");
140
+ const known = usages.map((u) => u.entityName).join(", ") || "<none>";
141
+ throw new Error(
142
+ `${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
143
+ `Mount a mail-transport-* feature first if no plugins are registered.`,
144
+ );
145
+ }
146
+
147
+ const usages = ctx.registry.getExtensionUsages("mailTransport");
148
+ const usage = usages.find((u) => u.entityName === provider);
149
+ if (!usage) {
150
+ const known = usages.map((u) => u.entityName).join(", ") || "<none>";
151
+ throw new Error(
152
+ `${FEATURE_NAME}: provider "${provider}" not registered. Known: ${known}. ` +
153
+ `Mount the matching mail-transport-${provider} feature.`,
154
+ );
155
+ }
156
+
157
+ // @cast-boundary engine-payload — extension-usage carries unknown options
158
+ const plugin = usage.options as MailTransportPlugin;
159
+ return plugin.build(ctx, tenantId);
160
+ }
@@ -0,0 +1,14 @@
1
+ // Public API of the mail-foundation bundled-feature.
2
+ //
3
+ // **What downstream apps import:**
4
+ // - `mailFoundationFeature` — register at app boot
5
+ // - `createTransportForTenant(ctx, tenantId)` — async factory: looks
6
+ // up the registered transport-plugin, returns its EmailTransport
7
+ // - `MailTransportPlugin` — type that provider-features implement
8
+ // when registering via `r.useExtension("mailTransport", ...)`
9
+
10
+ export {
11
+ createTransportForTenant,
12
+ type MailTransportPlugin,
13
+ mailFoundationFeature,
14
+ } from "./feature";
@@ -0,0 +1,37 @@
1
+ // feature.ts contract tests for mail-transport-inmemory.
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../feature";
5
+
6
+ describe("mailTransportInMemoryFeature — shape", () => {
7
+ test("has the expected name", () => {
8
+ expect(mailTransportInMemoryFeature.name).toBe("mail-transport-inmemory");
9
+ });
10
+
11
+ test("requires only mail-foundation (no config, no secrets — nothing to configure)", () => {
12
+ expect(mailTransportInMemoryFeature.requires).toContain("mail-foundation");
13
+ expect(mailTransportInMemoryFeature.requires).not.toContain("config");
14
+ expect(mailTransportInMemoryFeature.requires).not.toContain("secrets");
15
+ });
16
+ });
17
+
18
+ describe("mailTransportInMemoryFeature — plugin-registration", () => {
19
+ test("registers itself under entityName 'inmemory' for mail-foundation's extension", () => {
20
+ const usages = mailTransportInMemoryFeature.extensionUsages;
21
+ expect(
22
+ usages.some((u) => u.extensionName === "mailTransport" && u.entityName === "inmemory"),
23
+ ).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe("getInbox / clearInbox — per-tenant buffer helpers", () => {
28
+ test("getInbox liefert empty-array für unbekannten Tenant", () => {
29
+ expect(getInbox("never-touched-tenant")).toEqual([]);
30
+ });
31
+
32
+ test("clearInbox auf nicht-existierenden Tenant ist no-op (kein throw)", () => {
33
+ // Defensive — wenn ein Demo-Test clearInbox vor dem ersten send aufruft,
34
+ // soll das nicht crashen.
35
+ expect(() => clearInbox("not-yet-existing")).not.toThrow();
36
+ });
37
+ });
@@ -0,0 +1,90 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // mail-transport-inmemory — In-Memory-EmailTransport für die mail-
4
+ // foundation Plugin-API. Sammelt Mails in einem per-Tenant-Buffer
5
+ // statt sie über einen echten SMTP-Server zu senden. Designed für
6
+ // Demos, Sample-Apps und Tests die ohne Mailpit/Mailcrab/echten SMTP
7
+ // laufen sollen.
8
+ //
9
+ // **Was diese Feature liefert:**
10
+ // 1. Plugin-Registration via `r.useExtension("mailTransport",
11
+ // "inmemory", { build })`. Tenants setzen
12
+ // "mail-foundation:config:provider" auf "inmemory" und kriegen
13
+ // die buffered-Variante.
14
+ // 2. **Pro-Tenant Inbox.** Jeder Tenant hat einen eigenen
15
+ // Mail-Buffer (Map<tenantId, EmailMessage[]>). Demo-Apps können
16
+ // die Inbox via `getInbox(tenantId)` lesen + UI rendern.
17
+ //
18
+ // **Pattern-Vorbild:** mirrors mail-transport-smtp.
19
+ //
20
+ // **NICHT für Production.** Buffer ist im Process-Memory, geht beim
21
+ // Restart verloren. Cap-Counter / Audit-Trail bleiben trotzdem korrekt
22
+ // — die hängen am event-store, nicht am Transport.
23
+
24
+ import type {
25
+ EmailMessage,
26
+ EmailTransport,
27
+ } from "@cosmicdrift/kumiko-bundled-features/channel-email";
28
+ import { createInMemoryTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
29
+ import type { MailTransportPlugin } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
30
+ import { defineFeature, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
31
+
32
+ const FEATURE_NAME = "mail-transport-inmemory";
33
+
34
+ // =============================================================================
35
+ // Per-tenant in-memory buffer
36
+ // =============================================================================
37
+ //
38
+ // Module-level Map weil der Plugin-build-call pro Send einen neuen
39
+ // Transport-Wrapper baut, aber wir wollen dass alle Wrapper für
40
+ // denselben Tenant auf denselben Buffer zeigen. Map<tenantId,
41
+ // InMemoryTransport>.
42
+
43
+ const transportsByTenant = new Map<string, ReturnType<typeof createInMemoryTransport>>();
44
+
45
+ function getOrCreateTransportForTenant(tenantId: string) {
46
+ let transport = transportsByTenant.get(tenantId);
47
+ if (!transport) {
48
+ transport = createInMemoryTransport();
49
+ transportsByTenant.set(tenantId, transport);
50
+ }
51
+ return transport;
52
+ }
53
+
54
+ /**
55
+ * Demo/Test-Helper: lies die "versendeten" Mails eines Tenants. Im
56
+ * Sample-App rendert ein query-Handler darauf die Inbox-UI.
57
+ */
58
+ export function getInbox(tenantId: string): readonly EmailMessage[] {
59
+ return transportsByTenant.get(tenantId)?.sent ?? [];
60
+ }
61
+
62
+ /**
63
+ * Demo/Test-Helper: clear einen Tenant-Buffer (z.B. zwischen Test-
64
+ * Szenarien).
65
+ */
66
+ export function clearInbox(tenantId: string): void {
67
+ const t = transportsByTenant.get(tenantId);
68
+ if (t) t.sent.length = 0;
69
+ }
70
+
71
+ // =============================================================================
72
+ // Feature-definition
73
+ // =============================================================================
74
+
75
+ export const mailTransportInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
76
+ // Kein r.requires("config") + kein r.requires("secrets") — der
77
+ // In-Memory-Transport hat keine Config (nichts zu konfigurieren) und
78
+ // kein Secret. Der einzige hard-require ist mail-foundation, das den
79
+ // extension-point "mailTransport" definiert.
80
+ r.requires("mail-foundation");
81
+
82
+ const plugin: MailTransportPlugin = {
83
+ build: async (_ctx: HandlerContext, tenantId: string): Promise<EmailTransport> => {
84
+ // Returnt den per-tenant Buffer. Identitätsstabil zwischen calls
85
+ // damit die Demo-Inbox accumulated bleibt.
86
+ return getOrCreateTransportForTenant(tenantId);
87
+ },
88
+ };
89
+ r.useExtension("mailTransport", "inmemory", plugin);
90
+ });
@@ -0,0 +1,3 @@
1
+ // Public API of the mail-transport-inmemory bundled-feature.
2
+
3
+ export { clearInbox, getInbox, mailTransportInMemoryFeature } from "./feature";
@@ -0,0 +1,61 @@
1
+ // feature.ts contract tests for mail-transport-smtp — pins the
2
+ // SMTP-specific config-keys + secret-handle that the plugin owns.
3
+ // Plugin-registration shape is also pinned (drift-pin: name "smtp",
4
+ // build-fn presence).
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../feature";
8
+
9
+ describe("mailTransportSmtpFeature — shape", () => {
10
+ test("has the expected name", () => {
11
+ expect(mailTransportSmtpFeature.name).toBe("mail-transport-smtp");
12
+ });
13
+
14
+ test("requires config + secrets + mail-foundation as hard dependencies", () => {
15
+ expect(mailTransportSmtpFeature.requires).toContain("config");
16
+ expect(mailTransportSmtpFeature.requires).toContain("secrets");
17
+ expect(mailTransportSmtpFeature.requires).toContain("mail-foundation");
18
+ });
19
+ });
20
+
21
+ describe("mailTransportSmtpFeature.exports — typed handles", () => {
22
+ test("exports.configKeys covers the SMTP-config knobs", () => {
23
+ const keys = mailTransportSmtpFeature.exports.configKeys;
24
+ expect(keys.host).toBeDefined();
25
+ expect(keys.port).toBeDefined();
26
+ expect(keys.secure).toBeDefined();
27
+ expect(keys.from).toBeDefined();
28
+ expect(keys.authUser).toBeDefined();
29
+ });
30
+
31
+ test("exports.password is the SMTP_PASSWORD secret-handle (drift-pin)", () => {
32
+ expect(mailTransportSmtpFeature.exports.password).toBe(SMTP_PASSWORD);
33
+ expect(SMTP_PASSWORD.name).toBe("mail-transport-smtp:secret:smtp-password");
34
+ });
35
+ });
36
+
37
+ describe("SMTP_PASSWORD — generic redaction", () => {
38
+ const secretDef = mailTransportSmtpFeature.secretKeys["smtp.password"];
39
+
40
+ test("redact preserves first 3 + last 2 chars for verifiability on long keys", () => {
41
+ expect(secretDef?.redact).toBeDefined();
42
+ expect(secretDef?.redact?.("brevoXKEY01abc")).toMatch(/^bre\.\.\.bc$/);
43
+ });
44
+
45
+ test("redact masks short keys completely (no leak on under-8-char input)", () => {
46
+ expect(secretDef?.redact?.("shortpw")).toBe("•".repeat(7));
47
+ });
48
+ });
49
+
50
+ describe("mailTransportSmtpFeature — plugin-registration", () => {
51
+ test("registers itself under entityName 'smtp' for mail-foundation's extension", () => {
52
+ // r.useExtension("mailTransport", "smtp", ...) lands in the feature's
53
+ // feature.extensionUsages. Drift-pin: tenant sets `mail-foundation:
54
+ // config:provider = "smtp"` and the foundation-factory looks up by
55
+ // exactly this name.
56
+ const usages = mailTransportSmtpFeature.extensionUsages;
57
+ expect(usages.some((u) => u.extensionName === "mailTransport" && u.entityName === "smtp")).toBe(
58
+ true,
59
+ );
60
+ });
61
+ });