@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,109 @@
1
+ // Retention/cleanup job for user_sessions. Without this the table grows
2
+ // monotonically: every login adds one row, logout only flips revokedAt. A
3
+ // long-lived app would eventually accumulate millions of dead rows that
4
+ // slow the sessionChecker point-read down and bloat backups.
5
+ //
6
+ // Policy:
7
+ // - Delete rows whose expiresAt is older than `olderThanDays` (default
8
+ // 30d). Expired sessions can never go live again; the audit trail
9
+ // isn't useful past the retention window.
10
+ // - Delete rows whose revokedAt is older than `olderThanDays`. Same
11
+ // reasoning: a session revoked months ago has no operational value.
12
+ // - NEVER delete currently-live rows. Safe by construction — the WHERE
13
+ // clause requires either expiresAt OR revokedAt to be past-cutoff.
14
+ //
15
+ // Chunked DELETE (default 1000/batch) keeps lock durations bounded. Ops
16
+ // schedules this daily (manual trigger by default — opt-in to cron in the
17
+ // app's feature-wiring so a dev environment doesn't churn through a fresh
18
+ // seed). Mirror of secrets/retention.job in shape.
19
+
20
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
21
+ import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
23
+ import { or, sql } from "drizzle-orm";
24
+ import { userSessionTable } from "../schema/user-session";
25
+
26
+ const DEFAULT_OLDER_THAN_DAYS = 30;
27
+ const DEFAULT_BATCH_SIZE = 1000;
28
+
29
+ export type SessionCleanupPayload = {
30
+ readonly olderThanDays?: number;
31
+ readonly batchSize?: number;
32
+ readonly maxDurationMs?: number;
33
+ };
34
+
35
+ export type SessionCleanupResult = {
36
+ readonly deleted: number;
37
+ readonly batchesProcessed: number;
38
+ readonly stoppedReason: "empty" | "timeout" | "signal";
39
+ };
40
+
41
+ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
42
+ const payload = rawPayload as SessionCleanupPayload;
43
+ if (!ctx.db) {
44
+ throw new InternalError({
45
+ message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
46
+ });
47
+ }
48
+ const db = ctx.db as DbConnection;
49
+
50
+ // Coerce-and-validate: BullMQ payloads arrive as opaque JSON, so TS types
51
+ // don't survive. Guard before the value is interpolated into SQL.
52
+ const olderThanDaysRaw = payload.olderThanDays ?? DEFAULT_OLDER_THAN_DAYS;
53
+ const olderThanDays = Number(olderThanDaysRaw);
54
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0 || !Number.isInteger(olderThanDays)) {
55
+ throw new InternalError({
56
+ message: `[sessions:cleanup] olderThanDays must be a non-negative integer (got ${String(olderThanDaysRaw)})`,
57
+ });
58
+ }
59
+ const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
60
+ const deadline = payload.maxDurationMs
61
+ ? Date.now() + payload.maxDurationMs
62
+ : Number.POSITIVE_INFINITY;
63
+
64
+ const cutoff = sql`now() - (${olderThanDays} * interval '1 day')`;
65
+
66
+ let deleted = 0;
67
+ let batchesProcessed = 0;
68
+ let stoppedReason: SessionCleanupResult["stoppedReason"] = "empty";
69
+
70
+ while (true) {
71
+ if (ctx.signal?.aborted) {
72
+ stoppedReason = "signal";
73
+ break;
74
+ }
75
+ if (Date.now() >= deadline) {
76
+ stoppedReason = "timeout";
77
+ break;
78
+ }
79
+
80
+ // DELETE-by-id-subquery with an explicit LIMIT so the lock stays short.
81
+ // The WHERE clause is the safety net: we only touch rows that are
82
+ // PAST-CUTOFF (expired OR revoked), never currently-live sessions. A
83
+ // null-check in PG semantics: `x < cutoff` already excludes null.
84
+ const rows = await db
85
+ .delete(userSessionTable)
86
+ .where(
87
+ sql`${userSessionTable["id"]} in (
88
+ select ${userSessionTable["id"]}
89
+ from ${userSessionTable}
90
+ where ${or(
91
+ sql`${userSessionTable["expiresAt"]} < ${cutoff}`,
92
+ sql`${userSessionTable["revokedAt"]} < ${cutoff}`,
93
+ )}
94
+ limit ${batchSize}
95
+ )`,
96
+ )
97
+ .returning({ id: userSessionTable["id"] });
98
+
99
+ if (rows.length === 0) break;
100
+
101
+ deleted += rows.length;
102
+ batchesProcessed++;
103
+
104
+ if (rows.length < batchSize) break;
105
+ }
106
+
107
+ const result: SessionCleanupResult = { deleted, batchesProcessed, stoppedReason };
108
+ ctx.log?.info?.(`[sessions:cleanup] complete: ${JSON.stringify(result)}`);
109
+ };
@@ -0,0 +1,35 @@
1
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { desc } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { userSessionTable } from "../schema/user-session";
5
+
6
+ // Admin view of every session in the active tenant. Tenant admins use this
7
+ // to investigate "who is logged in right now" or revoke a suspicious
8
+ // device. Unlike `session:mine` this does NOT filter by userId — it's the
9
+ // whole tenant. Tenant-scoping comes from ctx.db (TenantDb applies a tenant
10
+ // filter automatically on select from tables with a tenantId column), so
11
+ // cross-tenant bleed is impossible.
12
+ //
13
+ // Includes revoked rows too — distinct column in the response tells the UI
14
+ // which entries are historical vs. live. The default ordering puts the
15
+ // newest first so a security review starts at the recent activity.
16
+ export const listQuery = defineQueryHandler({
17
+ name: "user-session:list",
18
+ schema: z.object({}),
19
+ access: { roles: access.admin },
20
+ handler: async (_query, ctx) => {
21
+ const rows = await ctx.db
22
+ .select({
23
+ id: userSessionTable["id"],
24
+ userId: userSessionTable["userId"],
25
+ createdAt: userSessionTable["createdAt"],
26
+ expiresAt: userSessionTable["expiresAt"],
27
+ revokedAt: userSessionTable["revokedAt"],
28
+ ip: userSessionTable["ip"],
29
+ userAgent: userSessionTable["userAgent"],
30
+ })
31
+ .from(userSessionTable)
32
+ .orderBy(desc(userSessionTable["createdAt"]));
33
+ return rows;
34
+ },
35
+ });
@@ -0,0 +1,37 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, desc, eq, isNull } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { userSessionTable } from "../schema/user-session";
5
+
6
+ // "My live sessions" — the backing data for a devices/sessions UI. Returns
7
+ // ONLY the current user's own, currently-live sessions, ordered by most-
8
+ // recently-used first. Revoked rows are excluded (they survive in DB for
9
+ // audit but the UI shouldn't show them as active).
10
+ //
11
+ // Note the `current` marker: we compare against the caller's `user.sid` so
12
+ // the UI can label the entry the user is looking at ("this device"). A user
13
+ // without a sid (stateless-JWT deployment) will simply see `current: false`
14
+ // on every row — the feature still works, just without the marker.
15
+ export const mineQuery = defineQueryHandler({
16
+ name: "user-session:mine",
17
+ schema: z.object({}),
18
+ access: { openToAll: true },
19
+ handler: async (query, ctx) => {
20
+ const rows = await ctx.db
21
+ .select({
22
+ id: userSessionTable["id"],
23
+ createdAt: userSessionTable["createdAt"],
24
+ expiresAt: userSessionTable["expiresAt"],
25
+ ip: userSessionTable["ip"],
26
+ userAgent: userSessionTable["userAgent"],
27
+ })
28
+ .from(userSessionTable)
29
+ .where(
30
+ and(eq(userSessionTable["userId"], query.user.id), isNull(userSessionTable["revokedAt"])),
31
+ )
32
+ .orderBy(desc(userSessionTable["createdAt"]));
33
+
34
+ const currentSid = query.user.sid;
35
+ return rows.map((r) => ({ ...r, current: currentSid === r["id"] }));
36
+ },
37
+ });
@@ -0,0 +1,42 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { and, eq, isNull, ne } from "drizzle-orm";
4
+ import { Temporal } from "temporal-polyfill";
5
+ import { z } from "zod";
6
+ import { SessionErrors } from "../constants";
7
+ import { userSessionTable } from "../schema/user-session";
8
+
9
+ // "Sign out everywhere else" — keep the caller's current session, kill all
10
+ // other live sessions for this user. Requires `user.sid` so "keep current"
11
+ // is well-defined; without it we refuse loudly rather than silently nuking
12
+ // the caller's own session along with the others.
13
+ export const revokeAllOthersWrite = defineWriteHandler({
14
+ name: "user-session:revoke-all-others",
15
+ schema: z.object({}),
16
+ access: { openToAll: true },
17
+ handler: async (event, ctx) => {
18
+ const keepSid = event.user.sid;
19
+ if (!keepSid) {
20
+ return writeFailure(
21
+ new UnprocessableError(SessionErrors.sessionRequired, {
22
+ i18nKey: "sessions.errors.sessionRequired",
23
+ details: { userId: event.user.id },
24
+ }),
25
+ );
26
+ }
27
+
28
+ const updated = await ctx.db
29
+ .update(userSessionTable)
30
+ .set({ revokedAt: Temporal.Now.instant() })
31
+ .where(
32
+ and(
33
+ eq(userSessionTable["userId"], event.user.id),
34
+ isNull(userSessionTable["revokedAt"]),
35
+ ne(userSessionTable["id"], keepSid),
36
+ ),
37
+ )
38
+ .returning();
39
+
40
+ return { isSuccess: true, data: { count: updated.length } };
41
+ },
42
+ });
@@ -0,0 +1,76 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { and, eq, isNull } from "drizzle-orm";
4
+ import { Temporal } from "temporal-polyfill";
5
+ import { z } from "zod";
6
+ import { SessionErrors } from "../constants";
7
+ import { userSessionTable } from "../schema/user-session";
8
+
9
+ // Revoke a single session by id (= JWT jti). Three distinguishable outcomes:
10
+ //
11
+ // - Success: row existed, belonged to the caller, was live → revokedAt
12
+ // stamped to now().
13
+ // - already_revoked: row existed, belonged to the caller, was ALREADY
14
+ // revoked → distinct error so UIs can show "logged out at <time>"
15
+ // instead of a generic access-denied. Audit's original revokedAt is
16
+ // preserved (isNull-guard on the UPDATE).
17
+ // - ownership_denied: row didn't exist OR belonged to another user. Same
18
+ // response for both branches = no existence oracle for other users' sids.
19
+ //
20
+ // Try the UPDATE first with the full constraint set (id + userId + live);
21
+ // if it touches zero rows, a second SELECT disambiguates the reason. The
22
+ // second roundtrip only happens on the error path — success stays single-
23
+ // roundtrip.
24
+ export const revokeWrite = defineWriteHandler({
25
+ name: "user-session:revoke",
26
+ schema: z.object({
27
+ id: z.uuid(),
28
+ }),
29
+ access: { openToAll: true },
30
+ handler: async (event, ctx) => {
31
+ const updated = await ctx.db
32
+ .update(userSessionTable)
33
+ .set({ revokedAt: Temporal.Now.instant() })
34
+ .where(
35
+ and(
36
+ eq(userSessionTable["id"], event.payload.id),
37
+ eq(userSessionTable["userId"], event.user.id),
38
+ isNull(userSessionTable["revokedAt"]),
39
+ ),
40
+ )
41
+ .returning();
42
+
43
+ if (updated.length > 0) {
44
+ return { isSuccess: true, data: { id: event.payload.id } };
45
+ }
46
+
47
+ // Zero rows touched — disambiguate between "not yours" and "already
48
+ // revoked" via a point-read. Only hits on the error path.
49
+ const [row] = await ctx.db
50
+ .select({ userId: userSessionTable["userId"], revokedAt: userSessionTable["revokedAt"] })
51
+ .from(userSessionTable)
52
+ .where(eq(userSessionTable["id"], event.payload.id))
53
+ .limit(1);
54
+
55
+ if (row && row["userId"] === event.user.id && row["revokedAt"] !== null) {
56
+ return writeFailure(
57
+ new UnprocessableError(SessionErrors.alreadyRevoked, {
58
+ i18nKey: "sessions.errors.alreadyRevoked",
59
+ details: { id: event.payload.id },
60
+ }),
61
+ );
62
+ }
63
+
64
+ return writeFailure(
65
+ new UnprocessableError(SessionErrors.ownershipDenied, {
66
+ i18nKey: "errors.ownershipDenied",
67
+ details: {
68
+ scope: "entity",
69
+ entityName: "user-session",
70
+ action: "revoke",
71
+ userId: event.user.id,
72
+ },
73
+ }),
74
+ );
75
+ },
76
+ });
@@ -0,0 +1,17 @@
1
+ export {
2
+ DEFAULT_SESSION_CACHE_TTL_MS,
3
+ DEFAULT_SESSION_EXPIRY_MS,
4
+ SESSIONS_FEATURE,
5
+ SessionErrors,
6
+ SessionHandlers,
7
+ SessionQueries,
8
+ } from "./constants";
9
+ export type { SessionsFeatureOptions } from "./feature";
10
+ export { createSessionsFeature } from "./feature";
11
+ export { userSessionEntity, userSessionTable } from "./schema/user-session";
12
+ export type {
13
+ SessionCallbacks,
14
+ SessionCallbacksOptions,
15
+ SessionMassRevoker,
16
+ } from "./session-callbacks";
17
+ export { createSessionCallbacks } from "./session-callbacks";
@@ -0,0 +1,5 @@
1
+ // Re-exports aus den schema/<entity>.ts Files. Eine Datei pro Entity
2
+ // — wenn das Feature später weitere Entities hinzubekommt, kommen sie
3
+ // als zusätzliche Datei rein und werden hier exportiert.
4
+
5
+ export * from "./user-session";
@@ -0,0 +1,67 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ access,
4
+ createEntity,
5
+ createTextField,
6
+ createTimestampField,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+
9
+ // userSession — one row per signed-in client (browser tab, mobile app).
10
+ // The PRIMARY key is the sid — generated by the framework at login time and
11
+ // embedded in the JWT's `jti` claim. Using sid-as-PK means the auth middleware
12
+ // can look a session up with a single point-read instead of a (userId, sid)
13
+ // lookup, which matters because the check runs on every authenticated request.
14
+ //
15
+ // Fields that are system-managed (userId, revokedAt, expiresAt, etc.) are
16
+ // write-locked to the privileged role so the feature's handlers can still
17
+ // mutate them via ctx.db inside a transaction, but no user request can bypass
18
+ // the revocation flow by poking the column directly.
19
+ export const userSessionEntity = createEntity({
20
+ // Entity-Key ist "user-session" (mit Dash), toTableName's snake-case-
21
+ // Transform käme auf "read_user-sessions" → kein valides SQL ohne Quoting.
22
+ // Deshalb expliziter Override auf den read_-konformen Namen.
23
+ table: "read_user_sessions",
24
+ // sid-as-PK: the sessionCreator callback generates the UUID, returns it to
25
+ // the framework; the framework stamps it as `jti`; here the same value is
26
+ // the row primary key. Single source of truth for the identifier.
27
+ // No softDelete: revocation is its own lifecycle (revokedAt timestamp), not
28
+ // a delete — we want to keep the audit trail of revoked sessions for the
29
+ // "your devices" UI ("signed out 3 days ago").
30
+ softDelete: false,
31
+ fields: {
32
+ userId: createTextField({
33
+ required: true,
34
+ maxLength: 36,
35
+ access: { write: access.privileged },
36
+ }),
37
+ tenantId: createTextField({
38
+ required: true,
39
+ maxLength: 36,
40
+ access: { write: access.privileged },
41
+ }),
42
+ createdAt: createTimestampField({
43
+ required: true,
44
+ access: { write: access.privileged },
45
+ }),
46
+ expiresAt: createTimestampField({
47
+ required: true,
48
+ access: { write: access.privileged },
49
+ }),
50
+ // Set when the session is revoked (logout, switch-tenant, password-change,
51
+ // admin action). Middleware treats `revokedAt != null` as "blocked" and
52
+ // returns 401; the row sticks around for the audit trail.
53
+ revokedAt: createTimestampField({
54
+ access: { write: access.privileged },
55
+ }),
56
+ ip: createTextField({
57
+ maxLength: 64,
58
+ access: { write: access.privileged },
59
+ }),
60
+ userAgent: createTextField({
61
+ maxLength: 512,
62
+ access: { write: access.privileged },
63
+ }),
64
+ },
65
+ });
66
+
67
+ export const userSessionTable = buildDrizzleTable("user_session", userSessionEntity);
@@ -0,0 +1,110 @@
1
+ import type {
2
+ AuthSessionStatus,
3
+ SessionChecker,
4
+ SessionCreator,
5
+ SessionMetadata,
6
+ SessionRevoker,
7
+ } from "@cosmicdrift/kumiko-framework/api";
8
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
11
+ import { and, eq, isNull } from "drizzle-orm";
12
+ import { Temporal } from "temporal-polyfill";
13
+ import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
14
+ import { userSessionTable } from "./schema/user-session";
15
+
16
+ // Why the callbacks live at the raw-DB level rather than going through the
17
+ // dispatcher: session-create/revoke/check run on the hot path of every
18
+ // login and every request. The (createdAt/revokedAt/ip/userAgent) columns
19
+ // already are the audit trail — a dispatcher roundtrip buys nothing.
20
+
21
+ // Mass-revoke for a single user. Used by the password-change hook and
22
+ // "sign out everywhere" flows. Returns the count of rows flipped so a
23
+ // caller can log "revoked N other sessions".
24
+ export type SessionMassRevoker = (userId: string) => Promise<number>;
25
+
26
+ export type SessionCallbacksOptions = {
27
+ readonly db: DbConnection;
28
+ // Session lifetime. MVP uses a single flat window; per-app policies can
29
+ // come later (e.g. longer for "remember me", shorter for admin).
30
+ readonly expiresInMs?: number;
31
+ };
32
+
33
+ export type SessionCallbacks = {
34
+ sessionCreator: SessionCreator;
35
+ sessionRevoker: SessionRevoker;
36
+ sessionChecker: SessionChecker;
37
+ sessionMassRevoker: SessionMassRevoker;
38
+ };
39
+
40
+ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCallbacks {
41
+ const ttlMs = opts.expiresInMs ?? DEFAULT_SESSION_EXPIRY_MS;
42
+ const { db } = opts;
43
+
44
+ return {
45
+ async sessionCreator(user: SessionUser, meta: SessionMetadata): Promise<string> {
46
+ const sid = generateId();
47
+ const now = Temporal.Now.instant();
48
+ const expiresAt = now.add({ milliseconds: ttlMs });
49
+ await db.insert(userSessionTable).values({
50
+ id: sid,
51
+ tenantId: user.tenantId,
52
+ userId: user.id,
53
+ createdAt: now,
54
+ expiresAt,
55
+ ip: meta.ip,
56
+ userAgent: meta.userAgent,
57
+ });
58
+ return sid;
59
+ },
60
+
61
+ async sessionRevoker(sid: string): Promise<void> {
62
+ // Audit-preserving: `isNull(revokedAt)` in WHERE means a second call
63
+ // on an already-revoked sid is a no-op instead of overwriting the
64
+ // original timestamp. Double-revoke races land here via logout +
65
+ // switch-tenant on the same sid. (Password-change uses a different
66
+ // callback — sessionMassRevoker — and isn't in scope for this guard.)
67
+ await db
68
+ .update(userSessionTable)
69
+ .set({ revokedAt: Temporal.Now.instant() })
70
+ .where(and(eq(userSessionTable.id, sid), isNull(userSessionTable.revokedAt)));
71
+ },
72
+
73
+ async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionStatus> {
74
+ const rows = await db
75
+ .select({
76
+ userId: userSessionTable.userId,
77
+ revokedAt: userSessionTable.revokedAt,
78
+ expiresAt: userSessionTable.expiresAt,
79
+ })
80
+ .from(userSessionTable)
81
+ .where(eq(userSessionTable.id, sid))
82
+ .limit(1);
83
+ const row = rows[0];
84
+ if (!row) return "missing";
85
+ // Cross-user check: if the sid belongs to someone else, treat it
86
+ // identically to "missing" so a compromised sid paired with a valid
87
+ // JWT from a different user gets the same opaque response as a
88
+ // forged sid. No existence oracle on other users' sids.
89
+ if (row.userId !== expectedUserId) return "missing";
90
+ if (row.revokedAt !== null) return "revoked";
91
+ // Temporal-native clock read (Sprint F migration) — keeps the feature
92
+ // free of raw Date.now() for consistency with the rest of the codebase.
93
+ if (row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds) {
94
+ return "expired";
95
+ }
96
+ return "live";
97
+ },
98
+
99
+ async sessionMassRevoker(userId: string): Promise<number> {
100
+ // Count is accurate because we only touch live rows — a previously
101
+ // revoked row stays in its state and isn't double-counted.
102
+ const result = await db
103
+ .update(userSessionTable)
104
+ .set({ revokedAt: Temporal.Now.instant() })
105
+ .where(and(eq(userSessionTable.userId, userId), isNull(userSessionTable.revokedAt)))
106
+ .returning({ id: userSessionTable.id });
107
+ return result.length;
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,42 @@
1
+ // Testing helpers for the sessions feature. The factory below turns a
2
+ // `LateBoundHolder<SessionCallbacks>` into the two shapes a test needs:
3
+ //
4
+ // const holder = createLateBoundHolder<SessionCallbacks>("session-callbacks");
5
+ // const bound = sessionCallbacksFromLateBound(holder);
6
+ //
7
+ // stack = await setupTestStack({
8
+ // features: [..., createSessionsFeature({
9
+ // autoRevokeOnPasswordChange: bound.asMassRevoker(),
10
+ // })],
11
+ // authConfig: { ...bound.asAuthConfig(), membershipQuery, loginHandler },
12
+ // });
13
+ // holder.set(createSessionCallbacks({ db: stack.db }));
14
+ //
15
+ // Why the helper lives in bundled-features/sessions rather than framework/testing:
16
+ // it closes over `AuthRoutesConfig` + `SessionCallbacks`, both of which the
17
+ // sessions feature owns. framework/testing only provides the generic
18
+ // `createLateBoundHolder<T>` — shape-independent.
19
+
20
+ import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
21
+ import type { LateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
22
+ import type { SessionCallbacks, SessionMassRevoker } from "./session-callbacks";
23
+
24
+ export type BoundSessionCallbacks = {
25
+ /** auth-config fragment: creator + revoker + checker, all late-bound. */
26
+ asAuthConfig(): Pick<AuthRoutesConfig, "sessionCreator" | "sessionRevoker" | "sessionChecker">;
27
+ /** mass-revoker function for sessionsFeature({ autoRevokeOnPasswordChange }). */
28
+ asMassRevoker(): SessionMassRevoker;
29
+ };
30
+
31
+ export function sessionCallbacksFromLateBound(
32
+ holder: LateBoundHolder<SessionCallbacks>,
33
+ ): BoundSessionCallbacks {
34
+ return {
35
+ asAuthConfig: () => ({
36
+ sessionCreator: (user, meta) => holder.get().sessionCreator(user, meta),
37
+ sessionRevoker: (sid) => holder.get().sessionRevoker(sid),
38
+ sessionChecker: (sid, userId) => holder.get().sessionChecker(sid, userId),
39
+ }),
40
+ asMassRevoker: () => (userId) => holder.get().sessionMassRevoker(userId),
41
+ };
42
+ }
@@ -0,0 +1,106 @@
1
+ // feature.ts contract tests for subscription-mollie.
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "../constants";
5
+ import { createSubscriptionMollieFeature } from "../feature";
6
+
7
+ const VALID_OPTIONS = {
8
+ apiKey: "test_dummy_apikey",
9
+ webhookUrl: "https://app.example.com/api/subscription/webhook/mollie",
10
+ priceToTier: { plan_pro: "pro" },
11
+ priceToConfig: {
12
+ plan_pro: {
13
+ amountValue: "9.99",
14
+ amountCurrency: "EUR",
15
+ interval: "1 month",
16
+ description: "Pro Plan",
17
+ },
18
+ },
19
+ };
20
+
21
+ describe("createSubscriptionMollieFeature — shape", () => {
22
+ test("has the expected name", () => {
23
+ const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
24
+ expect(feature.name).toBe(SUBSCRIPTION_MOLLIE_FEATURE);
25
+ expect(feature.name).toBe("subscription-mollie");
26
+ });
27
+
28
+ test("requires only subscription-foundation (alles app-wide via factory-options)", () => {
29
+ const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
30
+ expect(feature.requires).toContain("billing-foundation");
31
+ expect(feature.requires).not.toContain("config");
32
+ expect(feature.requires).not.toContain("secrets");
33
+ });
34
+ });
35
+
36
+ describe("createSubscriptionMollieFeature — module-load validation", () => {
37
+ test("throws bei empty apiKey", () => {
38
+ expect(() => createSubscriptionMollieFeature({ ...VALID_OPTIONS, apiKey: "" })).toThrow(
39
+ /apiKey is empty/,
40
+ );
41
+ });
42
+
43
+ test("throws bei empty webhookUrl", () => {
44
+ expect(() => createSubscriptionMollieFeature({ ...VALID_OPTIONS, webhookUrl: "" })).toThrow(
45
+ /webhookUrl is empty/,
46
+ );
47
+ });
48
+
49
+ test("throws bei priceToTier ↔ priceToConfig drift (priceId nur in tier)", () => {
50
+ expect(() =>
51
+ createSubscriptionMollieFeature({
52
+ ...VALID_OPTIONS,
53
+ priceToTier: { plan_pro: "pro", plan_business: "business" },
54
+ // plan_business fehlt in config
55
+ }),
56
+ ).toThrow(/missing config:.*plan_business/);
57
+ });
58
+
59
+ test("throws bei priceToTier ↔ priceToConfig drift (priceId nur in config)", () => {
60
+ expect(() =>
61
+ createSubscriptionMollieFeature({
62
+ ...VALID_OPTIONS,
63
+ priceToConfig: {
64
+ ...VALID_OPTIONS.priceToConfig,
65
+ plan_extra: {
66
+ amountValue: "29.99",
67
+ amountCurrency: "EUR",
68
+ interval: "1 month",
69
+ description: "Extra",
70
+ },
71
+ },
72
+ // plan_extra fehlt in tier
73
+ }),
74
+ ).toThrow(/missing tier:.*plan_extra/);
75
+ });
76
+ });
77
+
78
+ describe("subscription-mollie — plugin-registration", () => {
79
+ test("registers under entityName 'mollie' for subscription-foundation extension", () => {
80
+ const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
81
+ expect(
82
+ feature.extensionUsages.some(
83
+ (u) => u.extensionName === "subscriptionProvider" && u.entityName === MOLLIE_PROVIDER_NAME,
84
+ ),
85
+ ).toBe(true);
86
+ });
87
+
88
+ test("plugin has verifyAndParseWebhook + createCheckoutSession; KEIN portal/cancel (Mollie-Limit)", () => {
89
+ // Drift-Pin: Mollie's Plugin-shape ist intentional schmaler als Stripe.
90
+ // Wenn jemand einen createPortalSession-stub hinzufügt der "not-supported"
91
+ // wirft, würde das die foundation-error-Story ändern. Phase-5.3-MVP
92
+ // lässt die optional-Fields KOMPLETT weg.
93
+ const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
94
+ const usage = feature.extensionUsages.find((u) => u.entityName === MOLLIE_PROVIDER_NAME);
95
+ const plugin = usage?.options as {
96
+ verifyAndParseWebhook?: unknown;
97
+ createCheckoutSession?: unknown;
98
+ createPortalSession?: unknown;
99
+ cancelSubscription?: unknown;
100
+ };
101
+ expect(typeof plugin?.verifyAndParseWebhook).toBe("function");
102
+ expect(typeof plugin?.createCheckoutSession).toBe("function");
103
+ expect(plugin?.createPortalSession).toBeUndefined();
104
+ expect(plugin?.cancelSubscription).toBeUndefined();
105
+ });
106
+ });