@furystack/rest-service 14.0.0 → 14.1.1
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 +118 -0
- 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/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/logout.d.ts +2 -6
- package/esm/actions/logout.d.ts.map +1 -1
- package/esm/actions/logout.js +2 -6
- 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 +5 -12
- package/esm/actions/password-login-action.js.map +1 -1
- package/esm/authenticate.d.ts +5 -11
- package/esm/authenticate.d.ts.map +1 -1
- package/esm/authenticate.js +5 -11
- package/esm/authenticate.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/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/helpers.d.ts +6 -6
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +3 -4
- package/esm/helpers.js.map +1 -1
- package/esm/http-authentication-settings.d.ts +27 -6
- package/esm/http-authentication-settings.d.ts.map +1 -1
- package/esm/http-authentication-settings.js +6 -0
- package/esm/http-authentication-settings.js.map +1 -1
- package/esm/http-server-pool.spec.js.map +1 -1
- package/esm/http-user-context.d.ts +8 -5
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +26 -27
- package/esm/http-user-context.js.map +1 -1
- package/esm/http-user-context.spec.js +48 -6
- 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 +3 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +3 -0
- package/esm/index.js.map +1 -1
- package/esm/login-response-strategy.d.ts.map +1 -1
- package/esm/login-response-strategy.js.map +1 -1
- package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
- package/esm/proxy-manager.spec.js.map +1 -1
- 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.spec.js.map +1 -1
- package/esm/server-telemetry.d.ts +6 -14
- package/esm/server-telemetry.d.ts.map +1 -1
- package/esm/server-telemetry.js +2 -3
- package/esm/server-telemetry.js.map +1 -1
- 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.map +1 -1
- package/esm/user-store.js.map +1 -1
- package/esm/validate.d.ts +13 -12
- package/esm/validate.d.ts.map +1 -1
- package/esm/validate.integration.spec.js +1 -0
- 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 +15 -13
- package/src/actions/error-action.ts +7 -3
- package/src/actions/get-current-user.ts +1 -4
- package/src/actions/is-authenticated.ts +3 -4
- package/src/actions/logout.ts +2 -6
- package/src/actions/not-found-action.ts +1 -3
- package/src/actions/password-login-action.ts +5 -12
- package/src/authenticate.ts +5 -11
- 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.ts +6 -17
- package/src/get-schema-from-api.ts +1 -0
- package/src/helpers.ts +11 -11
- package/src/http-authentication-settings.ts +34 -6
- package/src/http-server-pool.spec.ts +1 -1
- package/src/http-user-context.spec.ts +59 -11
- package/src/http-user-context.ts +38 -31
- 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 +3 -0
- package/src/login-response-strategy.ts +1 -0
- package/src/openapi/generate-openapi-document.spec.ts +2 -2
- package/src/proxy-manager.spec.ts +6 -6
- package/src/request-action-implementation.ts +25 -0
- package/src/rest-api-runtime.spec.ts +1 -1
- package/src/server-telemetry.ts +6 -14
- package/src/user-resolution-cache.spec.ts +241 -0
- package/src/user-resolution-cache.ts +164 -0
- package/src/user-store.ts +1 -0
- package/src/validate.integration.spec.ts +1 -0
- 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:
|
|
18
|
+
'/items/:id': async ({ getUrlParams }) => JsonResult({ id: getUrlParams().id }),
|
|
19
19
|
},
|
|
20
20
|
POST: {
|
|
21
21
|
'/items': async ({ getBody }) => JsonResult(await getBody()),
|
package/src/server-telemetry.ts
CHANGED
|
@@ -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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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 }>(
|