@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.
- package/CHANGELOG.md +124 -0
- package/README.md +47 -28
- package/esm/actions/error-action.d.ts +7 -2
- package/esm/actions/error-action.d.ts.map +1 -1
- package/esm/actions/error-action.js +7 -2
- package/esm/actions/error-action.js.map +1 -1
- package/esm/actions/get-current-user.d.ts +1 -4
- package/esm/actions/get-current-user.d.ts.map +1 -1
- package/esm/actions/get-current-user.js +1 -4
- package/esm/actions/get-current-user.js.map +1 -1
- package/esm/actions/get-current-user.spec.js +10 -6
- package/esm/actions/get-current-user.spec.js.map +1 -1
- package/esm/actions/is-authenticated.d.ts +3 -4
- package/esm/actions/is-authenticated.d.ts.map +1 -1
- package/esm/actions/is-authenticated.js +3 -4
- package/esm/actions/is-authenticated.js.map +1 -1
- package/esm/actions/is-authenticated.spec.js +10 -6
- package/esm/actions/is-authenticated.spec.js.map +1 -1
- package/esm/actions/logout-action.spec.js +11 -9
- package/esm/actions/logout-action.spec.js.map +1 -1
- package/esm/actions/logout.d.ts +2 -6
- package/esm/actions/logout.d.ts.map +1 -1
- package/esm/actions/logout.js +3 -7
- package/esm/actions/logout.js.map +1 -1
- package/esm/actions/not-found-action.d.ts +1 -3
- package/esm/actions/not-found-action.d.ts.map +1 -1
- package/esm/actions/not-found-action.js +1 -3
- package/esm/actions/not-found-action.js.map +1 -1
- package/esm/actions/password-login-action.d.ts +5 -12
- package/esm/actions/password-login-action.d.ts.map +1 -1
- package/esm/actions/password-login-action.js +6 -13
- package/esm/actions/password-login-action.js.map +1 -1
- package/esm/actions/password-login-action.spec.js +23 -28
- package/esm/actions/password-login-action.spec.js.map +1 -1
- package/esm/authenticate.d.ts +5 -11
- package/esm/authenticate.d.ts.map +1 -1
- package/esm/authenticate.js +6 -12
- package/esm/authenticate.js.map +1 -1
- package/esm/authenticate.spec.js +41 -33
- package/esm/authenticate.spec.js.map +1 -1
- package/esm/authentication-providers/authentication-provider.d.ts +17 -0
- package/esm/authentication-providers/authentication-provider.d.ts.map +1 -1
- package/esm/authentication-providers/basic-auth-provider.d.ts +7 -0
- package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -1
- package/esm/authentication-providers/basic-auth-provider.js +7 -0
- package/esm/authentication-providers/basic-auth-provider.js.map +1 -1
- package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -1
- package/esm/authentication-providers/cookie-auth-provider.js +8 -0
- package/esm/authentication-providers/cookie-auth-provider.js.map +1 -1
- package/esm/authorize.d.ts +5 -17
- package/esm/authorize.d.ts.map +1 -1
- package/esm/authorize.js +5 -17
- package/esm/authorize.js.map +1 -1
- package/esm/authorize.spec.js +34 -29
- package/esm/authorize.spec.js.map +1 -1
- package/esm/endpoint-generators/create-delete-endpoint.d.ts +6 -11
- package/esm/endpoint-generators/create-delete-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-delete-endpoint.js +7 -11
- package/esm/endpoint-generators/create-delete-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-delete-endpoint.spec.js +14 -21
- package/esm/endpoint-generators/create-delete-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-get-collection-endpoint.d.ts +11 -11
- package/esm/endpoint-generators/create-get-collection-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-collection-endpoint.js +13 -12
- package/esm/endpoint-generators/create-get-collection-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-get-collection-endpoint.spec.js +43 -70
- package/esm/endpoint-generators/create-get-collection-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-get-entity-endpoint.d.ts +6 -11
- package/esm/endpoint-generators/create-get-entity-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-entity-endpoint.js +7 -11
- package/esm/endpoint-generators/create-get-entity-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-get-entity-endpoint.spec.js +16 -33
- package/esm/endpoint-generators/create-get-entity-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.js +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -1
- package/esm/endpoint-generators/create-get-schema-action.d.ts +1 -1
- package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-schema-action.js.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.d.ts +7 -13
- package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.js +8 -13
- package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.spec.js +10 -17
- package/esm/endpoint-generators/create-patch-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.d.ts +7 -13
- package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.js +8 -13
- package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.spec.js +41 -14
- package/esm/endpoint-generators/create-post-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/utils.d.ts +17 -2
- package/esm/endpoint-generators/utils.d.ts.map +1 -1
- package/esm/endpoint-generators/utils.js +33 -20
- package/esm/endpoint-generators/utils.js.map +1 -1
- package/esm/get-schema-from-api.d.ts +1 -1
- package/esm/get-schema-from-api.d.ts.map +1 -1
- package/esm/get-schema-from-api.js +1 -0
- package/esm/get-schema-from-api.js.map +1 -1
- package/esm/get-schema-from-api.spec.js.map +1 -1
- package/esm/helpers.d.ts +54 -88
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +90 -92
- package/esm/helpers.js.map +1 -1
- package/esm/helpers.spec.d.ts +2 -1
- package/esm/helpers.spec.d.ts.map +1 -1
- package/esm/helpers.spec.js +41 -38
- package/esm/helpers.spec.js.map +1 -1
- package/esm/http-authentication-settings.d.ts +56 -12
- package/esm/http-authentication-settings.d.ts.map +1 -1
- package/esm/http-authentication-settings.js +27 -29
- package/esm/http-authentication-settings.js.map +1 -1
- package/esm/http-server-pool.d.ts +64 -0
- package/esm/http-server-pool.d.ts.map +1 -0
- package/esm/http-server-pool.js +95 -0
- package/esm/http-server-pool.js.map +1 -0
- package/esm/http-server-pool.spec.d.ts +2 -0
- package/esm/http-server-pool.spec.d.ts.map +1 -0
- package/esm/http-server-pool.spec.js +193 -0
- package/esm/http-server-pool.spec.js.map +1 -0
- package/esm/http-user-context.d.ts +34 -44
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +75 -95
- package/esm/http-user-context.js.map +1 -1
- package/esm/http-user-context.spec.d.ts +8 -2
- package/esm/http-user-context.spec.d.ts.map +1 -1
- package/esm/http-user-context.spec.js +226 -332
- package/esm/http-user-context.spec.js.map +1 -1
- package/esm/identity-cache-keys.d.ts +28 -0
- package/esm/identity-cache-keys.d.ts.map +1 -0
- package/esm/identity-cache-keys.js +13 -0
- package/esm/identity-cache-keys.js.map +1 -0
- package/esm/identity-cache-keys.spec.d.ts +2 -0
- package/esm/identity-cache-keys.spec.d.ts.map +1 -0
- package/esm/identity-cache-keys.spec.js +21 -0
- package/esm/identity-cache-keys.spec.js.map +1 -0
- package/esm/identity-event-bus.d.ts +102 -0
- package/esm/identity-event-bus.d.ts.map +1 -0
- package/esm/identity-event-bus.js +151 -0
- package/esm/identity-event-bus.js.map +1 -0
- package/esm/identity-event-bus.spec.d.ts +2 -0
- package/esm/identity-event-bus.spec.d.ts.map +1 -0
- package/esm/identity-event-bus.spec.js +392 -0
- package/esm/identity-event-bus.spec.js.map +1 -0
- package/esm/index.d.ts +9 -4
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +9 -4
- package/esm/index.js.map +1 -1
- package/esm/login-response-strategy.d.ts +11 -10
- package/esm/login-response-strategy.d.ts.map +1 -1
- package/esm/login-response-strategy.js +7 -6
- package/esm/login-response-strategy.js.map +1 -1
- package/esm/login-response-strategy.spec.js +25 -29
- package/esm/login-response-strategy.spec.js.map +1 -1
- package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
- package/esm/proxy-manager.spec.js +152 -176
- package/esm/proxy-manager.spec.js.map +1 -1
- package/esm/proxy-runtime.d.ts +27 -0
- package/esm/proxy-runtime.d.ts.map +1 -0
- package/esm/proxy-runtime.js +45 -0
- package/esm/proxy-runtime.js.map +1 -0
- package/esm/proxy-runtime.spec.d.ts +2 -0
- package/esm/proxy-runtime.spec.d.ts.map +1 -0
- package/esm/proxy-runtime.spec.js +39 -0
- package/esm/proxy-runtime.spec.js.map +1 -0
- package/esm/request-action-implementation.d.ts +25 -0
- package/esm/request-action-implementation.d.ts.map +1 -1
- package/esm/request-action-implementation.js +9 -0
- package/esm/request-action-implementation.js.map +1 -1
- package/esm/rest-api-runtime.d.ts +76 -0
- package/esm/rest-api-runtime.d.ts.map +1 -0
- package/esm/rest-api-runtime.js +131 -0
- package/esm/rest-api-runtime.js.map +1 -0
- package/esm/rest-api-runtime.spec.d.ts +2 -0
- package/esm/rest-api-runtime.spec.d.ts.map +1 -0
- package/esm/rest-api-runtime.spec.js +58 -0
- package/esm/rest-api-runtime.spec.js.map +1 -0
- package/esm/rest-service.integration.spec.d.ts +1 -1
- package/esm/rest-service.integration.spec.d.ts.map +1 -1
- package/esm/rest-service.integration.spec.js +57 -80
- package/esm/rest-service.integration.spec.js.map +1 -1
- package/esm/rest.integration.test.js +26 -84
- package/esm/rest.integration.test.js.map +1 -1
- package/esm/server-telemetry.d.ts +49 -0
- package/esm/server-telemetry.d.ts.map +1 -0
- package/esm/server-telemetry.js +25 -0
- package/esm/server-telemetry.js.map +1 -0
- package/esm/server-telemetry.spec.d.ts +2 -0
- package/esm/server-telemetry.spec.d.ts.map +1 -0
- package/esm/server-telemetry.spec.js +52 -0
- package/esm/server-telemetry.spec.js.map +1 -0
- package/esm/static-server-manager.spec.js +60 -125
- package/esm/static-server-manager.spec.js.map +1 -1
- package/esm/static-site-runtime.d.ts +21 -0
- package/esm/static-site-runtime.d.ts.map +1 -0
- package/esm/static-site-runtime.js +48 -0
- package/esm/static-site-runtime.js.map +1 -0
- package/esm/static-site-runtime.spec.d.ts +2 -0
- package/esm/static-site-runtime.spec.d.ts.map +1 -0
- package/esm/static-site-runtime.spec.js +30 -0
- package/esm/static-site-runtime.spec.js.map +1 -0
- package/esm/user-resolution-cache.d.ts +65 -0
- package/esm/user-resolution-cache.d.ts.map +1 -0
- package/esm/user-resolution-cache.js +101 -0
- package/esm/user-resolution-cache.js.map +1 -0
- package/esm/user-resolution-cache.spec.d.ts +2 -0
- package/esm/user-resolution-cache.spec.d.ts.map +1 -0
- package/esm/user-resolution-cache.spec.js +220 -0
- package/esm/user-resolution-cache.spec.js.map +1 -0
- package/esm/user-store.d.ts +39 -0
- package/esm/user-store.d.ts.map +1 -0
- package/esm/user-store.js +62 -0
- package/esm/user-store.js.map +1 -0
- package/esm/user-store.spec.d.ts +2 -0
- package/esm/user-store.spec.d.ts.map +1 -0
- package/esm/user-store.spec.js +72 -0
- package/esm/user-store.spec.js.map +1 -0
- package/esm/validate.d.ts +13 -12
- package/esm/validate.d.ts.map +1 -1
- package/esm/validate.integration.spec.js +27 -28
- package/esm/validate.integration.spec.js.map +1 -1
- package/esm/validate.js +12 -0
- package/esm/validate.js.map +1 -1
- package/package.json +14 -12
- package/src/actions/error-action.ts +7 -3
- package/src/actions/get-current-user.spec.ts +11 -10
- package/src/actions/get-current-user.ts +1 -4
- package/src/actions/is-authenticated.spec.ts +12 -7
- package/src/actions/is-authenticated.ts +3 -4
- package/src/actions/logout-action.spec.ts +12 -12
- package/src/actions/logout.ts +3 -7
- package/src/actions/not-found-action.ts +1 -3
- package/src/actions/password-login-action.spec.ts +30 -30
- package/src/actions/password-login-action.ts +6 -13
- package/src/authenticate.spec.ts +45 -51
- package/src/authenticate.ts +6 -12
- package/src/authentication-providers/authentication-provider.ts +17 -0
- package/src/authentication-providers/basic-auth-provider.ts +7 -0
- package/src/authentication-providers/cookie-auth-provider.ts +8 -0
- package/src/authorize.spec.ts +37 -39
- package/src/authorize.ts +6 -17
- package/src/endpoint-generators/create-delete-endpoint.spec.ts +16 -24
- package/src/endpoint-generators/create-delete-endpoint.ts +11 -17
- package/src/endpoint-generators/create-get-collection-endpoint.spec.ts +45 -78
- package/src/endpoint-generators/create-get-collection-endpoint.ts +17 -19
- package/src/endpoint-generators/create-get-entity-endpoint.spec.ts +17 -33
- package/src/endpoint-generators/create-get-entity-endpoint.ts +11 -17
- package/src/endpoint-generators/create-get-openapi-document-action.ts +2 -2
- package/src/endpoint-generators/create-get-schema-action.ts +1 -1
- package/src/endpoint-generators/create-patch-endpoint.spec.ts +11 -18
- package/src/endpoint-generators/create-patch-endpoint.ts +12 -23
- package/src/endpoint-generators/create-post-endpoint.spec.ts +45 -14
- package/src/endpoint-generators/create-post-endpoint.ts +13 -23
- package/src/endpoint-generators/utils.ts +36 -28
- package/src/get-schema-from-api.spec.ts +1 -1
- package/src/get-schema-from-api.ts +2 -1
- package/src/helpers.spec.ts +51 -41
- package/src/helpers.ts +140 -109
- package/src/http-authentication-settings.ts +78 -24
- package/src/http-server-pool.spec.ts +220 -0
- package/src/http-server-pool.ts +169 -0
- package/src/http-user-context.spec.ts +246 -378
- package/src/http-user-context.ts +122 -92
- package/src/identity-cache-keys.spec.ts +24 -0
- package/src/identity-cache-keys.ts +31 -0
- package/src/identity-event-bus.spec.ts +285 -0
- package/src/identity-event-bus.ts +230 -0
- package/src/index.ts +9 -4
- package/src/login-response-strategy.spec.ts +32 -31
- package/src/login-response-strategy.ts +14 -12
- package/src/openapi/generate-openapi-document.spec.ts +2 -2
- package/src/proxy-manager.spec.ts +158 -188
- package/src/proxy-runtime.spec.ts +50 -0
- package/src/proxy-runtime.ts +75 -0
- package/src/request-action-implementation.ts +25 -0
- package/src/rest-api-runtime.spec.ts +77 -0
- package/src/rest-api-runtime.ts +204 -0
- package/src/rest-service.integration.spec.ts +65 -85
- package/src/rest.integration.test.ts +27 -87
- package/src/server-telemetry.spec.ts +56 -0
- package/src/server-telemetry.ts +43 -0
- package/src/static-server-manager.spec.ts +63 -151
- package/src/static-site-runtime.spec.ts +39 -0
- package/src/static-site-runtime.ts +75 -0
- package/src/user-resolution-cache.spec.ts +241 -0
- package/src/user-resolution-cache.ts +164 -0
- package/src/user-store.spec.ts +86 -0
- package/src/user-store.ts +69 -0
- package/src/validate.integration.spec.ts +40 -35
- package/src/validate.ts +14 -12
- package/esm/api-manager.d.ts +0 -104
- package/esm/api-manager.d.ts.map +0 -1
- package/esm/api-manager.js +0 -179
- package/esm/api-manager.js.map +0 -1
- package/esm/proxy-manager.d.ts +0 -53
- package/esm/proxy-manager.d.ts.map +0 -1
- package/esm/proxy-manager.js +0 -84
- package/esm/proxy-manager.js.map +0 -1
- package/esm/server-manager.d.ts +0 -48
- package/esm/server-manager.d.ts.map +0 -1
- package/esm/server-manager.js +0 -100
- package/esm/server-manager.js.map +0 -1
- package/esm/static-server-manager.d.ts +0 -19
- package/esm/static-server-manager.d.ts.map +0 -1
- package/esm/static-server-manager.js +0 -65
- package/esm/static-server-manager.js.map +0 -1
- package/src/api-manager.ts +0 -304
- package/src/proxy-manager.ts +0 -102
- package/src/server-manager.ts +0 -135
- package/src/static-server-manager.ts +0 -90
package/src/http-user-context.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
return this.
|
|
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.
|
|
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
|
-
|
|
142
|
+
response: { setHeader: (header: string, value: string) => void },
|
|
126
143
|
): Promise<User> {
|
|
127
144
|
const sessionId = randomBytes(32).toString('hex')
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
this.
|
|
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.
|
|
145
|
-
const sessions = await sessionDataSet.find(this.systemInjector, {
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
})
|