@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,472 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ createEntityTable,
6
+ pushTables,
7
+ setupTestStack,
8
+ type TestStack,
9
+ testTenantId,
10
+ } from "@cosmicdrift/kumiko-framework/stack";
11
+ import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
12
+ import { and, eq } from "drizzle-orm";
13
+ import { Temporal } from "temporal-polyfill";
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
15
+ import { AuthHandlers } from "../../auth-email-password/constants";
16
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
17
+ import { createConfigFeature } from "../../config";
18
+ import { createConfigResolver } from "../../config/resolver";
19
+ import { configValuesTable } from "../../config/table";
20
+ import { createTenantFeature } from "../../tenant";
21
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
22
+ import { tenantEntity } from "../../tenant/schema/tenant";
23
+ import { createUserFeature } from "../../user/feature";
24
+ import { userEntity, userTable } from "../../user/schema/user";
25
+ import { SessionHandlers, SessionQueries } from "../constants";
26
+ import { createSessionsFeature } from "../feature";
27
+ import { userSessionEntity, userSessionTable } from "../schema/user-session";
28
+ import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
29
+ import { sessionCallbacksFromLateBound } from "../testing";
30
+ import { makeSessionHelpers } from "./test-helpers";
31
+
32
+ // End-to-end test of the sessions feature. Full loop: login persists a
33
+ // session → JWT carries jti → middleware checks it on every subsequent
34
+ // request → revoke flips the DB row → the previously-valid JWT is rejected
35
+ // on the next call. No mocks — real Drizzle + HTTP.
36
+
37
+ let stack: TestStack;
38
+ let h: ReturnType<typeof makeSessionHelpers>;
39
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
40
+
41
+ const encryptionKey = randomBytes(32).toString("base64");
42
+ const TENANT: TenantId = testTenantId(1);
43
+
44
+ beforeAll(async () => {
45
+ const encryption = createEncryptionProvider(encryptionKey);
46
+ const resolver = createConfigResolver({ encryption });
47
+ const bound = sessionCallbacksFromLateBound(callbacks);
48
+
49
+ stack = await setupTestStack({
50
+ features: [
51
+ createConfigFeature(),
52
+ createUserFeature(),
53
+ createTenantFeature(),
54
+ createAuthEmailPasswordFeature(),
55
+ createSessionsFeature(),
56
+ ],
57
+ extraContext: { configResolver: resolver, configEncryption: encryption },
58
+ authConfig: {
59
+ ...bound.asAuthConfig(),
60
+ membershipQuery: "tenant:query:memberships",
61
+ loginHandler: AuthHandlers.login,
62
+ },
63
+ });
64
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
65
+ h = makeSessionHelpers(stack, TENANT);
66
+
67
+ await createEntityTable(stack.db, userEntity);
68
+ await createEntityTable(stack.db, tenantEntity);
69
+ await createEntityTable(stack.db, userSessionEntity);
70
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
71
+ });
72
+
73
+ afterAll(async () => {
74
+ await stack.cleanup();
75
+ });
76
+
77
+ beforeEach(async () => {
78
+ await stack.db.delete(userTable);
79
+ await stack.db.delete(tenantMembershipsTable);
80
+ await stack.db.delete(userSessionTable);
81
+ });
82
+
83
+ describe("sessions feature — login → check → revoke → rejected", () => {
84
+ test("login persists a userSession row with PK equal to the JWT jti", async () => {
85
+ await h.seedUser("persist@example.com", "pw-long-enough");
86
+ const { sid } = await h.login("persist@example.com", "pw-long-enough");
87
+
88
+ const rows = await stack.db.select().from(userSessionTable);
89
+ expect(rows).toHaveLength(1);
90
+ expect(rows[0]?.["id"]).toBe(sid);
91
+ expect(rows[0]?.["revokedAt"]).toBeNull();
92
+ });
93
+
94
+ test("authenticated request passes while session is live, 401s once revoked", async () => {
95
+ await h.seedUser("round@example.com", "pw-long-enough");
96
+ const { token, sid } = await h.login("round@example.com", "pw-long-enough");
97
+
98
+ // Before revoke: the me-query comes back with data
99
+ const before = await h.authedPost("/api/query", token, {
100
+ type: "user:query:user:me",
101
+ payload: {},
102
+ });
103
+ expect(before.status).toBe(200);
104
+
105
+ // Revoke this session via the feature handler (user revokes their own)
106
+ const revokeRes = await h.authedPost("/api/write", token, {
107
+ type: SessionHandlers.revoke,
108
+ payload: { id: sid },
109
+ });
110
+ expect(revokeRes.status).toBe(200);
111
+
112
+ // After revoke: the SAME JWT is rejected by middleware
113
+ const after = await h.authedPost("/api/query", token, {
114
+ type: "user:query:user:me",
115
+ payload: {},
116
+ });
117
+ expect(after.status).toBe(401);
118
+ const afterBody = (await after.json()) as { error?: { details?: { reason?: string } } };
119
+ expect(afterBody.error?.details?.reason).toBe("revoked");
120
+ });
121
+
122
+ test("POST /auth/logout flips the DB row and invalidates the JWT", async () => {
123
+ await h.seedUser("logout@example.com", "pw-long-enough");
124
+ const { token, sid } = await h.login("logout@example.com", "pw-long-enough");
125
+
126
+ const logoutRes = await h.authedPost("/api/auth/logout", token);
127
+ expect(logoutRes.status).toBe(200);
128
+
129
+ const rows = await stack.db.select().from(userSessionTable);
130
+ expect(rows[0]?.["id"]).toBe(sid);
131
+ expect(rows[0]?.["revokedAt"]).not.toBeNull();
132
+
133
+ const next = await h.authedPost("/api/query", token, {
134
+ type: "user:query:user:me",
135
+ payload: {},
136
+ });
137
+ expect(next.status).toBe(401);
138
+ });
139
+
140
+ // A user-initiated revoke against their own already-revoked sid gets a
141
+ // distinct already_revoked response so UIs can show "logged out at <time>"
142
+ // instead of the generic ownership_denied. Also asserts that the original
143
+ // revokedAt timestamp is preserved — the isNull-guard on the handler's
144
+ // UPDATE keeps the audit trail intact.
145
+ test("revoking an already-revoked sid → already_revoked + preserves original revokedAt", async () => {
146
+ await h.seedUser("double@example.com", "pw-long-enough");
147
+ const first = await h.login("double@example.com", "pw-long-enough");
148
+
149
+ // First revoke succeeds — stamps revokedAt = t1.
150
+ const firstRevoke = await h.authedPost("/api/write", first.token, {
151
+ type: SessionHandlers.revoke,
152
+ payload: { id: first.sid },
153
+ });
154
+ expect(firstRevoke.status).toBe(200);
155
+
156
+ const [rowAfterFirst] = await stack.db
157
+ .select()
158
+ .from(userSessionTable)
159
+ .where(eq(userSessionTable["id"], first.sid));
160
+ const originalRevokedAt = rowAfterFirst?.["revokedAt"] as Temporal.Instant | null;
161
+ expect(originalRevokedAt).not.toBeNull();
162
+
163
+ // Fresh login for the same user — new sid, new token. Hit the handler
164
+ // via the PRODUCTION auth path (no bypass-JWT hackery) and try to
165
+ // revoke the OLD, already-revoked sid.
166
+ const second = await h.login("double@example.com", "pw-long-enough");
167
+ const retry = await h.authedPost("/api/write", second.token, {
168
+ type: SessionHandlers.revoke,
169
+ payload: { id: first.sid },
170
+ });
171
+ expect(retry.status).toBe(422);
172
+ const body = (await retry.json()) as { error?: { details?: { reason?: string } } };
173
+ expect(body.error?.details?.reason).toBe("session_already_revoked");
174
+
175
+ // Audit: the retry must NOT have touched the row. Same timestamp as t1.
176
+ const [rowAfterRetry] = await stack.db
177
+ .select()
178
+ .from(userSessionTable)
179
+ .where(eq(userSessionTable["id"], first.sid));
180
+ const preservedRevokedAt = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
181
+ expect(preservedRevokedAt?.epochMilliseconds).toBe(originalRevokedAt?.epochMilliseconds);
182
+ });
183
+
184
+ test("session:mine only returns live sessions, marks the current one", async () => {
185
+ await h.seedUser("mine@example.com", "pw-long-enough");
186
+ // Three logins = three sessions (think: browser, mobile app, tablet)
187
+ const a = await h.login("mine@example.com", "pw-long-enough");
188
+ const _b = await h.login("mine@example.com", "pw-long-enough");
189
+ const c = await h.login("mine@example.com", "pw-long-enough");
190
+
191
+ // Revoke the middle one so we can assert the list hides revoked rows
192
+ await stack.http.raw(
193
+ "POST",
194
+ "/api/write",
195
+ { type: SessionHandlers.revoke, payload: { id: _b.sid } },
196
+ { Authorization: `Bearer ${a.token}` },
197
+ );
198
+
199
+ const listRes = await h.authedPost("/api/query", c.token, {
200
+ type: SessionQueries.mine,
201
+ payload: {},
202
+ });
203
+ expect(listRes.status).toBe(200);
204
+ const body = (await listRes.json()) as {
205
+ data: Array<{ id: string; current: boolean }>;
206
+ };
207
+ const ids = body.data.map((r) => r.id);
208
+ // Order: most-recently-created first. c was the last login, so it
209
+ // should lead; a (the first login) trails. Pinning the order stops a
210
+ // silent orderBy removal from slipping through.
211
+ expect(ids).toEqual([c.sid, a.sid]);
212
+ expect(ids).not.toContain(_b.sid);
213
+
214
+ // The caller's OWN sid is flagged as current
215
+ const currentRow = body.data.find((r) => r.current);
216
+ expect(currentRow?.id).toBe(c.sid);
217
+ });
218
+
219
+ test("session:revoke-all-others keeps the caller's session alive", async () => {
220
+ await h.seedUser("nuke@example.com", "pw-long-enough");
221
+ const a = await h.login("nuke@example.com", "pw-long-enough");
222
+ const b = await h.login("nuke@example.com", "pw-long-enough");
223
+ const c = await h.login("nuke@example.com", "pw-long-enough");
224
+
225
+ // Caller is session b; revoke everything else
226
+ const res = await h.authedPost("/api/write", b.token, {
227
+ type: SessionHandlers.revokeAllOthers,
228
+ payload: {},
229
+ });
230
+ expect(res.status).toBe(200);
231
+ const body = (await res.json()) as { data: { count: number } };
232
+ expect(body.data.count).toBe(2); // a + c
233
+
234
+ // b's JWT still works
235
+ const still = await h.authedPost("/api/query", b.token, {
236
+ type: "user:query:user:me",
237
+ payload: {},
238
+ });
239
+ expect(still.status).toBe(200);
240
+
241
+ // a and c are out
242
+ const outA = await h.authedPost("/api/query", a.token, {
243
+ type: "user:query:user:me",
244
+ payload: {},
245
+ });
246
+ expect(outA.status).toBe(401);
247
+ const outC = await h.authedPost("/api/query", c.token, {
248
+ type: "user:query:user:me",
249
+ payload: {},
250
+ });
251
+ expect(outC.status).toBe(401);
252
+ });
253
+
254
+ test("a user cannot revoke another user's session — ownership_denied", async () => {
255
+ await h.seedUser("alice@example.com", "pw-long-enough");
256
+ await h.seedUser("mallory@example.com", "pw-long-enough");
257
+
258
+ const alice = await h.login("alice@example.com", "pw-long-enough");
259
+ const mallory = await h.login("mallory@example.com", "pw-long-enough");
260
+
261
+ // Mallory tries to revoke Alice's sid — fail-loud per H.2 convention
262
+ const res = await h.authedPost("/api/write", mallory.token, {
263
+ type: SessionHandlers.revoke,
264
+ payload: { id: alice.sid },
265
+ });
266
+ expect(res.status).toBe(422);
267
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
268
+ expect(body.error?.details?.reason).toBe("ownership_denied");
269
+
270
+ // Alice's session is still live
271
+ const aliceStillIn = await h.authedPost("/api/query", alice.token, {
272
+ type: "user:query:user:me",
273
+ payload: {},
274
+ });
275
+ expect(aliceStillIn.status).toBe(200);
276
+ });
277
+
278
+ test("revoking an unknown sid returns the SAME ownership_denied — no existence oracle", async () => {
279
+ await h.seedUser("eve@example.com", "pw-long-enough");
280
+ const eve = await h.login("eve@example.com", "pw-long-enough");
281
+
282
+ // Well-formed UUID that doesn't exist in user_sessions
283
+ const res = await h.authedPost("/api/write", eve.token, {
284
+ type: SessionHandlers.revoke,
285
+ payload: { id: "00000000-0000-4000-8000-0000deadbeef" },
286
+ });
287
+ expect(res.status).toBe(422);
288
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
289
+ expect(body.error?.details?.reason).toBe("ownership_denied");
290
+ });
291
+
292
+ test("revoke-all-others on a sidless JWT refuses loudly (session_required)", async () => {
293
+ const { userId } = await h.seedUser("sidless@example.com", "pw-long-enough");
294
+
295
+ // Hand-sign a JWT without jti — simulates a stateless-JWT deployment
296
+ // or a rolling-deploy gap. "sign out everywhere else" is ill-defined
297
+ // without knowing which session is "current", so refuse.
298
+ const tokenNoSid = await stack.jwt.sign({ id: userId, tenantId: TENANT, roles: ["User"] });
299
+
300
+ const res = await stack.http.raw(
301
+ "POST",
302
+ "/api/write",
303
+ { type: SessionHandlers.revokeAllOthers, payload: {} },
304
+ { Authorization: `Bearer ${tokenNoSid}` },
305
+ );
306
+ expect(res.status).toBe(422);
307
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
308
+ expect(body.error?.details?.reason).toBe("session_required");
309
+ });
310
+
311
+ test("N concurrent revoke+logout requests on the same sid converge safely", async () => {
312
+ // Fire many pairs of revoke + logout against one sid and verify:
313
+ // (a) no call 5xxs,
314
+ // (b) the revocation timestamp is stable (exactly one racer's write
315
+ // wins, later racers no-op thanks to the isNull-guard),
316
+ // (c) the token is dead afterwards.
317
+ // The expected 4xx mix: middleware 401 for requests whose JWT-check
318
+ // sees the already-revoked sid, or handler 422 `already_revoked` for
319
+ // revoke calls that make it past the middleware but find the row
320
+ // already flipped. Either is correct behaviour.
321
+ await h.seedUser("race@example.com", "pw-long-enough");
322
+ const { token, sid } = await h.login("race@example.com", "pw-long-enough");
323
+
324
+ const RACES = 8;
325
+ const pairs = Array.from({ length: RACES }, () => [
326
+ h.authedPost("/api/write", token, {
327
+ type: SessionHandlers.revoke,
328
+ payload: { id: sid },
329
+ }),
330
+ h.authedPost("/api/auth/logout", token),
331
+ ]).flat();
332
+ const results = await Promise.all(pairs);
333
+
334
+ for (const res of results) {
335
+ expect(res.status).toBeLessThan(500);
336
+ }
337
+
338
+ // Capture revokedAt once straight after the race, then after a short
339
+ // delay + another revoke attempt via a fresh login (production path —
340
+ // not a bypass hack). If the audit-guard were missing, the second
341
+ // readout would move forward because one of the late racers would
342
+ // have overwritten t1.
343
+ const [row] = await stack.db
344
+ .select()
345
+ .from(userSessionTable)
346
+ .where(eq(userSessionTable["id"], sid));
347
+ const tAfterRace = row?.["revokedAt"] as Temporal.Instant | null;
348
+ expect(tAfterRace).not.toBeNull();
349
+
350
+ // Fresh login → new sid → try to revoke the old sid once more. Handler
351
+ // will 422 already_revoked; the DB row must not move.
352
+ const fresh = await h.login("race@example.com", "pw-long-enough");
353
+ const retry = await h.authedPost("/api/write", fresh.token, {
354
+ type: SessionHandlers.revoke,
355
+ payload: { id: sid },
356
+ });
357
+ expect(retry.status).toBe(422);
358
+
359
+ const [rowAfterRetry] = await stack.db
360
+ .select()
361
+ .from(userSessionTable)
362
+ .where(eq(userSessionTable["id"], sid));
363
+ const tAfterRetry = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
364
+ expect(tAfterRetry?.epochMilliseconds).toBe(tAfterRace?.epochMilliseconds);
365
+
366
+ // Sanity: the original JWT is definitively dead now, no matter which
367
+ // racer won.
368
+ const after = await h.authedPost("/api/query", token, {
369
+ type: "user:query:user:me",
370
+ payload: {},
371
+ });
372
+ expect(after.status).toBe(401);
373
+ });
374
+
375
+ // Middleware must reject "session row is gone" (attacker forged a sid, or
376
+ // the cleanup job deleted it) with a distinct reason so logs can tell the
377
+ // two branches apart from "revoked".
378
+ test("missing sid row → 401 with reason=missing", async () => {
379
+ await h.seedUser("ghost@example.com", "pw-long-enough");
380
+ const { token, sid } = await h.login("ghost@example.com", "pw-long-enough");
381
+
382
+ // Hard-delete the session row so it's gone from the store (as opposed to
383
+ // soft-revoked). The JWT stays syntactically valid.
384
+ await stack.db.delete(userSessionTable).where(eq(userSessionTable["id"], sid));
385
+
386
+ const res = await h.authedPost("/api/query", token, {
387
+ type: "user:query:user:me",
388
+ payload: {},
389
+ });
390
+ expect(res.status).toBe(401);
391
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
392
+ expect(body.error?.details?.reason).toBe("missing");
393
+ });
394
+
395
+ test("expired session row → 401 with reason=expired", async () => {
396
+ await h.seedUser("stale@example.com", "pw-long-enough");
397
+ const { token, sid } = await h.login("stale@example.com", "pw-long-enough");
398
+
399
+ // Back-date expiresAt so the row is still present + not revoked, just
400
+ // past its window. Simulates what a long-lived JWT would hit.
401
+ await stack.db
402
+ .update(userSessionTable)
403
+ .set({ expiresAt: Temporal.Instant.from("2020-01-01T00:00:00Z") })
404
+ .where(eq(userSessionTable["id"], sid));
405
+
406
+ const res = await h.authedPost("/api/query", token, {
407
+ type: "user:query:user:me",
408
+ payload: {},
409
+ });
410
+ expect(res.status).toBe(401);
411
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
412
+ expect(body.error?.details?.reason).toBe("expired");
413
+ });
414
+
415
+ // Admin list — all sessions in the caller's tenant (including revoked).
416
+ // Regular Users get a 403; only admins may see other people's sessions.
417
+ // The Admin promotion goes through the production path (membership-row
418
+ // update + fresh login), so the test documents the real flow rather than
419
+ // a JWT shortcut.
420
+ test("session:list returns every session in the tenant for admins, 403 for users", async () => {
421
+ const { userId: aliceId } = await h.seedUser("alice2@example.com", "pw-long-enough");
422
+ await h.seedUser("bob2@example.com", "pw-long-enough");
423
+ const alice = await h.login("alice2@example.com", "pw-long-enough");
424
+ await h.login("bob2@example.com", "pw-long-enough");
425
+
426
+ // Alice is a plain User → access layer blocks the list query
427
+ const asUser = await h.authedPost("/api/query", alice.token, {
428
+ type: SessionQueries.list,
429
+ payload: {},
430
+ });
431
+ expect(asUser.status).toBe(403);
432
+
433
+ // Promote Alice to Admin via the tenant-memberships row, then re-login
434
+ // so she gets a fresh JWT with the new role in its claims. This is the
435
+ // actual production path — roles are tenant-membership data, not JWT
436
+ // metadata we can fiddle with directly.
437
+ await stack.db
438
+ .update(tenantMembershipsTable)
439
+ .set({ roles: JSON.stringify(["Admin"]) })
440
+ .where(
441
+ and(
442
+ eq(tenantMembershipsTable.userId, aliceId),
443
+ eq(tenantMembershipsTable.tenantId, TENANT),
444
+ ),
445
+ );
446
+ const aliceAsAdmin = await h.login("alice2@example.com", "pw-long-enough");
447
+
448
+ const asAdmin = await h.authedPost("/api/query", aliceAsAdmin.token, {
449
+ type: SessionQueries.list,
450
+ payload: {},
451
+ });
452
+ expect(asAdmin.status).toBe(200);
453
+ const body = (await asAdmin.json()) as {
454
+ data: Array<{
455
+ id: string;
456
+ userId: string;
457
+ createdAt: string;
458
+ revokedAt: string | null;
459
+ }>;
460
+ };
461
+ // Three rows total: Alice's pre-promotion session, Alice's post-promotion
462
+ // session, Bob's session. Two distinct users.
463
+ expect(body.data).toHaveLength(3);
464
+ const userIds = new Set(body.data.map((r) => r.userId));
465
+ expect(userIds.size).toBe(2);
466
+
467
+ // Order: most-recently-created first. aliceAsAdmin's session was the
468
+ // last login; aliceAsAdmin.sid leads the list. Pinning guards against
469
+ // silent orderBy removal.
470
+ expect(body.data[0]?.id).toBe(aliceAsAdmin.sid);
471
+ });
472
+ });
@@ -0,0 +1,66 @@
1
+ // Shared fixtures for session-related integration tests. Centralises the
2
+ // seed/login/request helpers while keeping per-suite state (stack, tenantId)
3
+ // explicit in the call site.
4
+ //
5
+ // Usage:
6
+ // const h = makeSessionHelpers(stack, TENANT);
7
+ // await h.seedUser("x@example.com", "pw");
8
+ // const { token, sid } = await h.login("x@example.com", "pw");
9
+ // const res = await h.authedPost("/api/query", token, { type, payload });
10
+
11
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
13
+ import * as jose from "jose";
14
+ import { expect } from "vitest";
15
+ import { hashPassword } from "../../auth-email-password/password-hashing";
16
+ import { seedTenantMembership } from "../../tenant/seeding";
17
+ import { UserHandlers } from "../../user";
18
+
19
+ export type LoginResult = {
20
+ readonly token: string;
21
+ readonly sid: string;
22
+ };
23
+
24
+ // Return type is inferred from the factory so callers just use
25
+ // `ReturnType<typeof makeSessionHelpers>` — no separate type export to
26
+ // keep in sync with the implementation. Params are typed inline on each
27
+ // method so the inference is sharp.
28
+ export function makeSessionHelpers(stack: TestStack, tenantId: TenantId) {
29
+ return {
30
+ async seedUser(
31
+ email: string,
32
+ password: string,
33
+ opts?: { roles?: readonly string[] },
34
+ ): Promise<{ userId: string }> {
35
+ const hash = await hashPassword(password);
36
+ const created = await stack.http.writeOk<{ id: string }>(
37
+ UserHandlers.create,
38
+ { email, passwordHash: hash, displayName: email.split("@")[0] ?? "u" },
39
+ TestUsers.systemAdmin,
40
+ );
41
+ await seedTenantMembership(stack.db, {
42
+ userId: created.id,
43
+ tenantId,
44
+ roles: opts?.roles ?? ["User"],
45
+ });
46
+ return { userId: created.id };
47
+ },
48
+
49
+ async login(email: string, password: string): Promise<LoginResult> {
50
+ const res = await stack.http.raw("POST", "/api/auth/login", { email, password });
51
+ expect(res.status).toBe(200);
52
+ const body = (await res.json()) as { token: string };
53
+ const payload = jose.decodeJwt(body.token);
54
+ const sid = payload.jti;
55
+ if (typeof sid !== "string") {
56
+ throw new Error("login did not emit a sid — is sessions wired?");
57
+ }
58
+ return { token: body.token, sid };
59
+ },
60
+
61
+ /** POST with `Authorization: Bearer ${token}`. Body is JSON-serialised. */
62
+ authedPost(path: string, token: string, body?: unknown): Promise<Response> {
63
+ return stack.http.raw("POST", path, body, { Authorization: `Bearer ${token}` });
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,43 @@
1
+ export const SESSIONS_FEATURE = "sessions" as const;
2
+
3
+ // Qualified write handler names — entity prefix is "userSession", snake_case
4
+ // "user_session" on the wire.
5
+ export const SessionHandlers = {
6
+ revoke: "sessions:write:user-session:revoke",
7
+ revokeAllOthers: "sessions:write:user-session:revoke-all-others",
8
+ } as const;
9
+
10
+ export const SessionQueries = {
11
+ // User-scoped: "my live sessions" (other devices/browsers)
12
+ mine: "sessions:query:user-session:mine",
13
+ // Admin-scoped: all sessions in the caller's tenant (live + revoked).
14
+ // Tenant isolation comes from ctx.db; access-gate is admin-or-higher.
15
+ list: "sessions:query:user-session:list",
16
+ } as const;
17
+
18
+ export const SessionErrors = {
19
+ // Returned by session:revoke when the sid is already revoked. Distinct
20
+ // from ownership_denied so the UI can say "this session was already
21
+ // logged out at <time>" instead of a generic access error. Revealing an
22
+ // already-revoked sid's state to its owner is not a leak (they already
23
+ // know it was their session).
24
+ alreadyRevoked: "session_already_revoked",
25
+ // "sign out everywhere else" called without a current session on the JWT.
26
+ // Stateless-JWT deployments can't express "everywhere else", so we refuse
27
+ // rather than interpret the request as "nuke everything including me".
28
+ sessionRequired: "session_required",
29
+ // Handler reuses the framework-wide ownership_denied reason (kept here as
30
+ // a constant so the handler's constructor-arg and the test's assertion
31
+ // read from the same source).
32
+ ownershipDenied: "ownership_denied",
33
+ } as const;
34
+
35
+ // Default cache TTL in milliseconds. 60s keeps DB load down while still
36
+ // surfacing revocations within a minute. Tests override to 0 for determinism.
37
+ export const DEFAULT_SESSION_CACHE_TTL_MS = 60_000;
38
+
39
+ // Default session lifetime — 30 days. Mirrors typical "remember me" windows
40
+ // and matches the JWT 24h refresh story (JWT expires sooner, session lives
41
+ // longer so refresh can rotate the token without requiring a new password).
42
+ // MVP ships a single window; per-app overrides can come later.
43
+ export const DEFAULT_SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
@@ -0,0 +1,84 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { cleanupJob } from "./handlers/cleanup.job";
3
+ import { listQuery } from "./handlers/list.query";
4
+ import { mineQuery } from "./handlers/mine.query";
5
+ import { revokeWrite } from "./handlers/revoke.write";
6
+ import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
7
+ import { userSessionEntity } from "./schema/user-session";
8
+ import type { SessionMassRevoker } from "./session-callbacks";
9
+
10
+ export type SessionsFeatureOptions = {
11
+ // When wired, a successful update on the `user` entity that changes the
12
+ // `passwordHash` column triggers a mass-revoke of every live session for
13
+ // that user. Industry-standard "password-change signs you out everywhere"
14
+ // flow, including the session that did the change itself — the client has
15
+ // to re-login after a password change.
16
+ //
17
+ // Runs as an afterCommit postSave hook: the password-change commits first,
18
+ // then the sessions are revoked. Best-effort — if the mass-revoker throws,
19
+ // the password change is NOT rolled back (a password change with a stale
20
+ // session still wins over a user-visible error on the change itself).
21
+ readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
22
+ };
23
+
24
+ // The sessions feature registers the userSession entity and the three user-
25
+ // facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
26
+ // export a sessionCreator/sessionRevoker here — those are produced by
27
+ // `createSessionCallbacks()` at app-setup time and wired into
28
+ // `buildServer({ auth: { ... } })`.
29
+ //
30
+ // Why the split: handlers participate in the dispatcher pipeline (access
31
+ // checks, audit, hooks). The creator/revoker callbacks run on the hot
32
+ // login/request path and do direct-DB writes — threading them through the
33
+ // dispatcher would buy latency without added safety (the row columns ARE
34
+ // the audit trail).
35
+ //
36
+ // Not system-scoped: sessions live per tenant, and the handlers should only
37
+ // see rows in the caller's active tenant.
38
+ export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
39
+ return defineFeature("sessions", (r) => {
40
+ r.entity("user-session", userSessionEntity);
41
+
42
+ const handlers = {
43
+ revoke: r.writeHandler(revokeWrite),
44
+ revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
45
+ };
46
+
47
+ const queries = {
48
+ mine: r.queryHandler(mineQuery),
49
+ list: r.queryHandler(listQuery),
50
+ };
51
+
52
+ // Retention: chunked DELETE of expired/revoked rows. Manual trigger
53
+ // only so dev environments don't churn. Ops wires a cron in the app's
54
+ // dispatcher config when running a long-lived deployment.
55
+ r.job("cleanup", { trigger: { manual: true } }, cleanupJob);
56
+
57
+ // Cross-feature entity hook on "user". `r.entityHook` (NOT `r.hook`) is
58
+ // the supported cross-feature path: entity-keyed, not prefixed by the
59
+ // registering feature. Fires after every successful write on any
60
+ // user-entity handler; we only act when passwordHash is part of the
61
+ // changes-delta the handler was given.
62
+ //
63
+ // Checking `changes["passwordHash"] !== undefined` is cheaper and more
64
+ // correct than diffing data vs previous — "undefined in changes" means
65
+ // "the handler didn't touch this column", which is exactly the signal
66
+ // we want to skip on. Works for both direct user:update calls and any
67
+ // other handler that happens to write the column.
68
+ const autoRevoke = options?.autoRevokeOnPasswordChange;
69
+ if (autoRevoke) {
70
+ r.entityHook("postSave", "user", async (ctx) => {
71
+ // skip: brand-new user, no sessions can possibly exist yet. The
72
+ // initial passwordHash on a user:create would trip the second guard
73
+ // otherwise — every registration would do a mass-revoke roundtrip
74
+ // for a user who literally has no rows in user_sessions.
75
+ if (ctx.isNew) return;
76
+ // skip: handler didn't touch passwordHash, nothing to revoke
77
+ if (ctx.changes["passwordHash"] === undefined) return;
78
+ await autoRevoke(String(ctx.id));
79
+ });
80
+ }
81
+
82
+ return { handlers, queries };
83
+ });
84
+ }