@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,512 @@
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 { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
5
+ import {
6
+ createEntityTable,
7
+ pushTables,
8
+ setupTestStack,
9
+ type TestStack,
10
+ TestUsers,
11
+ testTenantId,
12
+ } from "@cosmicdrift/kumiko-framework/stack";
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
14
+ import { createConfigFeature } from "../../config";
15
+ import { createConfigResolver } from "../../config/resolver";
16
+ import { configValuesTable } from "../../config/table";
17
+ import { createTenantFeature } from "../../tenant";
18
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
19
+ import { tenantEntity } from "../../tenant/schema/tenant";
20
+ import { seedTenantMembership } from "../../tenant/testing";
21
+ import { UserHandlers } from "../../user";
22
+ import { createUserFeature } from "../../user/feature";
23
+ import { userEntity, userTable } from "../../user/schema/user";
24
+ import { AuthErrors, AuthHandlers } from "../constants";
25
+ import { createAuthEmailPasswordFeature } from "../feature";
26
+ import { hashPassword } from "../password-hashing";
27
+
28
+ // Sample-style extension feature that shows the real-world shape of a claims
29
+ // hook: look something up in a tenant-scoped table and stuff it into the JWT.
30
+ // Keeping the hook inline (no separate file) so the test stays self-contained
31
+ // and a reader can see in one glance what's being asserted.
32
+ function makeProfileFeature(data: {
33
+ getSegment: (userId: string, tenantId: TenantId) => string | undefined;
34
+ }) {
35
+ return defineFeature("profile", (r) => {
36
+ // A hook returning a feature-scoped claim — `segment` lands under
37
+ // `user.claims["profile:segment"]` after login thanks to the auto-prefix.
38
+ r.authClaims(async (user) => {
39
+ const seg = data.getSegment(user.id, user.tenantId);
40
+ return seg ? { segment: seg } : {};
41
+ });
42
+ });
43
+ }
44
+
45
+ // Second feature — proves two independent hooks can coexist with no collisions
46
+ // because the prefix is the feature name.
47
+ function makeBillingFeature(plans: Map<TenantId, string>) {
48
+ return defineFeature("billing", (r) => {
49
+ r.authClaims(async (user) => {
50
+ const plan = plans.get(user.tenantId);
51
+ return plan ? { plan } : {};
52
+ });
53
+ });
54
+ }
55
+
56
+ let stack: TestStack;
57
+ const systemAdmin = TestUsers.systemAdmin;
58
+ const encryptionKey = randomBytes(32).toString("base64");
59
+ const tenantA = testTenantId(1);
60
+ const tenantB = testTenantId(2);
61
+
62
+ // Segment data keyed by (userId, tenantId) so different tenants return
63
+ // different claim values for the SAME user — this is what we assert in the
64
+ // switch-tenant test.
65
+ const segmentsByUserAndTenant = new Map<string, string>();
66
+ const plansByTenant = new Map<TenantId, string>();
67
+
68
+ beforeAll(async () => {
69
+ const encryption = createEncryptionProvider(encryptionKey);
70
+ const resolver = createConfigResolver({ encryption });
71
+
72
+ stack = await setupTestStack({
73
+ features: [
74
+ createConfigFeature(),
75
+ createUserFeature(),
76
+ createTenantFeature(),
77
+ createAuthEmailPasswordFeature(),
78
+ makeProfileFeature({
79
+ getSegment: (userId, tenantId) => segmentsByUserAndTenant.get(`${userId}|${tenantId}`),
80
+ }),
81
+ makeBillingFeature(plansByTenant),
82
+ ],
83
+ extraContext: { configResolver: resolver, configEncryption: encryption },
84
+ authConfig: {
85
+ membershipQuery: "tenant:query:memberships",
86
+ loginHandler: AuthHandlers.login,
87
+ loginErrorStatusMap: {
88
+ [AuthErrors.invalidCredentials]: 401,
89
+ [AuthErrors.noMembership]: 403,
90
+ },
91
+ },
92
+ });
93
+
94
+ await createEntityTable(stack.db, userEntity);
95
+ await createEntityTable(stack.db, tenantEntity);
96
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
97
+ });
98
+
99
+ afterAll(async () => {
100
+ await stack.cleanup();
101
+ });
102
+
103
+ beforeEach(async () => {
104
+ await stack.db.delete(userTable);
105
+ await stack.db.delete(tenantMembershipsTable);
106
+ segmentsByUserAndTenant.clear();
107
+ plansByTenant.clear();
108
+ });
109
+
110
+ async function seedUser(email: string, password: string): Promise<string> {
111
+ const hash = await hashPassword(password);
112
+ const created = await stack.http.writeOk<{ id: string }>(
113
+ UserHandlers.create,
114
+ { email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
115
+ systemAdmin,
116
+ );
117
+ return created.id;
118
+ }
119
+
120
+ async function addMembership(userId: string, tenantId: TenantId, roles: string[]): Promise<void> {
121
+ await stack.db.insert(tenantMembershipsTable).values({
122
+ userId,
123
+ tenantId,
124
+ roles: JSON.stringify(roles),
125
+ });
126
+ }
127
+
128
+ describe("scenario 1: login populates claims via r.authClaims hooks", () => {
129
+ test("two features each contribute their prefixed claim into the JWT", async () => {
130
+ const userId = await seedUser("joe@example.com", "pw-long-enough");
131
+ await addMembership(userId, tenantA, ["User"]);
132
+
133
+ // Feature data set-up: profile knows this user's segment in tenantA;
134
+ // billing knows this tenant's plan.
135
+ segmentsByUserAndTenant.set(`${userId}|${tenantA}`, "premium");
136
+ plansByTenant.set(tenantA, "pro");
137
+
138
+ const res = await stack.http.raw("POST", "/api/auth/login", {
139
+ email: "joe@example.com",
140
+ password: "pw-long-enough",
141
+ });
142
+ expect(res.status).toBe(200);
143
+ const body = await res.json();
144
+ expect(body.isSuccess).toBe(true);
145
+
146
+ // Verify the JWT actually carries the claims — decode via the test stack's jwt helper.
147
+ const payload = await stack.jwt.verify(body.token);
148
+ expect(payload.claims).toEqual({
149
+ "profile:segment": "premium",
150
+ "billing:plan": "pro",
151
+ });
152
+ });
153
+
154
+ test("no feature returns data → token has no claims field (not an empty object)", async () => {
155
+ const userId = await seedUser("nomatch@example.com", "pw-long-enough");
156
+ await addMembership(userId, tenantA, ["User"]);
157
+ // Both feature data maps are empty (cleared in beforeEach) so each hook
158
+ // returns {} — and the merged record is also {}.
159
+
160
+ const res = await stack.http.raw("POST", "/api/auth/login", {
161
+ email: "nomatch@example.com",
162
+ password: "pw-long-enough",
163
+ });
164
+ expect(res.status).toBe(200);
165
+ const body = await res.json();
166
+ const payload = await stack.jwt.verify(body.token);
167
+ // Absent is explicit — the JWT layer only adds `claims` when there's at
168
+ // least one key, so the client never sees `claims: {}` dead weight.
169
+ expect(payload.claims).toBeUndefined();
170
+ });
171
+ });
172
+
173
+ describe("scenario 2: switch-tenant recomputes claims (no stale tenant-A claims in tenant-B token)", () => {
174
+ test("switching to tenant B wipes tenant-A claims and reads tenant-B data", async () => {
175
+ const userId = await seedUser("multi@example.com", "pw-long-enough");
176
+ await addMembership(userId, tenantA, ["User"]);
177
+ await addMembership(userId, tenantB, ["Admin"]);
178
+
179
+ // Tenant-scoped state: user has a "premium" segment in A, "starter" in B.
180
+ // Billing has "pro" plan in A, "free" in B. If switch-tenant merely
181
+ // re-signed the old claims instead of recomputing, we'd see A's claims
182
+ // leak into B's token — a real identity-leak bug.
183
+ segmentsByUserAndTenant.set(`${userId}|${tenantA}`, "premium");
184
+ segmentsByUserAndTenant.set(`${userId}|${tenantB}`, "starter");
185
+ plansByTenant.set(tenantA, "pro");
186
+ plansByTenant.set(tenantB, "free");
187
+
188
+ // Login lands in tenantA (first membership by default).
189
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
190
+ email: "multi@example.com",
191
+ password: "pw-long-enough",
192
+ });
193
+ const loginBody = await loginRes.json();
194
+ const tokenA = loginBody.token as string;
195
+ const payloadA = await stack.jwt.verify(tokenA);
196
+ expect(payloadA.tenantId).toBe(tenantA);
197
+ expect(payloadA.claims).toEqual({
198
+ "profile:segment": "premium",
199
+ "billing:plan": "pro",
200
+ });
201
+
202
+ // Switch to tenantB.
203
+ const switchRes = await stack.http.raw(
204
+ "POST",
205
+ "/api/auth/switch-tenant",
206
+ { tenantId: tenantB },
207
+ { Authorization: `Bearer ${tokenA}` },
208
+ );
209
+ expect(switchRes.status).toBe(200);
210
+ const switchBody = await switchRes.json();
211
+ const tokenB = switchBody.token as string;
212
+
213
+ const payloadB = await stack.jwt.verify(tokenB);
214
+ expect(payloadB.tenantId).toBe(tenantB);
215
+ expect(payloadB.roles).toEqual(["Admin"]);
216
+
217
+ // The hard part: claims are recomputed from tenant B data, not carried
218
+ // forward from tenant A. "premium"/"pro" MUST NOT appear.
219
+ expect(payloadB.claims).toEqual({
220
+ "profile:segment": "starter",
221
+ "billing:plan": "free",
222
+ });
223
+ });
224
+
225
+ test("switching to a tenant with no matching claim data → claims field absent on the new JWT", async () => {
226
+ const userId = await seedUser("sparse@example.com", "pw-long-enough");
227
+ await addMembership(userId, tenantA, ["User"]);
228
+ await addMembership(userId, tenantB, ["Admin"]);
229
+
230
+ // Only tenant A has data.
231
+ segmentsByUserAndTenant.set(`${userId}|${tenantA}`, "premium");
232
+ plansByTenant.set(tenantA, "pro");
233
+
234
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
235
+ email: "sparse@example.com",
236
+ password: "pw-long-enough",
237
+ });
238
+ const tokenA = (await loginRes.json()).token as string;
239
+ const payloadA = await stack.jwt.verify(tokenA);
240
+ expect(payloadA.claims).toEqual({
241
+ "profile:segment": "premium",
242
+ "billing:plan": "pro",
243
+ });
244
+
245
+ const switchRes = await stack.http.raw(
246
+ "POST",
247
+ "/api/auth/switch-tenant",
248
+ { tenantId: tenantB },
249
+ { Authorization: `Bearer ${tokenA}` },
250
+ );
251
+ const tokenB = (await switchRes.json()).token as string;
252
+ const payloadB = await stack.jwt.verify(tokenB);
253
+
254
+ // Both hooks return {} under tenant B → no claims field on the JWT.
255
+ expect(payloadB.claims).toBeUndefined();
256
+ });
257
+ });
258
+
259
+ describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
260
+ test("one feature returns key with ':' → only that key dropped, other features merge cleanly", async () => {
261
+ const localEncryption = createEncryptionProvider(encryptionKey);
262
+ const localResolver = createConfigResolver({ encryption: localEncryption });
263
+ // A polluted feature that tries to smuggle in a qualified-name-shaped
264
+ // inner key (injecting ":" would otherwise let it spoof another
265
+ // feature's prefix). Plus a clean companion to prove the drop is
266
+ // per-key, not per-feature.
267
+ const polluter = defineFeature("polluter", (r) => {
268
+ r.authClaims(async () => ({
269
+ "other:teamId": "injected", // must be dropped
270
+ legitKey: "ok", // must survive
271
+ }));
272
+ });
273
+ const clean = defineFeature("cleanliness", (r) => {
274
+ r.authClaims(async () => ({ mood: "tidy" }));
275
+ });
276
+
277
+ const localStack = await setupTestStack({
278
+ features: [
279
+ createConfigFeature(),
280
+ createUserFeature(),
281
+ createTenantFeature(),
282
+ createAuthEmailPasswordFeature(),
283
+ polluter,
284
+ clean,
285
+ ],
286
+ extraContext: { configResolver: localResolver },
287
+ authConfig: {
288
+ membershipQuery: "tenant:query:memberships",
289
+ loginHandler: AuthHandlers.login,
290
+ loginErrorStatusMap: {
291
+ [AuthErrors.invalidCredentials]: 401,
292
+ [AuthErrors.noMembership]: 403,
293
+ },
294
+ },
295
+ });
296
+ try {
297
+ await createEntityTable(localStack.db, userEntity);
298
+ await createEntityTable(localStack.db, tenantEntity);
299
+ await pushTables(localStack.db, { configValuesTable, tenantMembershipsTable });
300
+
301
+ const hash = await hashPassword("pw-long-enough");
302
+ const created = await localStack.http.writeOk<{ id: string }>(
303
+ UserHandlers.create,
304
+ { email: "sep@example.com", passwordHash: hash, displayName: "Sep" },
305
+ systemAdmin,
306
+ );
307
+ await seedTenantMembership(localStack.db, {
308
+ userId: created.id,
309
+ tenantId: tenantA,
310
+ roles: ["User"],
311
+ });
312
+
313
+ const res = await localStack.http.raw("POST", "/api/auth/login", {
314
+ email: "sep@example.com",
315
+ password: "pw-long-enough",
316
+ });
317
+ expect(res.status).toBe(200);
318
+ const { token } = (await res.json()) as { token: string };
319
+ const payload = await localStack.jwt.verify(token);
320
+
321
+ // The colon-laden key is gone. The polluter's other key + the clean
322
+ // feature's key both made it through under their own prefixes.
323
+ expect(payload.claims).toEqual({
324
+ "polluter:legitKey": "ok",
325
+ "cleanliness:mood": "tidy",
326
+ });
327
+ // And critically: no "other:teamId" survived — the polluter cannot
328
+ // spoof another feature's namespace by embedding the separator.
329
+ expect(payload.claims).not.toHaveProperty("other:teamId");
330
+ } finally {
331
+ await localStack.cleanup();
332
+ }
333
+ });
334
+ });
335
+
336
+ describe("scenario 2.6: multi-feature drift warnings fire independently", () => {
337
+ test("feature A + feature B both return undeclared keys → each logs its own warning", async () => {
338
+ const localEncryption = createEncryptionProvider(encryptionKey);
339
+ const localResolver = createConfigResolver({ encryption: localEncryption });
340
+
341
+ // Capture warn() calls to verify drift warnings fire per-feature, not
342
+ // globally collapsed.
343
+ const warnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
344
+ const testLogger = {
345
+ info: () => {},
346
+ warn: (msg: string, data?: Record<string, unknown>) => {
347
+ warnCalls.push({ msg, ...(data && { data }) });
348
+ },
349
+ error: () => {},
350
+ debug: () => {},
351
+ child() {
352
+ return this;
353
+ },
354
+ };
355
+
356
+ // Feature A declares `teamId` but returns both `teamId` AND undeclared
357
+ // `stray`. Feature B declares `plan` but returns both `plan` AND
358
+ // undeclared `extra`. Both should fire drift warnings — distinct feature
359
+ // names, distinct keys.
360
+ const featA = defineFeature("alpha", (r) => {
361
+ r.claimKey("teamId", { type: "string" });
362
+ r.authClaims(async () => ({ teamId: "t1", stray: "drift-a" }));
363
+ });
364
+ const featB = defineFeature("beta", (r) => {
365
+ r.claimKey("plan", { type: "string" });
366
+ r.authClaims(async () => ({ plan: "pro", extra: "drift-b" }));
367
+ });
368
+
369
+ const localStack = await setupTestStack({
370
+ features: [
371
+ createConfigFeature(),
372
+ createUserFeature(),
373
+ createTenantFeature(),
374
+ createAuthEmailPasswordFeature(),
375
+ featA,
376
+ featB,
377
+ ],
378
+ extraContext: { configResolver: localResolver, log: testLogger },
379
+ authConfig: {
380
+ membershipQuery: "tenant:query:memberships",
381
+ loginHandler: AuthHandlers.login,
382
+ loginErrorStatusMap: {
383
+ [AuthErrors.invalidCredentials]: 401,
384
+ [AuthErrors.noMembership]: 403,
385
+ },
386
+ },
387
+ });
388
+ try {
389
+ await createEntityTable(localStack.db, userEntity);
390
+ await createEntityTable(localStack.db, tenantEntity);
391
+ await pushTables(localStack.db, { configValuesTable, tenantMembershipsTable });
392
+
393
+ const hash = await hashPassword("pw-long-enough");
394
+ const created = await localStack.http.writeOk<{ id: string }>(
395
+ UserHandlers.create,
396
+ { email: "drift@example.com", passwordHash: hash, displayName: "Drift" },
397
+ systemAdmin,
398
+ );
399
+ await seedTenantMembership(localStack.db, {
400
+ userId: created.id,
401
+ tenantId: tenantA,
402
+ roles: ["User"],
403
+ });
404
+
405
+ const res = await localStack.http.raw("POST", "/api/auth/login", {
406
+ email: "drift@example.com",
407
+ password: "pw-long-enough",
408
+ });
409
+ expect(res.status).toBe(200);
410
+
411
+ // Two distinct drift warnings — per-feature, per-key, not collapsed.
412
+ const drifts = warnCalls.filter((w) => w.msg.includes("not declared via r.claimKey()"));
413
+ expect(drifts).toHaveLength(2);
414
+ const features = drifts.map((w) => w.data?.["featureName"]).sort();
415
+ expect(features).toEqual(["alpha", "beta"]);
416
+ const keys = drifts.map((w) => w.data?.["undeclaredKey"]).sort();
417
+ expect(keys).toEqual(["extra", "stray"]);
418
+ } finally {
419
+ await localStack.cleanup();
420
+ }
421
+ });
422
+ });
423
+
424
+ describe("scenario 3: a broken claims hook does not break login", () => {
425
+ test("hook throws → login still succeeds, broken feature simply contributes nothing, warn logged with featureName+err", async () => {
426
+ const localEncryption = createEncryptionProvider(encryptionKey);
427
+ const localResolver = createConfigResolver({ encryption: localEncryption });
428
+
429
+ // Capture warn-calls so the test can verify the resolver actually
430
+ // logged the broken hook with featureName + err.message — previously
431
+ // the test only asserted "login still succeeded", which would pass
432
+ // even if the log statement silently disappeared.
433
+ const warnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
434
+ const testLogger = {
435
+ info: () => {},
436
+ warn: (msg: string, data?: Record<string, unknown>) => {
437
+ warnCalls.push({ msg, ...(data && { data }) });
438
+ },
439
+ error: () => {},
440
+ debug: () => {},
441
+ child() {
442
+ return this;
443
+ },
444
+ };
445
+
446
+ const brokenFeature = defineFeature("broken", (r) => {
447
+ r.authClaims(async () => {
448
+ throw new Error("pretend the DB exploded");
449
+ });
450
+ });
451
+ const healthyFeature = defineFeature("drivers", (r) => {
452
+ r.authClaims(async () => ({ teamId: "t-42" }));
453
+ });
454
+
455
+ const localStack = await setupTestStack({
456
+ features: [
457
+ createConfigFeature(),
458
+ createUserFeature(),
459
+ createTenantFeature(),
460
+ createAuthEmailPasswordFeature(),
461
+ brokenFeature,
462
+ healthyFeature,
463
+ ],
464
+ extraContext: { configResolver: localResolver, log: testLogger },
465
+ authConfig: {
466
+ membershipQuery: "tenant:query:memberships",
467
+ loginHandler: AuthHandlers.login,
468
+ loginErrorStatusMap: {
469
+ [AuthErrors.invalidCredentials]: 401,
470
+ [AuthErrors.noMembership]: 403,
471
+ },
472
+ },
473
+ });
474
+ try {
475
+ await createEntityTable(localStack.db, userEntity);
476
+ await createEntityTable(localStack.db, tenantEntity);
477
+ await pushTables(localStack.db, { configValuesTable, tenantMembershipsTable });
478
+
479
+ const hash = await hashPassword("pw-long-enough");
480
+ const created = await localStack.http.writeOk<{ id: string }>(
481
+ UserHandlers.create,
482
+ { email: "broken@example.com", passwordHash: hash, displayName: "Broken" },
483
+ systemAdmin,
484
+ );
485
+ await seedTenantMembership(localStack.db, {
486
+ userId: created.id,
487
+ tenantId: tenantA,
488
+ roles: ["User"],
489
+ });
490
+
491
+ const res = await localStack.http.raw("POST", "/api/auth/login", {
492
+ email: "broken@example.com",
493
+ password: "pw-long-enough",
494
+ });
495
+ expect(res.status).toBe(200);
496
+ const body = await res.json();
497
+ expect(body.isSuccess).toBe(true);
498
+
499
+ const payload = await localStack.jwt.verify(body.token);
500
+ expect(payload.claims).toEqual({ "drivers:teamId": "t-42" });
501
+
502
+ // The resolver MUST have logged the failure with feature name and
503
+ // error message — ops needs that breadcrumb to find the bug.
504
+ const threwLog = warnCalls.find((w) => w.msg.includes("hook threw"));
505
+ expect(threwLog).toBeDefined();
506
+ expect(threwLog?.data?.["featureName"]).toBe("broken");
507
+ expect(threwLog?.data?.["err"]).toBe("pretend the DB exploded");
508
+ } finally {
509
+ await localStack.cleanup();
510
+ }
511
+ });
512
+ });