@furystack/rest-service 14.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 (143) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/esm/actions/error-action.d.ts +7 -2
  3. package/esm/actions/error-action.d.ts.map +1 -1
  4. package/esm/actions/error-action.js +7 -2
  5. package/esm/actions/error-action.js.map +1 -1
  6. package/esm/actions/get-current-user.d.ts +1 -4
  7. package/esm/actions/get-current-user.d.ts.map +1 -1
  8. package/esm/actions/get-current-user.js +1 -4
  9. package/esm/actions/get-current-user.js.map +1 -1
  10. package/esm/actions/is-authenticated.d.ts +3 -4
  11. package/esm/actions/is-authenticated.d.ts.map +1 -1
  12. package/esm/actions/is-authenticated.js +3 -4
  13. package/esm/actions/is-authenticated.js.map +1 -1
  14. package/esm/actions/logout.d.ts +2 -6
  15. package/esm/actions/logout.d.ts.map +1 -1
  16. package/esm/actions/logout.js +2 -6
  17. package/esm/actions/logout.js.map +1 -1
  18. package/esm/actions/not-found-action.d.ts +1 -3
  19. package/esm/actions/not-found-action.d.ts.map +1 -1
  20. package/esm/actions/not-found-action.js +1 -3
  21. package/esm/actions/not-found-action.js.map +1 -1
  22. package/esm/actions/password-login-action.d.ts +5 -12
  23. package/esm/actions/password-login-action.d.ts.map +1 -1
  24. package/esm/actions/password-login-action.js +5 -12
  25. package/esm/actions/password-login-action.js.map +1 -1
  26. package/esm/authenticate.d.ts +5 -11
  27. package/esm/authenticate.d.ts.map +1 -1
  28. package/esm/authenticate.js +5 -11
  29. package/esm/authenticate.js.map +1 -1
  30. package/esm/authentication-providers/authentication-provider.d.ts +17 -0
  31. package/esm/authentication-providers/authentication-provider.d.ts.map +1 -1
  32. package/esm/authentication-providers/basic-auth-provider.d.ts +7 -0
  33. package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -1
  34. package/esm/authentication-providers/basic-auth-provider.js +7 -0
  35. package/esm/authentication-providers/basic-auth-provider.js.map +1 -1
  36. package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -1
  37. package/esm/authentication-providers/cookie-auth-provider.js +8 -0
  38. package/esm/authentication-providers/cookie-auth-provider.js.map +1 -1
  39. package/esm/authorize.d.ts +5 -17
  40. package/esm/authorize.d.ts.map +1 -1
  41. package/esm/authorize.js +5 -17
  42. package/esm/authorize.js.map +1 -1
  43. package/esm/get-schema-from-api.d.ts.map +1 -1
  44. package/esm/get-schema-from-api.js +1 -0
  45. package/esm/get-schema-from-api.js.map +1 -1
  46. package/esm/helpers.d.ts +6 -6
  47. package/esm/helpers.d.ts.map +1 -1
  48. package/esm/helpers.js +3 -4
  49. package/esm/helpers.js.map +1 -1
  50. package/esm/http-authentication-settings.d.ts +27 -6
  51. package/esm/http-authentication-settings.d.ts.map +1 -1
  52. package/esm/http-authentication-settings.js +6 -0
  53. package/esm/http-authentication-settings.js.map +1 -1
  54. package/esm/http-server-pool.spec.js.map +1 -1
  55. package/esm/http-user-context.d.ts +8 -5
  56. package/esm/http-user-context.d.ts.map +1 -1
  57. package/esm/http-user-context.js +26 -27
  58. package/esm/http-user-context.js.map +1 -1
  59. package/esm/http-user-context.spec.js +48 -6
  60. package/esm/http-user-context.spec.js.map +1 -1
  61. package/esm/identity-cache-keys.d.ts +28 -0
  62. package/esm/identity-cache-keys.d.ts.map +1 -0
  63. package/esm/identity-cache-keys.js +13 -0
  64. package/esm/identity-cache-keys.js.map +1 -0
  65. package/esm/identity-cache-keys.spec.d.ts +2 -0
  66. package/esm/identity-cache-keys.spec.d.ts.map +1 -0
  67. package/esm/identity-cache-keys.spec.js +21 -0
  68. package/esm/identity-cache-keys.spec.js.map +1 -0
  69. package/esm/identity-event-bus.d.ts +102 -0
  70. package/esm/identity-event-bus.d.ts.map +1 -0
  71. package/esm/identity-event-bus.js +151 -0
  72. package/esm/identity-event-bus.js.map +1 -0
  73. package/esm/identity-event-bus.spec.d.ts +2 -0
  74. package/esm/identity-event-bus.spec.d.ts.map +1 -0
  75. package/esm/identity-event-bus.spec.js +392 -0
  76. package/esm/identity-event-bus.spec.js.map +1 -0
  77. package/esm/index.d.ts +3 -0
  78. package/esm/index.d.ts.map +1 -1
  79. package/esm/index.js +3 -0
  80. package/esm/index.js.map +1 -1
  81. package/esm/login-response-strategy.d.ts.map +1 -1
  82. package/esm/login-response-strategy.js.map +1 -1
  83. package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
  84. package/esm/proxy-manager.spec.js.map +1 -1
  85. package/esm/request-action-implementation.d.ts +25 -0
  86. package/esm/request-action-implementation.d.ts.map +1 -1
  87. package/esm/request-action-implementation.js +9 -0
  88. package/esm/request-action-implementation.js.map +1 -1
  89. package/esm/rest-api-runtime.spec.js.map +1 -1
  90. package/esm/server-telemetry.d.ts +6 -14
  91. package/esm/server-telemetry.d.ts.map +1 -1
  92. package/esm/server-telemetry.js +2 -3
  93. package/esm/server-telemetry.js.map +1 -1
  94. package/esm/user-resolution-cache.d.ts +65 -0
  95. package/esm/user-resolution-cache.d.ts.map +1 -0
  96. package/esm/user-resolution-cache.js +101 -0
  97. package/esm/user-resolution-cache.js.map +1 -0
  98. package/esm/user-resolution-cache.spec.d.ts +2 -0
  99. package/esm/user-resolution-cache.spec.d.ts.map +1 -0
  100. package/esm/user-resolution-cache.spec.js +220 -0
  101. package/esm/user-resolution-cache.spec.js.map +1 -0
  102. package/esm/user-store.d.ts.map +1 -1
  103. package/esm/user-store.js.map +1 -1
  104. package/esm/validate.d.ts +13 -12
  105. package/esm/validate.d.ts.map +1 -1
  106. package/esm/validate.integration.spec.js +1 -0
  107. package/esm/validate.integration.spec.js.map +1 -1
  108. package/esm/validate.js +12 -0
  109. package/esm/validate.js.map +1 -1
  110. package/package.json +14 -12
  111. package/src/actions/error-action.ts +7 -3
  112. package/src/actions/get-current-user.ts +1 -4
  113. package/src/actions/is-authenticated.ts +3 -4
  114. package/src/actions/logout.ts +2 -6
  115. package/src/actions/not-found-action.ts +1 -3
  116. package/src/actions/password-login-action.ts +5 -12
  117. package/src/authenticate.ts +5 -11
  118. package/src/authentication-providers/authentication-provider.ts +17 -0
  119. package/src/authentication-providers/basic-auth-provider.ts +7 -0
  120. package/src/authentication-providers/cookie-auth-provider.ts +8 -0
  121. package/src/authorize.ts +6 -17
  122. package/src/get-schema-from-api.ts +1 -0
  123. package/src/helpers.ts +11 -11
  124. package/src/http-authentication-settings.ts +34 -6
  125. package/src/http-server-pool.spec.ts +1 -1
  126. package/src/http-user-context.spec.ts +59 -11
  127. package/src/http-user-context.ts +38 -31
  128. package/src/identity-cache-keys.spec.ts +24 -0
  129. package/src/identity-cache-keys.ts +31 -0
  130. package/src/identity-event-bus.spec.ts +285 -0
  131. package/src/identity-event-bus.ts +230 -0
  132. package/src/index.ts +3 -0
  133. package/src/login-response-strategy.ts +1 -0
  134. package/src/openapi/generate-openapi-document.spec.ts +2 -2
  135. package/src/proxy-manager.spec.ts +6 -6
  136. package/src/request-action-implementation.ts +25 -0
  137. package/src/rest-api-runtime.spec.ts +1 -1
  138. package/src/server-telemetry.ts +6 -14
  139. package/src/user-resolution-cache.spec.ts +241 -0
  140. package/src/user-resolution-cache.ts +164 -0
  141. package/src/user-store.ts +1 -0
  142. package/src/validate.integration.spec.ts +1 -0
  143. package/src/validate.ts +14 -12
@@ -1,12 +1,17 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
  import type { IncomingMessage, ServerResponse } from 'http'
3
3
 
4
+ /**
5
+ * The shape returned by every {@link RequestAction}. Consumed by the runtime
6
+ * to write the response (`statusCode`, `headers`, serialized `chunk`).
7
+ */
4
8
  export interface ActionResult<T> {
5
9
  statusCode: number
6
10
  headers: { [K: string]: string }
7
11
  chunk: T
8
12
  }
9
13
 
14
+ /** {@link ActionResult} with `application/json`. Default status 200. */
10
15
  export const JsonResult = <T extends object>(chunk: T, statusCode = 200, headers?: { [K: string]: string }) =>
11
16
  ({
12
17
  statusCode,
@@ -17,6 +22,7 @@ export const JsonResult = <T extends object>(chunk: T, statusCode = 200, headers
17
22
  },
18
23
  }) as ActionResult<T>
19
24
 
25
+ /** {@link ActionResult} with `plain/text`. Default status 200. */
20
26
  export const PlainTextResult = (text: string, statusCode = 200, headers?: { [K: string]: string }) =>
21
27
  ({
22
28
  statusCode,
@@ -27,6 +33,7 @@ export const PlainTextResult = (text: string, statusCode = 200, headers?: { [K:
27
33
  },
28
34
  }) as ActionResult<string>
29
35
 
36
+ /** {@link ActionResult} with `application/xml;charset=utf-8`. Default status 200. */
30
37
  export const XmlResult = (text: string, statusCode = 200, headers?: { [K: string]: string }) =>
31
38
  ({
32
39
  statusCode,
@@ -37,6 +44,7 @@ export const XmlResult = (text: string, statusCode = 200, headers?: { [K: string
37
44
  },
38
45
  }) as ActionResult<string>
39
46
 
47
+ /** {@link ActionResult} with no body. Use for `204 No Content` responses. */
40
48
  export const EmptyResult = (statusCode = 200, headers?: { [K: string]: string }) =>
41
49
  ({
42
50
  statusCode,
@@ -45,11 +53,22 @@ export const EmptyResult = (statusCode = 200, headers?: { [K: string]: string })
45
53
  },
46
54
  }) as ActionResult<undefined>
47
55
 
56
+ /**
57
+ * Sentinel result that signals "skip this action; let the runtime fall
58
+ * through to the next matching handler (or 404 if none)". Returned from
59
+ * an action when it decides not to handle the request after inspection.
60
+ */
48
61
  export const BypassResult = () =>
49
62
  ({
50
63
  chunk: 'BypassResult',
51
64
  }) as ActionResult<'BypassResult'>
52
65
 
66
+ /**
67
+ * Argument shape passed to a {@link RequestAction}. Conditional members
68
+ * (`getBody`, `getUrlParams`, `getQuery`, `headers`) are present only when
69
+ * the action's contract declares the corresponding key — keeping unrelated
70
+ * actions free of unused parser allocations.
71
+ */
53
72
  export type RequestActionOptions<T extends { result: unknown }> = {
54
73
  request: IncomingMessage
55
74
  response: ServerResponse
@@ -64,6 +83,12 @@ export type RequestActionOptions<T extends { result: unknown }> = {
64
83
  (T extends { result: unknown; query: infer U } ? { getQuery: () => U } : unknown) &
65
84
  (T extends { result: unknown; headers: infer U } ? { headers: U } : unknown)
66
85
 
86
+ /**
87
+ * Server-side implementation of a typed REST endpoint contract `T` (declared
88
+ * via `@furystack/rest`). Receives {@link RequestActionOptions} narrowed to
89
+ * the contract's body/url/query/headers and resolves to an {@link ActionResult}
90
+ * carrying the contract's `result` shape.
91
+ */
67
92
  export type RequestAction<T extends { result: unknown }> = (
68
93
  options: RequestActionOptions<T>,
69
94
  ) => Promise<ActionResult<T['result']>>
@@ -15,7 +15,7 @@ type SampleApi = {
15
15
  const buildImplementation = (): RestApiImplementation<SampleApi> => ({
16
16
  GET: {
17
17
  '/items': async () => JsonResult({ items: [1, 2, 3] }),
18
- '/items/:id': async ({ getUrlParams }) => JsonResult({ id: (getUrlParams() as { id: string }).id }),
18
+ '/items/:id': async ({ getUrlParams }) => JsonResult({ id: getUrlParams().id }),
19
19
  },
20
20
  POST: {
21
21
  '/items': async ({ getBody }) => JsonResult(await getBody()),
@@ -4,32 +4,24 @@ import type { IncomingMessage, ServerResponse } from 'http'
4
4
  import type { Data, WebSocket } from 'ws'
5
5
 
6
6
  /**
7
- * Union of every server-lifecycle event the rest-service runtime emits. Replaces
8
- * the `EventHub`s that used to live on the deleted `ServerManager`,
9
- * `ProxyManager` and `WebSocketApi` classes — consumers subscribe once to the
10
- * telemetry singleton and receive events from every source.
7
+ * Union of every server-lifecycle event the rest-service runtime emits.
8
+ * The HTTP server pool, proxy runtime and websocket-api all forward into
9
+ * this single hub so subscribers can observe every source from one place.
11
10
  */
12
11
  export type ServerTelemetryEvents = {
13
- /** Emitted when the pool successfully opens an HTTP listener. */
14
12
  onServerListening: { url: string; port: number; hostName?: string }
15
- /** Emitted when the pool closes an HTTP listener during disposal. */
16
13
  onServerClosed: { url: string }
17
- /** Emitted when an API's `onRequest` rejects. Tuple matches the original `ServerManager` payload. */
14
+ /** Tuple-shaped to match the underlying Node `request` callback signature. */
18
15
  onRequestFailed: [unknown, IncomingMessage, ServerResponse<IncomingMessage>]
19
- /** Emitted when an HTTP proxy round-trip fails. */
20
16
  onProxyFailed: { from: string; to: string; error: unknown }
21
- /** Emitted when a WebSocket proxy upgrade fails. */
22
17
  onWebSocketProxyFailed: { from: string; to: string; error: unknown }
23
- /** Emitted when a WebSocket API action fails during lookup or execution. */
24
18
  onWebSocketActionFailed: { error: unknown; data?: Data; socket?: WebSocket }
25
- /** Emitted when a listener registered on this telemetry hub throws. */
26
19
  onListenerError: ListenerErrorPayload
27
20
  }
28
21
 
29
22
  /**
30
- * Telemetry surface exposed to applications. Extending {@link EventHub}
31
- * gives subscribers the same `addListener` / `subscribe` / `emit` API they
32
- * were using on the pre-migration managers.
23
+ * Application-facing telemetry surface. Extends {@link EventHub} so
24
+ * subscribers use the standard `addListener` / `subscribe` API.
33
25
  */
34
26
  export class ServerTelemetry extends EventHub<ServerTelemetryEvents> {}
35
27
 
@@ -0,0 +1,241 @@
1
+ import type { User } from '@furystack/core'
2
+ import { createInjector } from '@furystack/inject'
3
+ import { usingAsync } from '@furystack/utils'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { HttpAuthenticationSettings, defaultHttpAuthenticationSettings } from './http-authentication-settings.js'
6
+ import { resolveUserCacheKey, UserResolutionCache } from './user-resolution-cache.js'
7
+
8
+ const testUser: User = { username: 'alice', roles: ['reader'] }
9
+ const otherUser: User = { username: 'bob', roles: [] }
10
+
11
+ describe('UserResolutionCache', () => {
12
+ beforeEach(() => {
13
+ vi.useFakeTimers()
14
+ })
15
+
16
+ afterEach(() => {
17
+ vi.useRealTimers()
18
+ })
19
+
20
+ describe('resolve', () => {
21
+ it('returns the loader result on first call and reuses it on subsequent calls within TTL', async () => {
22
+ await usingAsync(createInjector(), async (i) => {
23
+ i.bind(HttpAuthenticationSettings, () => ({
24
+ ...defaultHttpAuthenticationSettings(),
25
+ userCacheTtlMs: 30_000,
26
+ }))
27
+ const cache = i.get(UserResolutionCache)
28
+ const loader = vi.fn(async () => testUser)
29
+ const first = await cache.resolve('cookie:abc', loader)
30
+ const second = await cache.resolve('cookie:abc', loader)
31
+ expect(first).toBe(testUser)
32
+ expect(second).toBe(testUser)
33
+ expect(loader).toHaveBeenCalledTimes(1)
34
+ })
35
+ })
36
+
37
+ it('treats distinct cache keys independently', async () => {
38
+ await usingAsync(createInjector(), async (i) => {
39
+ i.bind(HttpAuthenticationSettings, () => ({
40
+ ...defaultHttpAuthenticationSettings(),
41
+ userCacheTtlMs: 30_000,
42
+ }))
43
+ const cache = i.get(UserResolutionCache)
44
+ const loaderA = vi.fn(async () => testUser)
45
+ const loaderB = vi.fn(async () => otherUser)
46
+ expect(await cache.resolve('cookie:a', loaderA)).toBe(testUser)
47
+ expect(await cache.resolve('cookie:b', loaderB)).toBe(otherUser)
48
+ expect(await cache.resolve('cookie:a', loaderA)).toBe(testUser)
49
+ expect(loaderA).toHaveBeenCalledTimes(1)
50
+ expect(loaderB).toHaveBeenCalledTimes(1)
51
+ })
52
+ })
53
+
54
+ it('drops the entry once cacheTimeMs has elapsed', async () => {
55
+ await usingAsync(createInjector(), async (i) => {
56
+ i.bind(HttpAuthenticationSettings, () => ({
57
+ ...defaultHttpAuthenticationSettings(),
58
+ userCacheTtlMs: 100,
59
+ }))
60
+ const cache = i.get(UserResolutionCache)
61
+ const loader = vi.fn(async () => testUser)
62
+ await cache.resolve('cookie:ttl', loader)
63
+ await vi.advanceTimersByTimeAsync(150)
64
+ await cache.resolve('cookie:ttl', loader)
65
+ expect(loader).toHaveBeenCalledTimes(2)
66
+ })
67
+ })
68
+
69
+ it('runs the loader on every call when ttl is 0', async () => {
70
+ await usingAsync(createInjector(), async (i) => {
71
+ i.bind(HttpAuthenticationSettings, () => ({
72
+ ...defaultHttpAuthenticationSettings(),
73
+ userCacheTtlMs: 0,
74
+ }))
75
+ const cache = i.get(UserResolutionCache)
76
+ const loader = vi.fn(async () => testUser)
77
+ await cache.resolve('cookie:disabled', loader)
78
+ await cache.resolve('cookie:disabled', loader)
79
+ expect(loader).toHaveBeenCalledTimes(2)
80
+ expect(cache.size).toBe(0)
81
+ })
82
+ })
83
+
84
+ it('dedupes concurrent loaders for the same key', async () => {
85
+ await usingAsync(createInjector(), async (i) => {
86
+ i.bind(HttpAuthenticationSettings, () => ({
87
+ ...defaultHttpAuthenticationSettings(),
88
+ userCacheTtlMs: 30_000,
89
+ }))
90
+ const cache = i.get(UserResolutionCache)
91
+ const loader = vi.fn(
92
+ () =>
93
+ new Promise<User>((resolve) => {
94
+ setTimeout(() => resolve(testUser), 100)
95
+ }),
96
+ )
97
+ const both = Promise.all([cache.resolve('cookie:dedupe', loader), cache.resolve('cookie:dedupe', loader)])
98
+ await vi.advanceTimersByTimeAsync(150)
99
+ const [a, b] = await both
100
+ expect(a).toBe(testUser)
101
+ expect(b).toBe(testUser)
102
+ expect(loader).toHaveBeenCalledTimes(1)
103
+ })
104
+ })
105
+
106
+ it('rejects (without caching) when the loader throws', async () => {
107
+ await usingAsync(createInjector(), async (i) => {
108
+ i.bind(HttpAuthenticationSettings, () => ({
109
+ ...defaultHttpAuthenticationSettings(),
110
+ userCacheTtlMs: 30_000,
111
+ }))
112
+ const cache = i.get(UserResolutionCache)
113
+ const loader = vi
114
+ .fn<() => Promise<User>>()
115
+ .mockRejectedValueOnce(new Error('boom'))
116
+ .mockResolvedValueOnce(testUser)
117
+ await expect(cache.resolve('cookie:err', loader)).rejects.toThrow('boom')
118
+ await expect(cache.resolve('cookie:err', loader)).resolves.toBe(testUser)
119
+ expect(loader).toHaveBeenCalledTimes(2)
120
+ })
121
+ })
122
+ })
123
+
124
+ describe('invalidate', () => {
125
+ it('drops the targeted entry only', async () => {
126
+ await usingAsync(createInjector(), async (i) => {
127
+ i.bind(HttpAuthenticationSettings, () => ({
128
+ ...defaultHttpAuthenticationSettings(),
129
+ userCacheTtlMs: 30_000,
130
+ }))
131
+ const cache = i.get(UserResolutionCache)
132
+ const loaderA = vi.fn(async () => testUser)
133
+ const loaderB = vi.fn(async () => otherUser)
134
+ await cache.resolve('cookie:a', loaderA)
135
+ await cache.resolve('cookie:b', loaderB)
136
+ cache.invalidate('cookie:a')
137
+ await cache.resolve('cookie:a', loaderA)
138
+ await cache.resolve('cookie:b', loaderB)
139
+ expect(loaderA).toHaveBeenCalledTimes(2)
140
+ expect(loaderB).toHaveBeenCalledTimes(1)
141
+ })
142
+ })
143
+ })
144
+
145
+ describe('invalidateAll', () => {
146
+ it('flushes every entry', async () => {
147
+ await usingAsync(createInjector(), async (i) => {
148
+ i.bind(HttpAuthenticationSettings, () => ({
149
+ ...defaultHttpAuthenticationSettings(),
150
+ userCacheTtlMs: 30_000,
151
+ }))
152
+ const cache = i.get(UserResolutionCache)
153
+ await cache.resolve('cookie:a', async () => testUser)
154
+ await cache.resolve('cookie:b', async () => otherUser)
155
+ expect(cache.size).toBe(2)
156
+ cache.invalidateAll()
157
+ expect(cache.size).toBe(0)
158
+ })
159
+ })
160
+ })
161
+
162
+ describe('invalidateByUser', () => {
163
+ it('drops every cached session that resolved to the supplied username', async () => {
164
+ await usingAsync(createInjector(), async (i) => {
165
+ i.bind(HttpAuthenticationSettings, () => ({
166
+ ...defaultHttpAuthenticationSettings(),
167
+ userCacheTtlMs: 30_000,
168
+ }))
169
+ const cache = i.get(UserResolutionCache)
170
+ const aliceLoaderA = vi.fn(async () => testUser)
171
+ const aliceLoaderB = vi.fn(async () => testUser)
172
+ const bobLoader = vi.fn(async () => otherUser)
173
+ await cache.resolve('cookie:alice-1', aliceLoaderA)
174
+ await cache.resolve('cookie:alice-2', aliceLoaderB)
175
+ await cache.resolve('cookie:bob-1', bobLoader)
176
+ expect(cache.size).toBe(3)
177
+
178
+ cache.invalidateByUser('alice')
179
+
180
+ expect(cache.size).toBe(1)
181
+ await cache.resolve('cookie:alice-1', aliceLoaderA)
182
+ await cache.resolve('cookie:alice-2', aliceLoaderB)
183
+ await cache.resolve('cookie:bob-1', bobLoader)
184
+ expect(aliceLoaderA).toHaveBeenCalledTimes(2)
185
+ expect(aliceLoaderB).toHaveBeenCalledTimes(2)
186
+ expect(bobLoader).toHaveBeenCalledTimes(1)
187
+ })
188
+ })
189
+
190
+ it('is a no-op when the username is unknown', async () => {
191
+ await usingAsync(createInjector(), async (i) => {
192
+ i.bind(HttpAuthenticationSettings, () => ({
193
+ ...defaultHttpAuthenticationSettings(),
194
+ userCacheTtlMs: 30_000,
195
+ }))
196
+ const cache = i.get(UserResolutionCache)
197
+ await cache.resolve('cookie:alice-1', async () => testUser)
198
+ await cache.resolve('cookie:bob-1', async () => otherUser)
199
+ cache.invalidateByUser('charlie')
200
+ expect(cache.size).toBe(2)
201
+ })
202
+ })
203
+
204
+ it('is a no-op when caching is disabled (ttl = 0)', async () => {
205
+ await usingAsync(createInjector(), async (i) => {
206
+ i.bind(HttpAuthenticationSettings, () => ({
207
+ ...defaultHttpAuthenticationSettings(),
208
+ userCacheTtlMs: 0,
209
+ }))
210
+ const cache = i.get(UserResolutionCache)
211
+ expect(() => cache.invalidateByUser('alice')).not.toThrow()
212
+ expect(cache.size).toBe(0)
213
+ })
214
+ })
215
+ })
216
+ })
217
+
218
+ describe('resolveUserCacheKey', () => {
219
+ it('returns the first non-null key from the configured providers', () => {
220
+ const settings = {
221
+ ...defaultHttpAuthenticationSettings(),
222
+ authenticationProviders: [
223
+ { name: 'first', authenticate: async () => null, getCacheKey: () => null },
224
+ { name: 'second', authenticate: async () => null, getCacheKey: () => 'cookie:matched' },
225
+ { name: 'third', authenticate: async () => null, getCacheKey: () => 'jwt:not-reached' },
226
+ ],
227
+ }
228
+ expect(resolveUserCacheKey(settings, { headers: {} })).toBe('cookie:matched')
229
+ })
230
+
231
+ it('returns null when every provider opts out', () => {
232
+ const settings = {
233
+ ...defaultHttpAuthenticationSettings(),
234
+ authenticationProviders: [
235
+ { name: 'a', authenticate: async () => null },
236
+ { name: 'b', authenticate: async () => null, getCacheKey: () => null },
237
+ ],
238
+ }
239
+ expect(resolveUserCacheKey(settings, { headers: {} })).toBeNull()
240
+ })
241
+ })
@@ -0,0 +1,164 @@
1
+ import type { User } from '@furystack/core'
2
+ import { Cache } from '@furystack/cache'
3
+ import { defineService, type Token } from '@furystack/inject'
4
+ import type { IncomingMessage } from 'http'
5
+ import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
6
+ import {
7
+ DEFAULT_USER_CACHE_CAPACITY,
8
+ DEFAULT_USER_CACHE_TTL_MS,
9
+ HttpAuthenticationSettings,
10
+ } from './http-authentication-settings.js'
11
+ import type { HttpUserContext } from './http-user-context.js'
12
+ import { userCacheTag, type UserCacheTag } from './identity-cache-keys.js'
13
+
14
+ /**
15
+ * Singleton TTL cache that memoizes successful identity resolutions across
16
+ * requests within a single process.
17
+ *
18
+ * Cache keys are produced by the configured {@link AuthenticationProvider}s
19
+ * via their `getCacheKey` method (e.g. cookie-auth keys by session id). The
20
+ * cache's `load` callback runs the same provider chain that
21
+ * {@link HttpUserContext.authenticateRequest} would run — it is supplied via
22
+ * an internal "loader" registered when the {@link HttpUserContext} is
23
+ * resolved.
24
+ *
25
+ * Entries expire after `HttpAuthenticationSettings.userCacheTtlMs`; capacity
26
+ * is bounded by `HttpAuthenticationSettings.userCacheCapacity`. Setting the
27
+ * TTL to `0` disables caching (the loader runs on every call).
28
+ *
29
+ * **Multi-node note:** session invalidation on one instance does not
30
+ * propagate to siblings — the TTL bounds the staleness window. Apps that
31
+ * need stricter freshness must shorten the TTL or invalidate explicitly via
32
+ * {@link UserResolutionCache.invalidate} /
33
+ * {@link UserResolutionCache.invalidateByUser}.
34
+ */
35
+ export interface UserResolutionCache extends Disposable {
36
+ /**
37
+ * Returns the cached or freshly-resolved `User` for the supplied
38
+ * cache key. The `loader` is invoked on cache miss; concurrent calls for
39
+ * the same key dedupe to a single loader invocation.
40
+ */
41
+ resolve(cacheKey: string, loader: () => Promise<User>): Promise<User>
42
+ /** Drops a single cache entry. No-op when the key is unknown. */
43
+ invalidate(cacheKey: string): void
44
+ /**
45
+ * Drops every cached entry that resolved to `username`, regardless of
46
+ * which {@link AuthenticationProvider}'s key originally populated it.
47
+ * Backed by the cache's reverse tag index — no per-call O(n) scan.
48
+ *
49
+ * Use after role / password / account-state changes that must take
50
+ * effect immediately on the next request, instead of waiting for the
51
+ * TTL window to elapse.
52
+ */
53
+ invalidateByUser(username: string): void
54
+ /** Drops every cached entry. */
55
+ invalidateAll(): void
56
+ /** Returns the current entry count (for diagnostics / tests). */
57
+ readonly size: number
58
+ }
59
+
60
+ class UserResolutionCacheImpl implements UserResolutionCache {
61
+ private readonly loaders = new Map<string, () => Promise<User>>()
62
+ private readonly cache: Cache<User, [string], UserCacheTag> | null
63
+
64
+ constructor(ttlMs: number, capacity: number) {
65
+ if (ttlMs <= 0) {
66
+ this.cache = null
67
+ return
68
+ }
69
+ this.cache = new Cache<User, [string], UserCacheTag>({
70
+ load: (cacheKey) => {
71
+ const loader = this.loaders.get(cacheKey)
72
+ if (!loader) {
73
+ // The loader is only registered for the duration of a `resolve()`
74
+ // call. A reload triggered via `cache.get(key)` outside that call
75
+ // (e.g. from `getObservable`) would have no loader; refuse the
76
+ // load instead of caching `null`.
77
+ return Promise.reject(new Error(`No loader registered for cache key '${cacheKey}'`))
78
+ }
79
+ return loader()
80
+ },
81
+ cacheTimeMs: ttlMs,
82
+ capacity,
83
+ getKey: (cacheKey) => cacheKey,
84
+ getTags: (user) => [userCacheTag(user.username)],
85
+ })
86
+ }
87
+
88
+ public async resolve(cacheKey: string, loader: () => Promise<User>): Promise<User> {
89
+ if (!this.cache) {
90
+ return loader()
91
+ }
92
+ this.loaders.set(cacheKey, loader)
93
+ try {
94
+ return await this.cache.get(cacheKey)
95
+ } finally {
96
+ // Loader is per-request; clearing prevents the cache from re-running it
97
+ // out-of-band when the entry transitions to obsolete and a sibling call
98
+ // queries `getObservable`.
99
+ this.loaders.delete(cacheKey)
100
+ }
101
+ }
102
+
103
+ public invalidate(cacheKey: string): void {
104
+ this.cache?.remove(cacheKey)
105
+ }
106
+
107
+ public invalidateByUser(username: string): void {
108
+ this.cache?.removeByTag(userCacheTag(username))
109
+ }
110
+
111
+ public invalidateAll(): void {
112
+ this.cache?.flushAll()
113
+ }
114
+
115
+ public get size(): number {
116
+ return this.cache?.getCount() ?? 0
117
+ }
118
+
119
+ public [Symbol.dispose](): void {
120
+ this.cache?.[Symbol.dispose]()
121
+ this.loaders.clear()
122
+ }
123
+ }
124
+
125
+ /**
126
+ * DI token for the singleton {@link UserResolutionCache}. Resolved lazily
127
+ * by {@link HttpUserContext}; sized from {@link HttpAuthenticationSettings}.
128
+ */
129
+ export const UserResolutionCache: Token<UserResolutionCache, 'singleton'> = defineService({
130
+ name: 'furystack/rest-service/UserResolutionCache',
131
+ lifetime: 'singleton',
132
+ factory: ({ inject, onDispose }): UserResolutionCache => {
133
+ const settings = inject(HttpAuthenticationSettings)
134
+ const ttlMs = settings.userCacheTtlMs ?? DEFAULT_USER_CACHE_TTL_MS
135
+ const capacity = settings.userCacheCapacity ?? DEFAULT_USER_CACHE_CAPACITY
136
+ const impl = new UserResolutionCacheImpl(ttlMs, capacity)
137
+ onDispose(() => {
138
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- onDispose is the teardown hook
139
+ impl[Symbol.dispose]()
140
+ })
141
+ return impl
142
+ },
143
+ })
144
+
145
+ /**
146
+ * Resolves the configured cache key for the supplied request by walking the
147
+ * authentication providers in order and returning the first non-null key.
148
+ *
149
+ * Returns `null` when no provider opts in to caching (e.g. Basic Auth, or
150
+ * an anonymous request). The caller should bypass the cache in that case.
151
+ *
152
+ * Exported so plugins / tests can derive the same key the cache uses.
153
+ */
154
+ export const resolveUserCacheKey = (
155
+ settings: HttpAuthenticationSettings,
156
+ request: Pick<IncomingMessage, 'headers'>,
157
+ ): string | null => {
158
+ for (const provider of settings.authenticationProviders) {
159
+ if (!provider.getCacheKey) continue
160
+ const key = provider.getCacheKey(request)
161
+ if (key) return key
162
+ }
163
+ return null
164
+ }
package/src/user-store.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { defineStore, type StoreToken, User } from '@furystack/core'
2
2
  import { defineDataSet, type DataSetToken } from '@furystack/repository'
3
+ import type { HttpUserContext } from './http-user-context.js'
3
4
  import { DefaultSession } from './models/default-session.js'
4
5
 
5
6
  /**
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2
+ /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion -- false positives: `client` is typed for a narrow `RestApi`; casts to `ReturnType<typeof createClient<...>>` widen it so this test can exercise endpoints (e.g. `/openapi.json`) the production type intentionally hides */
2
3
  import { InMemoryStore, User as UserModel } from '@furystack/core'
3
4
  import { getPort } from '@furystack/core/port-generator'
4
5
  import { createInjector } from '@furystack/inject'
package/src/validate.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import type { ActionResult, RequestAction, RequestActionOptions } from './request-action-implementation.js'
2
+ import type { SchemaValidationError } from './schema-validator/schema-validation-error.js'
2
3
  import { SchemaValidator } from './schema-validator/schema-validator.js'
3
4
 
4
- /**
5
- * Represents a JSON Schema definition structure
6
- */
7
5
  type JsonSchemaDefinition = {
8
6
  required?: string[]
9
7
  additionalProperties?: boolean
@@ -17,22 +15,26 @@ type JsonSchemaDefinition = {
17
15
  [key: string]: unknown
18
16
  }
19
17
 
20
- /**
21
- * Represents a JSON Schema with definitions
22
- */
23
18
  type JsonSchemaWithDefinitions = {
24
19
  definitions: Record<string, JsonSchemaDefinition>
25
20
  }
26
21
 
22
+ /**
23
+ * Wraps a {@link RequestAction} with JSON Schema validation. The wrapper
24
+ * pre-validates `query`, `body`, `url` and `headers` against the named
25
+ * schema entry, throws {@link SchemaValidationError} on failure (caught by
26
+ * `ErrorAction` to return 400), then forwards the validated values to the
27
+ * inner action.
28
+ *
29
+ * The wrapped function exposes `schema` and `schemaName` so OpenAPI
30
+ * generators can reflect on the action without re-parsing the source.
31
+ *
32
+ * `furystack/rest-action-validate-wrapper` enforces this on REST actions.
33
+ */
27
34
  export const Validate =
28
35
  <TSchema extends JsonSchemaWithDefinitions>(validationOptions: {
29
- /**
30
- * The Schema object
31
- */
32
36
  schema: TSchema
33
- /**
34
- * Entity key from the JSON Schema object
35
- */
37
+ /** Entry under `schema.definitions` describing this endpoint's request shape. */
36
38
  schemaName: keyof TSchema['definitions']
37
39
  }) =>
38
40
  <T extends { result: unknown }>(