@furystack/rest-service 13.0.0 → 14.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 (311) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +47 -28
  3. package/esm/actions/error-action.d.ts +7 -2
  4. package/esm/actions/error-action.d.ts.map +1 -1
  5. package/esm/actions/error-action.js +7 -2
  6. package/esm/actions/error-action.js.map +1 -1
  7. package/esm/actions/get-current-user.d.ts +1 -4
  8. package/esm/actions/get-current-user.d.ts.map +1 -1
  9. package/esm/actions/get-current-user.js +1 -4
  10. package/esm/actions/get-current-user.js.map +1 -1
  11. package/esm/actions/get-current-user.spec.js +10 -6
  12. package/esm/actions/get-current-user.spec.js.map +1 -1
  13. package/esm/actions/is-authenticated.d.ts +3 -4
  14. package/esm/actions/is-authenticated.d.ts.map +1 -1
  15. package/esm/actions/is-authenticated.js +3 -4
  16. package/esm/actions/is-authenticated.js.map +1 -1
  17. package/esm/actions/is-authenticated.spec.js +10 -6
  18. package/esm/actions/is-authenticated.spec.js.map +1 -1
  19. package/esm/actions/logout-action.spec.js +11 -9
  20. package/esm/actions/logout-action.spec.js.map +1 -1
  21. package/esm/actions/logout.d.ts +2 -6
  22. package/esm/actions/logout.d.ts.map +1 -1
  23. package/esm/actions/logout.js +3 -7
  24. package/esm/actions/logout.js.map +1 -1
  25. package/esm/actions/not-found-action.d.ts +1 -3
  26. package/esm/actions/not-found-action.d.ts.map +1 -1
  27. package/esm/actions/not-found-action.js +1 -3
  28. package/esm/actions/not-found-action.js.map +1 -1
  29. package/esm/actions/password-login-action.d.ts +5 -12
  30. package/esm/actions/password-login-action.d.ts.map +1 -1
  31. package/esm/actions/password-login-action.js +6 -13
  32. package/esm/actions/password-login-action.js.map +1 -1
  33. package/esm/actions/password-login-action.spec.js +23 -28
  34. package/esm/actions/password-login-action.spec.js.map +1 -1
  35. package/esm/authenticate.d.ts +5 -11
  36. package/esm/authenticate.d.ts.map +1 -1
  37. package/esm/authenticate.js +6 -12
  38. package/esm/authenticate.js.map +1 -1
  39. package/esm/authenticate.spec.js +41 -33
  40. package/esm/authenticate.spec.js.map +1 -1
  41. package/esm/authentication-providers/authentication-provider.d.ts +17 -0
  42. package/esm/authentication-providers/authentication-provider.d.ts.map +1 -1
  43. package/esm/authentication-providers/basic-auth-provider.d.ts +7 -0
  44. package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -1
  45. package/esm/authentication-providers/basic-auth-provider.js +7 -0
  46. package/esm/authentication-providers/basic-auth-provider.js.map +1 -1
  47. package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -1
  48. package/esm/authentication-providers/cookie-auth-provider.js +8 -0
  49. package/esm/authentication-providers/cookie-auth-provider.js.map +1 -1
  50. package/esm/authorize.d.ts +5 -17
  51. package/esm/authorize.d.ts.map +1 -1
  52. package/esm/authorize.js +5 -17
  53. package/esm/authorize.js.map +1 -1
  54. package/esm/authorize.spec.js +34 -29
  55. package/esm/authorize.spec.js.map +1 -1
  56. package/esm/endpoint-generators/create-delete-endpoint.d.ts +6 -11
  57. package/esm/endpoint-generators/create-delete-endpoint.d.ts.map +1 -1
  58. package/esm/endpoint-generators/create-delete-endpoint.js +7 -11
  59. package/esm/endpoint-generators/create-delete-endpoint.js.map +1 -1
  60. package/esm/endpoint-generators/create-delete-endpoint.spec.js +14 -21
  61. package/esm/endpoint-generators/create-delete-endpoint.spec.js.map +1 -1
  62. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts +11 -11
  63. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts.map +1 -1
  64. package/esm/endpoint-generators/create-get-collection-endpoint.js +13 -12
  65. package/esm/endpoint-generators/create-get-collection-endpoint.js.map +1 -1
  66. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js +43 -70
  67. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js.map +1 -1
  68. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts +6 -11
  69. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts.map +1 -1
  70. package/esm/endpoint-generators/create-get-entity-endpoint.js +7 -11
  71. package/esm/endpoint-generators/create-get-entity-endpoint.js.map +1 -1
  72. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js +16 -33
  73. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js.map +1 -1
  74. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +1 -1
  75. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -1
  76. package/esm/endpoint-generators/create-get-openapi-document-action.js +1 -1
  77. package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -1
  78. package/esm/endpoint-generators/create-get-schema-action.d.ts +1 -1
  79. package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -1
  80. package/esm/endpoint-generators/create-get-schema-action.js.map +1 -1
  81. package/esm/endpoint-generators/create-patch-endpoint.d.ts +7 -13
  82. package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
  83. package/esm/endpoint-generators/create-patch-endpoint.js +8 -13
  84. package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
  85. package/esm/endpoint-generators/create-patch-endpoint.spec.js +10 -17
  86. package/esm/endpoint-generators/create-patch-endpoint.spec.js.map +1 -1
  87. package/esm/endpoint-generators/create-post-endpoint.d.ts +7 -13
  88. package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
  89. package/esm/endpoint-generators/create-post-endpoint.js +8 -13
  90. package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
  91. package/esm/endpoint-generators/create-post-endpoint.spec.js +41 -14
  92. package/esm/endpoint-generators/create-post-endpoint.spec.js.map +1 -1
  93. package/esm/endpoint-generators/utils.d.ts +17 -2
  94. package/esm/endpoint-generators/utils.d.ts.map +1 -1
  95. package/esm/endpoint-generators/utils.js +33 -20
  96. package/esm/endpoint-generators/utils.js.map +1 -1
  97. package/esm/get-schema-from-api.d.ts +1 -1
  98. package/esm/get-schema-from-api.d.ts.map +1 -1
  99. package/esm/get-schema-from-api.js +1 -0
  100. package/esm/get-schema-from-api.js.map +1 -1
  101. package/esm/get-schema-from-api.spec.js.map +1 -1
  102. package/esm/helpers.d.ts +54 -88
  103. package/esm/helpers.d.ts.map +1 -1
  104. package/esm/helpers.js +90 -92
  105. package/esm/helpers.js.map +1 -1
  106. package/esm/helpers.spec.d.ts +2 -1
  107. package/esm/helpers.spec.d.ts.map +1 -1
  108. package/esm/helpers.spec.js +41 -38
  109. package/esm/helpers.spec.js.map +1 -1
  110. package/esm/http-authentication-settings.d.ts +56 -12
  111. package/esm/http-authentication-settings.d.ts.map +1 -1
  112. package/esm/http-authentication-settings.js +27 -29
  113. package/esm/http-authentication-settings.js.map +1 -1
  114. package/esm/http-server-pool.d.ts +64 -0
  115. package/esm/http-server-pool.d.ts.map +1 -0
  116. package/esm/http-server-pool.js +95 -0
  117. package/esm/http-server-pool.js.map +1 -0
  118. package/esm/http-server-pool.spec.d.ts +2 -0
  119. package/esm/http-server-pool.spec.d.ts.map +1 -0
  120. package/esm/http-server-pool.spec.js +193 -0
  121. package/esm/http-server-pool.spec.js.map +1 -0
  122. package/esm/http-user-context.d.ts +34 -44
  123. package/esm/http-user-context.d.ts.map +1 -1
  124. package/esm/http-user-context.js +75 -95
  125. package/esm/http-user-context.js.map +1 -1
  126. package/esm/http-user-context.spec.d.ts +8 -2
  127. package/esm/http-user-context.spec.d.ts.map +1 -1
  128. package/esm/http-user-context.spec.js +226 -332
  129. package/esm/http-user-context.spec.js.map +1 -1
  130. package/esm/identity-cache-keys.d.ts +28 -0
  131. package/esm/identity-cache-keys.d.ts.map +1 -0
  132. package/esm/identity-cache-keys.js +13 -0
  133. package/esm/identity-cache-keys.js.map +1 -0
  134. package/esm/identity-cache-keys.spec.d.ts +2 -0
  135. package/esm/identity-cache-keys.spec.d.ts.map +1 -0
  136. package/esm/identity-cache-keys.spec.js +21 -0
  137. package/esm/identity-cache-keys.spec.js.map +1 -0
  138. package/esm/identity-event-bus.d.ts +102 -0
  139. package/esm/identity-event-bus.d.ts.map +1 -0
  140. package/esm/identity-event-bus.js +151 -0
  141. package/esm/identity-event-bus.js.map +1 -0
  142. package/esm/identity-event-bus.spec.d.ts +2 -0
  143. package/esm/identity-event-bus.spec.d.ts.map +1 -0
  144. package/esm/identity-event-bus.spec.js +392 -0
  145. package/esm/identity-event-bus.spec.js.map +1 -0
  146. package/esm/index.d.ts +9 -4
  147. package/esm/index.d.ts.map +1 -1
  148. package/esm/index.js +9 -4
  149. package/esm/index.js.map +1 -1
  150. package/esm/login-response-strategy.d.ts +11 -10
  151. package/esm/login-response-strategy.d.ts.map +1 -1
  152. package/esm/login-response-strategy.js +7 -6
  153. package/esm/login-response-strategy.js.map +1 -1
  154. package/esm/login-response-strategy.spec.js +25 -29
  155. package/esm/login-response-strategy.spec.js.map +1 -1
  156. package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
  157. package/esm/proxy-manager.spec.js +152 -176
  158. package/esm/proxy-manager.spec.js.map +1 -1
  159. package/esm/proxy-runtime.d.ts +27 -0
  160. package/esm/proxy-runtime.d.ts.map +1 -0
  161. package/esm/proxy-runtime.js +45 -0
  162. package/esm/proxy-runtime.js.map +1 -0
  163. package/esm/proxy-runtime.spec.d.ts +2 -0
  164. package/esm/proxy-runtime.spec.d.ts.map +1 -0
  165. package/esm/proxy-runtime.spec.js +39 -0
  166. package/esm/proxy-runtime.spec.js.map +1 -0
  167. package/esm/request-action-implementation.d.ts +25 -0
  168. package/esm/request-action-implementation.d.ts.map +1 -1
  169. package/esm/request-action-implementation.js +9 -0
  170. package/esm/request-action-implementation.js.map +1 -1
  171. package/esm/rest-api-runtime.d.ts +76 -0
  172. package/esm/rest-api-runtime.d.ts.map +1 -0
  173. package/esm/rest-api-runtime.js +131 -0
  174. package/esm/rest-api-runtime.js.map +1 -0
  175. package/esm/rest-api-runtime.spec.d.ts +2 -0
  176. package/esm/rest-api-runtime.spec.d.ts.map +1 -0
  177. package/esm/rest-api-runtime.spec.js +58 -0
  178. package/esm/rest-api-runtime.spec.js.map +1 -0
  179. package/esm/rest-service.integration.spec.d.ts +1 -1
  180. package/esm/rest-service.integration.spec.d.ts.map +1 -1
  181. package/esm/rest-service.integration.spec.js +57 -80
  182. package/esm/rest-service.integration.spec.js.map +1 -1
  183. package/esm/rest.integration.test.js +26 -84
  184. package/esm/rest.integration.test.js.map +1 -1
  185. package/esm/server-telemetry.d.ts +49 -0
  186. package/esm/server-telemetry.d.ts.map +1 -0
  187. package/esm/server-telemetry.js +25 -0
  188. package/esm/server-telemetry.js.map +1 -0
  189. package/esm/server-telemetry.spec.d.ts +2 -0
  190. package/esm/server-telemetry.spec.d.ts.map +1 -0
  191. package/esm/server-telemetry.spec.js +52 -0
  192. package/esm/server-telemetry.spec.js.map +1 -0
  193. package/esm/static-server-manager.spec.js +60 -125
  194. package/esm/static-server-manager.spec.js.map +1 -1
  195. package/esm/static-site-runtime.d.ts +21 -0
  196. package/esm/static-site-runtime.d.ts.map +1 -0
  197. package/esm/static-site-runtime.js +48 -0
  198. package/esm/static-site-runtime.js.map +1 -0
  199. package/esm/static-site-runtime.spec.d.ts +2 -0
  200. package/esm/static-site-runtime.spec.d.ts.map +1 -0
  201. package/esm/static-site-runtime.spec.js +30 -0
  202. package/esm/static-site-runtime.spec.js.map +1 -0
  203. package/esm/user-resolution-cache.d.ts +65 -0
  204. package/esm/user-resolution-cache.d.ts.map +1 -0
  205. package/esm/user-resolution-cache.js +101 -0
  206. package/esm/user-resolution-cache.js.map +1 -0
  207. package/esm/user-resolution-cache.spec.d.ts +2 -0
  208. package/esm/user-resolution-cache.spec.d.ts.map +1 -0
  209. package/esm/user-resolution-cache.spec.js +220 -0
  210. package/esm/user-resolution-cache.spec.js.map +1 -0
  211. package/esm/user-store.d.ts +39 -0
  212. package/esm/user-store.d.ts.map +1 -0
  213. package/esm/user-store.js +62 -0
  214. package/esm/user-store.js.map +1 -0
  215. package/esm/user-store.spec.d.ts +2 -0
  216. package/esm/user-store.spec.d.ts.map +1 -0
  217. package/esm/user-store.spec.js +72 -0
  218. package/esm/user-store.spec.js.map +1 -0
  219. package/esm/validate.d.ts +13 -12
  220. package/esm/validate.d.ts.map +1 -1
  221. package/esm/validate.integration.spec.js +27 -28
  222. package/esm/validate.integration.spec.js.map +1 -1
  223. package/esm/validate.js +12 -0
  224. package/esm/validate.js.map +1 -1
  225. package/package.json +14 -12
  226. package/src/actions/error-action.ts +7 -3
  227. package/src/actions/get-current-user.spec.ts +11 -10
  228. package/src/actions/get-current-user.ts +1 -4
  229. package/src/actions/is-authenticated.spec.ts +12 -7
  230. package/src/actions/is-authenticated.ts +3 -4
  231. package/src/actions/logout-action.spec.ts +12 -12
  232. package/src/actions/logout.ts +3 -7
  233. package/src/actions/not-found-action.ts +1 -3
  234. package/src/actions/password-login-action.spec.ts +30 -30
  235. package/src/actions/password-login-action.ts +6 -13
  236. package/src/authenticate.spec.ts +45 -51
  237. package/src/authenticate.ts +6 -12
  238. package/src/authentication-providers/authentication-provider.ts +17 -0
  239. package/src/authentication-providers/basic-auth-provider.ts +7 -0
  240. package/src/authentication-providers/cookie-auth-provider.ts +8 -0
  241. package/src/authorize.spec.ts +37 -39
  242. package/src/authorize.ts +6 -17
  243. package/src/endpoint-generators/create-delete-endpoint.spec.ts +16 -24
  244. package/src/endpoint-generators/create-delete-endpoint.ts +11 -17
  245. package/src/endpoint-generators/create-get-collection-endpoint.spec.ts +45 -78
  246. package/src/endpoint-generators/create-get-collection-endpoint.ts +17 -19
  247. package/src/endpoint-generators/create-get-entity-endpoint.spec.ts +17 -33
  248. package/src/endpoint-generators/create-get-entity-endpoint.ts +11 -17
  249. package/src/endpoint-generators/create-get-openapi-document-action.ts +2 -2
  250. package/src/endpoint-generators/create-get-schema-action.ts +1 -1
  251. package/src/endpoint-generators/create-patch-endpoint.spec.ts +11 -18
  252. package/src/endpoint-generators/create-patch-endpoint.ts +12 -23
  253. package/src/endpoint-generators/create-post-endpoint.spec.ts +45 -14
  254. package/src/endpoint-generators/create-post-endpoint.ts +13 -23
  255. package/src/endpoint-generators/utils.ts +36 -28
  256. package/src/get-schema-from-api.spec.ts +1 -1
  257. package/src/get-schema-from-api.ts +2 -1
  258. package/src/helpers.spec.ts +51 -41
  259. package/src/helpers.ts +140 -109
  260. package/src/http-authentication-settings.ts +78 -24
  261. package/src/http-server-pool.spec.ts +220 -0
  262. package/src/http-server-pool.ts +169 -0
  263. package/src/http-user-context.spec.ts +246 -378
  264. package/src/http-user-context.ts +122 -92
  265. package/src/identity-cache-keys.spec.ts +24 -0
  266. package/src/identity-cache-keys.ts +31 -0
  267. package/src/identity-event-bus.spec.ts +285 -0
  268. package/src/identity-event-bus.ts +230 -0
  269. package/src/index.ts +9 -4
  270. package/src/login-response-strategy.spec.ts +32 -31
  271. package/src/login-response-strategy.ts +14 -12
  272. package/src/openapi/generate-openapi-document.spec.ts +2 -2
  273. package/src/proxy-manager.spec.ts +158 -188
  274. package/src/proxy-runtime.spec.ts +50 -0
  275. package/src/proxy-runtime.ts +75 -0
  276. package/src/request-action-implementation.ts +25 -0
  277. package/src/rest-api-runtime.spec.ts +77 -0
  278. package/src/rest-api-runtime.ts +204 -0
  279. package/src/rest-service.integration.spec.ts +65 -85
  280. package/src/rest.integration.test.ts +27 -87
  281. package/src/server-telemetry.spec.ts +56 -0
  282. package/src/server-telemetry.ts +43 -0
  283. package/src/static-server-manager.spec.ts +63 -151
  284. package/src/static-site-runtime.spec.ts +39 -0
  285. package/src/static-site-runtime.ts +75 -0
  286. package/src/user-resolution-cache.spec.ts +241 -0
  287. package/src/user-resolution-cache.ts +164 -0
  288. package/src/user-store.spec.ts +86 -0
  289. package/src/user-store.ts +69 -0
  290. package/src/validate.integration.spec.ts +40 -35
  291. package/src/validate.ts +14 -12
  292. package/esm/api-manager.d.ts +0 -104
  293. package/esm/api-manager.d.ts.map +0 -1
  294. package/esm/api-manager.js +0 -179
  295. package/esm/api-manager.js.map +0 -1
  296. package/esm/proxy-manager.d.ts +0 -53
  297. package/esm/proxy-manager.d.ts.map +0 -1
  298. package/esm/proxy-manager.js +0 -84
  299. package/esm/proxy-manager.js.map +0 -1
  300. package/esm/server-manager.d.ts +0 -48
  301. package/esm/server-manager.d.ts.map +0 -1
  302. package/esm/server-manager.js +0 -100
  303. package/esm/server-manager.js.map +0 -1
  304. package/esm/static-server-manager.d.ts +0 -19
  305. package/esm/static-server-manager.d.ts.map +0 -1
  306. package/esm/static-server-manager.js +0 -65
  307. package/esm/static-server-manager.js.map +0 -1
  308. package/src/api-manager.ts +0 -304
  309. package/src/proxy-manager.ts +0 -102
  310. package/src/server-manager.ts +0 -135
  311. package/src/static-server-manager.ts +0 -90
@@ -1,17 +1,20 @@
1
1
  import type { User } from '@furystack/core'
2
2
  import { useSystemIdentityContext } from '@furystack/core'
3
3
  import type { Injector } from '@furystack/inject'
4
- import { Injectable, Injected } from '@furystack/inject'
4
+ import { defineService, type Token } from '@furystack/inject'
5
5
  import { PasswordAuthenticator, UnauthenticatedError } from '@furystack/security'
6
6
  import { EventHub, type ListenerErrorPayload } from '@furystack/utils'
7
7
  import { randomBytes } from 'crypto'
8
8
  import type { IncomingMessage } from 'http'
9
+ import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
9
10
  import { extractSessionIdFromCookies } from './authentication-providers/helpers.js'
10
11
  import { HttpAuthenticationSettings } from './http-authentication-settings.js'
11
- import type { DefaultSession } from './models/default-session.js'
12
+ import { IdentityEventBus } from './identity-event-bus.js'
13
+ import { resolveUserCacheKey, UserResolutionCache } from './user-resolution-cache.js'
12
14
 
13
15
  /**
14
- * Events emitted by the {@link HttpUserContext}
16
+ * Events emitted by a per-request {@link HttpUserContext} instance. Since
17
+ * the context is scoped, each request sees its own event stream.
15
18
  */
16
19
  export type HttpUserContextEvents = {
17
20
  onLogin: { user: User }
@@ -21,45 +24,78 @@ export type HttpUserContextEvents = {
21
24
  }
22
25
 
23
26
  /**
24
- * Injectable UserContext for FuryStack HTTP Api
27
+ * Request-scoped authentication/authorization facade.
28
+ *
29
+ * `HttpUserContext` consolidates identity lookup, authentication-provider
30
+ * walk-through and cookie session helpers behind a single object.
31
+ * Successful resolutions are memoized in the singleton
32
+ * {@link UserResolutionCache} keyed by the cache key produced by the
33
+ * matching {@link AuthenticationProvider} (e.g. `cookie:${sessionId}`
34
+ * for cookie auth) and expire after
35
+ * {@link HttpAuthenticationSettings.userCacheTtlMs}. Requests whose
36
+ * providers all opt out of caching (Basic Auth, JWT Bearer, anonymous
37
+ * traffic) bypass the cache and walk the provider chain on every call.
25
38
  */
26
- @Injectable({ lifetime: 'scoped' })
27
- export class HttpUserContext extends EventHub<HttpUserContextEvents> {
28
- public getUserDataSet = () => this.authentication.getUserDataSet(this.systemInjector)
29
-
30
- public getSessionDataSet = () => this.authentication.getSessionDataSet(this.systemInjector)
31
-
32
- private getUserByName = async (userName: string) => {
33
- const userDataSet = this.getUserDataSet()
34
- const users = await userDataSet.find(this.systemInjector, { filter: { username: { $eq: userName } }, top: 2 })
35
- if (users.length !== 1) {
36
- throw new UnauthenticatedError()
37
- }
38
- return users[0]
39
- }
40
-
41
- private user?: User
39
+ export interface HttpUserContext extends EventHub<HttpUserContextEvents> {
40
+ /** The active {@link HttpAuthenticationSettings} captured at resolve time. */
41
+ readonly authentication: HttpAuthenticationSettings
42
+ /** Whether the current user is authenticated according to the bound providers. */
43
+ isAuthenticated(request: Pick<IncomingMessage, 'headers'>): Promise<boolean>
44
+ /** Returns `true` when the current user has **all** of the supplied roles. */
45
+ isAuthorized(request: Pick<IncomingMessage, 'headers'>, ...roles: string[]): Promise<boolean>
46
+ /** Verifies a username/password pair against the system `PasswordAuthenticator`. */
47
+ authenticateUser(userName: string, password: string): Promise<User>
48
+ /** Returns the cached user for this request or resolves it via the provider chain. */
49
+ getCurrentUser(request: Pick<IncomingMessage, 'headers'>): Promise<User>
50
+ /** Extracts the session id for `request` using the configured cookie name. */
51
+ getSessionIdFromRequest(request: Pick<IncomingMessage, 'headers'>): string | null
52
+ /** Walks the configured authentication providers and returns the first match. */
53
+ authenticateRequest(request: Pick<IncomingMessage, 'headers'>): Promise<User>
54
+ /** Creates and persists a session for `user`, setting the cookie on `response`. */
55
+ cookieLogin(user: User, response: { setHeader: (header: string, value: string) => void }): Promise<User>
56
+ /** Clears the session identified by the request's cookie (if any). */
57
+ cookieLogout(
58
+ request: Pick<IncomingMessage, 'headers'>,
59
+ response: { setHeader: (header: string, value: string) => void },
60
+ ): Promise<void>
61
+ }
42
62
 
63
+ /**
64
+ * Internal concrete {@link EventHub}-backed implementation of
65
+ * {@link HttpUserContext}. Exposed via the `HttpUserContext` token;
66
+ * constructed by its factory.
67
+ */
68
+ class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements HttpUserContext {
43
69
  /**
44
- * @param request The request to be authenticated
45
- * @returns whether the current user is authenticated
70
+ * `authenticator` is resolved lazily so that tests (and applications) that
71
+ * never invoke `authenticateUser` don't need to bind password-credential
72
+ * stores.
73
+ *
74
+ * The shared {@link UserResolutionCache} memoizes successful authentications
75
+ * across requests within the process. Cache keys are produced by the
76
+ * configured providers via `getCacheKey`; requests whose providers all
77
+ * opt out (e.g. Basic Auth, anonymous traffic) bypass the cache.
46
78
  */
47
- public async isAuthenticated(request: IncomingMessage) {
79
+ constructor(
80
+ public readonly authentication: HttpAuthenticationSettings,
81
+ private readonly resolveAuthenticator: () => PasswordAuthenticator,
82
+ private readonly systemInjector: Injector,
83
+ private readonly userCache: UserResolutionCache,
84
+ private readonly identityEventBus: IdentityEventBus,
85
+ ) {
86
+ super()
87
+ }
88
+
89
+ public async isAuthenticated(request: Pick<IncomingMessage, 'headers'>): Promise<boolean> {
48
90
  try {
49
91
  const currentUser = await this.getCurrentUser(request)
50
92
  return currentUser !== null
51
- } catch (error) {
93
+ } catch {
52
94
  return false
53
95
  }
54
96
  }
55
97
 
56
- /**
57
- * Returns whether the current user can be authorized with ALL of the specified roles
58
- * @param request The request to be authenticated
59
- * @param roles The list of roles to authorize
60
- * @returns a boolean value that indicates whether the user is authorized
61
- */
62
- public async isAuthorized(request: IncomingMessage, ...roles: string[]): Promise<boolean> {
98
+ public async isAuthorized(request: Pick<IncomingMessage, 'headers'>, ...roles: string[]): Promise<boolean> {
63
99
  const currentUser = await this.getCurrentUser(request)
64
100
  for (const role of roles) {
65
101
  if (!currentUser || !currentUser.roles.some((c) => c === role)) {
@@ -69,15 +105,8 @@ export class HttpUserContext extends EventHub<HttpUserContextEvents> {
69
105
  return true
70
106
  }
71
107
 
72
- /**
73
- * Checks if the system contains a user with the provided name and password, throws an error otherwise
74
- * @param userName The username
75
- * @param password The password
76
- * @returns the authenticated User
77
- */
78
- public async authenticateUser(userName: string, password: string) {
79
- const result = await this.authenticator.checkPasswordForUser(userName, password)
80
-
108
+ public async authenticateUser(userName: string, password: string): Promise<User> {
109
+ const result = await this.resolveAuthenticator().checkPasswordForUser(userName, password)
81
110
  if (!result.isValid) {
82
111
  throw new UnauthenticatedError()
83
112
  }
@@ -88,24 +117,18 @@ export class HttpUserContext extends EventHub<HttpUserContextEvents> {
88
117
  return user
89
118
  }
90
119
 
91
- public async getCurrentUser(request: Pick<IncomingMessage, 'headers'>) {
92
- if (!this.user) {
93
- this.user = await this.authenticateRequest(request)
94
- return this.user
120
+ public async getCurrentUser(request: Pick<IncomingMessage, 'headers'>): Promise<User> {
121
+ const cacheKey = resolveUserCacheKey(this.authentication, request)
122
+ if (!cacheKey) {
123
+ return this.authenticateRequest(request)
95
124
  }
96
- return this.user
125
+ return this.userCache.resolve(cacheKey, () => this.authenticateRequest(request))
97
126
  }
98
127
 
99
128
  public getSessionIdFromRequest(request: Pick<IncomingMessage, 'headers'>): string | null {
100
129
  return extractSessionIdFromCookies(request, this.authentication.cookieName)
101
130
  }
102
131
 
103
- /**
104
- * Iterates registered authentication providers in order.
105
- * - A provider returning `User` means authentication succeeded.
106
- * - A provider returning `null` means it does not apply; try the next one.
107
- * - A provider throwing means it owns the request but auth failed; propagate the error.
108
- */
109
132
  public async authenticateRequest(request: Pick<IncomingMessage, 'headers'>): Promise<User> {
110
133
  for (const provider of this.authentication.authenticationProviders) {
111
134
  const user = await provider.authenticate(request)
@@ -114,20 +137,14 @@ export class HttpUserContext extends EventHub<HttpUserContextEvents> {
114
137
  throw new UnauthenticatedError()
115
138
  }
116
139
 
117
- /**
118
- * Creates and sets up a cookie-based session for the provided user
119
- * @param user The user to create a session for
120
- * @param serverResponse A serverResponse to set the cookie
121
- * @returns the current User
122
- */
123
140
  public async cookieLogin(
124
141
  user: User,
125
- serverResponse: { setHeader: (header: string, value: string) => void },
142
+ response: { setHeader: (header: string, value: string) => void },
126
143
  ): Promise<User> {
127
144
  const sessionId = randomBytes(32).toString('hex')
128
- await this.getSessionDataSet().add(this.systemInjector, { sessionId, username: user.username })
129
- serverResponse.setHeader('Set-Cookie', `${this.authentication.cookieName}=${sessionId}; Path=/; HttpOnly`)
130
- this.user = user
145
+ const sessionDataSet = this.systemInjector.get(this.authentication.sessionDataSet)
146
+ await sessionDataSet.add(this.systemInjector, { sessionId, username: user.username })
147
+ response.setHeader('Set-Cookie', `${this.authentication.cookieName}=${sessionId}; Path=/; HttpOnly`)
131
148
  this.emit('onLogin', { user })
132
149
  return user
133
150
  }
@@ -135,45 +152,58 @@ export class HttpUserContext extends EventHub<HttpUserContextEvents> {
135
152
  public async cookieLogout(
136
153
  request: Pick<IncomingMessage, 'headers'>,
137
154
  response: { setHeader: (header: string, value: string) => void },
138
- ) {
139
- this.user = undefined
155
+ ): Promise<void> {
140
156
  const sessionId = this.getSessionIdFromRequest(request)
141
157
  response.setHeader('Set-Cookie', `${this.authentication.cookieName}=; Path=/; HttpOnly`)
142
158
 
143
159
  if (sessionId) {
144
- const sessionDataSet = this.getSessionDataSet()
145
- const sessions = await sessionDataSet.find(this.systemInjector, { filter: { sessionId: { $eq: sessionId } } })
146
- await sessionDataSet.remove(this.systemInjector, ...sessions.map((s) => s[sessionDataSet.primaryKey]))
160
+ const sessionDataSet = this.systemInjector.get(this.authentication.sessionDataSet)
161
+ const sessions = await sessionDataSet.find(this.systemInjector, {
162
+ filter: { sessionId: { $eq: sessionId } },
163
+ })
164
+ await sessionDataSet.remove(this.systemInjector, ...sessions.map((s) => s.sessionId))
165
+ // Cross-node identity invalidation: the facade applies the local cache
166
+ // drop synchronously, then broadcasts so siblings collapse the
167
+ // staleness window from `userCacheTtlMs` to bus latency.
168
+ await this.identityEventBus.publish({ type: 'userLoggedOut', sessionId })
147
169
  }
148
170
  this.emit('onLogout', undefined)
149
171
  }
150
172
 
151
- @Injected(HttpAuthenticationSettings)
152
- declare public readonly authentication: HttpAuthenticationSettings<User, DefaultSession>
153
-
154
- @Injected((injector: Injector) => useSystemIdentityContext({ injector, username: 'HttpUserContext' }))
155
- declare private readonly systemInjector: Injector
156
-
157
- @Injected(PasswordAuthenticator)
158
- declare private readonly authenticator: PasswordAuthenticator
159
-
160
- public init() {
161
- this.getUserDataSet().addListener('onEntityUpdated', ({ id, change }) => {
162
- if (this.user?.username === id) {
163
- this.user = { ...this.user, ...change }
164
- }
165
- })
166
-
167
- this.getUserDataSet().addListener('onEntityRemoved', ({ key }) => {
168
- if (this.user?.username === key) {
169
- this.user = undefined
170
- this.emit('onSessionInvalidated', undefined)
171
- }
172
- })
173
-
174
- this.getSessionDataSet().addListener('onEntityRemoved', () => {
175
- this.user = undefined
176
- this.emit('onSessionInvalidated', undefined)
173
+ private async getUserByName(userName: string): Promise<User> {
174
+ const userDataSet = this.systemInjector.get(this.authentication.userDataSet)
175
+ const users = await userDataSet.find(this.systemInjector, {
176
+ filter: { username: { $eq: userName } },
177
+ top: 2,
177
178
  })
179
+ if (users.length !== 1) {
180
+ throw new UnauthenticatedError()
181
+ }
182
+ return users[0]
178
183
  }
179
184
  }
185
+
186
+ /**
187
+ * DI token for the per-request {@link HttpUserContext}. Scoped — each HTTP
188
+ * request resolves its own instance with a fresh `user` cache.
189
+ */
190
+ export const HttpUserContext: Token<HttpUserContext, 'scoped'> = defineService({
191
+ name: 'furystack/rest-service/HttpUserContext',
192
+ lifetime: 'scoped',
193
+ factory: ({ inject, injector, onDispose }): HttpUserContext => {
194
+ const authentication = inject(HttpAuthenticationSettings)
195
+ const userCache = inject(UserResolutionCache)
196
+ const identityEventBus = inject(IdentityEventBus)
197
+ const systemInjector = useSystemIdentityContext({ injector, username: 'HttpUserContext' })
198
+ onDispose(() => systemInjector[Symbol.asyncDispose]())
199
+ // Password authenticator resolution is deferred: tests that exercise only
200
+ // unauthenticated endpoints never have to bind credential stores.
201
+ return new HttpUserContextImpl(
202
+ authentication,
203
+ () => injector.get(PasswordAuthenticator),
204
+ systemInjector,
205
+ userCache,
206
+ identityEventBus,
207
+ )
208
+ },
209
+ })
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { sessionCacheKey, userCacheTag } from './identity-cache-keys.js'
3
+
4
+ describe('identity-cache-keys', () => {
5
+ describe('sessionCacheKey', () => {
6
+ it('prefixes the session id with `cookie:`', () => {
7
+ expect(sessionCacheKey('abc')).toBe('cookie:abc')
8
+ })
9
+
10
+ it('preserves the empty string suffix shape', () => {
11
+ expect(sessionCacheKey('')).toBe('cookie:')
12
+ })
13
+ })
14
+
15
+ describe('userCacheTag', () => {
16
+ it('prefixes the username with `user:`', () => {
17
+ expect(userCacheTag('alice')).toBe('user:alice')
18
+ })
19
+
20
+ it('preserves the empty string suffix shape', () => {
21
+ expect(userCacheTag('')).toBe('user:')
22
+ })
23
+ })
24
+ })
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Single source of truth for the cache key and cache tag shapes used by the
3
+ * identity-resolution path. Producer (`cookie-auth-provider.getCacheKey`),
4
+ * local invalidation (`HttpUserContext.cookieLogout`,
5
+ * `UserResolutionCache.invalidateByUser`), and any future
6
+ * `IdentityEventBus` subscribers all derive their strings here so the
7
+ * local-vs-replicated invalidation paths cannot drift.
8
+ */
9
+
10
+ /**
11
+ * Cache key shape for the cookie-authentication provider. Keying by
12
+ * session id is safe because session invalidation drops the session
13
+ * row and the {@link UserResolutionCache} TTL bounds staleness.
14
+ */
15
+ export type SessionCacheKey = `cookie:${string}`
16
+
17
+ /**
18
+ * Tag attached to every {@link UserResolutionCache} entry, derived from
19
+ * the resolved user's `username`. Enables `removeByTag` invalidation
20
+ * across every cached session that resolved to a given user — used by
21
+ * `invalidateByUser` and (later) by `IdentityEventBus` subscribers
22
+ * acting on `userRolesChanged` / `userDeleted` / `passwordChanged`
23
+ * events.
24
+ */
25
+ export type UserCacheTag = `user:${string}`
26
+
27
+ /** Builds the cookie-auth cache key for `sessionId`. */
28
+ export const sessionCacheKey = (sessionId: string): SessionCacheKey => `cookie:${sessionId}`
29
+
30
+ /** Builds the user-resolution cache tag for `username`. */
31
+ export const userCacheTag = (username: string): UserCacheTag => `user:${username}`
@@ -0,0 +1,285 @@
1
+ import type { User } from '@furystack/core'
2
+ import { CrossNodeBus, defineInProcessCrossNodeBus, type BusMessage } from '@furystack/cross-node-bus'
3
+ import { createInProcessBusNetwork } from '@furystack/cross-node-bus/testing'
4
+ import { createInjector } from '@furystack/inject'
5
+ import { usingAsync } from '@furystack/utils'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+ import { sessionCacheKey, userCacheTag } from './identity-cache-keys.js'
8
+ import {
9
+ applyLocalIdentityInvalidation,
10
+ IDENTITY_EVENT_TOPIC,
11
+ IdentityEventBus,
12
+ type IdentityEvent,
13
+ } from './identity-event-bus.js'
14
+ import { UserResolutionCache } from './user-resolution-cache.js'
15
+
16
+ const userOf = (username: string, roles: readonly string[] = []): User => ({ username, roles: [...roles] })
17
+
18
+ const populateCache = async (cache: UserResolutionCache, sessionId: string, user: User): Promise<void> => {
19
+ await cache.resolve(sessionCacheKey(sessionId), async () => user)
20
+ }
21
+
22
+ describe('IdentityEventBus', () => {
23
+ describe('applyLocalIdentityInvalidation', () => {
24
+ it('invalidates by session id for `userLoggedOut`', async () => {
25
+ await usingAsync(createInjector(), async (i) => {
26
+ const cache = i.get(UserResolutionCache)
27
+ await populateCache(cache, 's1', userOf('alice'))
28
+ expect(cache.size).toBe(1)
29
+ applyLocalIdentityInvalidation({ type: 'userLoggedOut', sessionId: 's1' }, cache)
30
+ expect(cache.size).toBe(0)
31
+ })
32
+ })
33
+
34
+ it('invalidates by session id for `sessionInvalidated`', async () => {
35
+ await usingAsync(createInjector(), async (i) => {
36
+ const cache = i.get(UserResolutionCache)
37
+ await populateCache(cache, 's2', userOf('alice'))
38
+ applyLocalIdentityInvalidation({ type: 'sessionInvalidated', sessionId: 's2' }, cache)
39
+ expect(cache.size).toBe(0)
40
+ })
41
+ })
42
+
43
+ for (const type of ['userRolesChanged', 'userDeleted', 'passwordChanged'] as const) {
44
+ it(`invalidates every cached session for the named user on \`${type}\``, async () => {
45
+ await usingAsync(createInjector(), async (i) => {
46
+ const cache = i.get(UserResolutionCache)
47
+ await populateCache(cache, 's-a-1', userOf('alice'))
48
+ await populateCache(cache, 's-a-2', userOf('alice'))
49
+ await populateCache(cache, 's-b-1', userOf('bob'))
50
+ expect(cache.size).toBe(3)
51
+ applyLocalIdentityInvalidation({ type, username: 'alice' }, cache)
52
+ expect(cache.size).toBe(1)
53
+ // The remaining entry is bob's.
54
+ await populateCache(cache, 's-a-3', userOf('alice'))
55
+ expect(cache.size).toBe(2)
56
+ })
57
+ })
58
+ }
59
+ })
60
+
61
+ describe('publish', () => {
62
+ it('invalidates the local cache synchronously before awaiting the bus', async () => {
63
+ await usingAsync(createInjector(), async (i) => {
64
+ const cache = i.get(UserResolutionCache)
65
+ await populateCache(cache, 's1', userOf('alice'))
66
+ const bus = i.get(IdentityEventBus)
67
+ const pending = bus.publish({ type: 'userLoggedOut', sessionId: 's1' })
68
+ // Local invalidation must already be visible — the bus.publish promise
69
+ // is irrelevant for in-process correctness on the originating node.
70
+ expect(cache.size).toBe(0)
71
+ await pending
72
+ })
73
+ })
74
+
75
+ it('fires local subscribers with the typed payload on every event variant', async () => {
76
+ await usingAsync(createInjector(), async (i) => {
77
+ const bus = i.get(IdentityEventBus)
78
+ const events: IdentityEvent[] = []
79
+ using _u = bus.subscribe('userLoggedOut', (event) => events.push(event))
80
+ using _s = bus.subscribe('sessionInvalidated', (event) => events.push(event))
81
+ using _r = bus.subscribe('userRolesChanged', (event) => events.push(event))
82
+ using _d = bus.subscribe('userDeleted', (event) => events.push(event))
83
+ using _p = bus.subscribe('passwordChanged', (event) => events.push(event))
84
+
85
+ await bus.publish({ type: 'userLoggedOut', sessionId: 's1' })
86
+ await bus.publish({ type: 'sessionInvalidated', sessionId: 's2' })
87
+ await bus.publish({ type: 'userRolesChanged', username: 'alice' })
88
+ await bus.publish({ type: 'userDeleted', username: 'bob' })
89
+ await bus.publish({ type: 'passwordChanged', username: 'carol' })
90
+
91
+ expect(events).toEqual([
92
+ { type: 'userLoggedOut', sessionId: 's1' },
93
+ { type: 'sessionInvalidated', sessionId: 's2' },
94
+ { type: 'userRolesChanged', username: 'alice' },
95
+ { type: 'userDeleted', username: 'bob' },
96
+ { type: 'passwordChanged', username: 'carol' },
97
+ ])
98
+ })
99
+ })
100
+
101
+ it('does not fire subscribers for other event types', async () => {
102
+ await usingAsync(createInjector(), async (i) => {
103
+ const bus = i.get(IdentityEventBus)
104
+ const handler = vi.fn()
105
+ using _u = bus.subscribe('userLoggedOut', handler)
106
+
107
+ await bus.publish({ type: 'sessionInvalidated', sessionId: 'x' })
108
+ await bus.publish({ type: 'userRolesChanged', username: 'alice' })
109
+
110
+ expect(handler).not.toHaveBeenCalled()
111
+ })
112
+ })
113
+
114
+ it('rejects when called after dispose', async () => {
115
+ const injector = createInjector()
116
+ const bus = injector.get(IdentityEventBus)
117
+ await injector[Symbol.asyncDispose]()
118
+ await expect(bus.publish({ type: 'userLoggedOut', sessionId: 's1' })).rejects.toThrow(/disposed/)
119
+ })
120
+
121
+ it('publishes onto the underlying CrossNodeBus topic', async () => {
122
+ await usingAsync(createInjector(), async (i) => {
123
+ const crossNodeBus = i.get(CrossNodeBus)
124
+ const wireHandler = vi.fn<(message: BusMessage) => void>()
125
+ using _sub = crossNodeBus.subscribe(IDENTITY_EVENT_TOPIC, wireHandler)
126
+
127
+ await i.get(IdentityEventBus).publish({ type: 'userRolesChanged', username: 'alice' })
128
+
129
+ expect(wireHandler).toHaveBeenCalledTimes(1)
130
+ expect(wireHandler.mock.calls[0][0].payload).toEqual({ type: 'userRolesChanged', username: 'alice' })
131
+ })
132
+ })
133
+ })
134
+
135
+ describe('subscribe', () => {
136
+ it('returns a Disposable that removes the listener', async () => {
137
+ await usingAsync(createInjector(), async (i) => {
138
+ const bus = i.get(IdentityEventBus)
139
+ const handler = vi.fn()
140
+ const sub = bus.subscribe('userLoggedOut', handler)
141
+ await bus.publish({ type: 'userLoggedOut', sessionId: 's1' })
142
+ sub[Symbol.dispose]()
143
+ await bus.publish({ type: 'userLoggedOut', sessionId: 's2' })
144
+ expect(handler).toHaveBeenCalledTimes(1)
145
+ })
146
+ })
147
+
148
+ it('isolates listener errors so peers still run', async () => {
149
+ await usingAsync(createInjector(), async (i) => {
150
+ const bus = i.get(IdentityEventBus)
151
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
152
+ try {
153
+ const peer = vi.fn()
154
+ using _bad = bus.subscribe('userLoggedOut', () => {
155
+ throw new Error('boom')
156
+ })
157
+ using _good = bus.subscribe('userLoggedOut', peer)
158
+
159
+ await bus.publish({ type: 'userLoggedOut', sessionId: 's1' })
160
+
161
+ expect(peer).toHaveBeenCalledTimes(1)
162
+ expect(errorSpy).toHaveBeenCalled()
163
+ } finally {
164
+ errorSpy.mockRestore()
165
+ }
166
+ })
167
+ })
168
+
169
+ it('lets a handler dispose its own subscription mid-emit without skipping peers', async () => {
170
+ await usingAsync(createInjector(), async (i) => {
171
+ const bus = i.get(IdentityEventBus)
172
+ const peer = vi.fn()
173
+ let selfDispose: Disposable | null = null
174
+ selfDispose = bus.subscribe('userLoggedOut', () => {
175
+ selfDispose?.[Symbol.dispose]()
176
+ })
177
+ using _peer = bus.subscribe('userLoggedOut', peer)
178
+
179
+ await bus.publish({ type: 'userLoggedOut', sessionId: 's1' })
180
+
181
+ expect(peer).toHaveBeenCalledTimes(1)
182
+ })
183
+ })
184
+ })
185
+
186
+ describe('cross-node delivery', () => {
187
+ it('logout on node A invalidates the cached session on node B', async () => {
188
+ using network = createInProcessBusNetwork({ count: 2 })
189
+ const [busA, busB] = network.buses
190
+ await usingAsync(createInjector(), async (a) => {
191
+ await usingAsync(createInjector(), async (b) => {
192
+ a.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busA.nodeId }))
193
+ b.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busB.nodeId }))
194
+
195
+ const cacheA = a.get(UserResolutionCache)
196
+ const cacheB = b.get(UserResolutionCache)
197
+ await populateCache(cacheA, 's1', userOf('alice'))
198
+ await populateCache(cacheB, 's1', userOf('alice'))
199
+
200
+ // Force the bus singletons to materialise so subscriptions are live.
201
+ const facadeA = a.get(IdentityEventBus)
202
+ b.get(IdentityEventBus)
203
+
204
+ await facadeA.publish({ type: 'userLoggedOut', sessionId: 's1' })
205
+
206
+ expect(cacheA.size).toBe(0)
207
+ expect(cacheB.size).toBe(0)
208
+ })
209
+ })
210
+ })
211
+
212
+ it('userRolesChanged on node A drops every alice entry on node B', async () => {
213
+ using network = createInProcessBusNetwork({ count: 2 })
214
+ const [busA, busB] = network.buses
215
+ await usingAsync(createInjector(), async (a) => {
216
+ await usingAsync(createInjector(), async (b) => {
217
+ a.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busA.nodeId }))
218
+ b.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busB.nodeId }))
219
+
220
+ const cacheB = b.get(UserResolutionCache)
221
+ await populateCache(cacheB, 's-a-1', userOf('alice'))
222
+ await populateCache(cacheB, 's-a-2', userOf('alice'))
223
+ await populateCache(cacheB, 's-b-1', userOf('bob'))
224
+ a.get(IdentityEventBus)
225
+ b.get(IdentityEventBus)
226
+
227
+ await a.get(IdentityEventBus).publish({ type: 'userRolesChanged', username: 'alice' })
228
+
229
+ expect(cacheB.size).toBe(1)
230
+ })
231
+ })
232
+ })
233
+
234
+ it('local subscribers on node A see the event before the bus broadcasts', async () => {
235
+ using network = createInProcessBusNetwork({ count: 2 })
236
+ const [busA, busB] = network.buses
237
+ await usingAsync(createInjector(), async (a) => {
238
+ await usingAsync(createInjector(), async (b) => {
239
+ a.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busA.nodeId }))
240
+ b.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busB.nodeId }))
241
+
242
+ const order: string[] = []
243
+ using _localA = a.get(IdentityEventBus).subscribe('userDeleted', () => order.push('A-local'))
244
+ using _remoteB = b.get(IdentityEventBus).subscribe('userDeleted', () => order.push('B-remote'))
245
+
246
+ await a.get(IdentityEventBus).publish({ type: 'userDeleted', username: 'bob' })
247
+
248
+ // A-local fires synchronously inside `publish`; B-remote fires after
249
+ // the bus accepts the message — same tick in-process, but ordering
250
+ // is guaranteed by the publish path's local-first contract.
251
+ expect(order).toEqual(['A-local', 'B-remote'])
252
+ })
253
+ })
254
+ })
255
+
256
+ it('drops malformed payloads received over the bus without invalidating the cache', async () => {
257
+ using network = createInProcessBusNetwork({ count: 2 })
258
+ const [busA, busB] = network.buses
259
+ await usingAsync(createInjector(), async (a) => {
260
+ await usingAsync(createInjector(), async (b) => {
261
+ a.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busA.nodeId }))
262
+ b.bind(CrossNodeBus, defineInProcessCrossNodeBus({ broker: network.broker, nodeId: busB.nodeId }))
263
+
264
+ const cacheB = b.get(UserResolutionCache)
265
+ await populateCache(cacheB, 's1', userOf('alice'))
266
+ b.get(IdentityEventBus)
267
+ a.get(IdentityEventBus)
268
+
269
+ await a.get(CrossNodeBus).publish(IDENTITY_EVENT_TOPIC, { type: 'unknownEvent', sessionId: 's1' })
270
+ await a.get(CrossNodeBus).publish(IDENTITY_EVENT_TOPIC, null)
271
+ await a.get(CrossNodeBus).publish(IDENTITY_EVENT_TOPIC, { type: 'userLoggedOut' })
272
+
273
+ expect(cacheB.size).toBe(1)
274
+ })
275
+ })
276
+ })
277
+ })
278
+
279
+ describe('shared helpers', () => {
280
+ it('exposes the cache-key shape used by the facade subscribers', () => {
281
+ expect(sessionCacheKey('s1')).toBe('cookie:s1')
282
+ expect(userCacheTag('alice')).toBe('user:alice')
283
+ })
284
+ })
285
+ })