@furystack/rest-service 13.0.0 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +47 -28
  3. package/esm/actions/error-action.d.ts +7 -2
  4. package/esm/actions/error-action.d.ts.map +1 -1
  5. package/esm/actions/error-action.js +7 -2
  6. package/esm/actions/error-action.js.map +1 -1
  7. package/esm/actions/get-current-user.d.ts +1 -4
  8. package/esm/actions/get-current-user.d.ts.map +1 -1
  9. package/esm/actions/get-current-user.js +1 -4
  10. package/esm/actions/get-current-user.js.map +1 -1
  11. package/esm/actions/get-current-user.spec.js +10 -6
  12. package/esm/actions/get-current-user.spec.js.map +1 -1
  13. package/esm/actions/is-authenticated.d.ts +3 -4
  14. package/esm/actions/is-authenticated.d.ts.map +1 -1
  15. package/esm/actions/is-authenticated.js +3 -4
  16. package/esm/actions/is-authenticated.js.map +1 -1
  17. package/esm/actions/is-authenticated.spec.js +10 -6
  18. package/esm/actions/is-authenticated.spec.js.map +1 -1
  19. package/esm/actions/logout-action.spec.js +11 -9
  20. package/esm/actions/logout-action.spec.js.map +1 -1
  21. package/esm/actions/logout.d.ts +2 -6
  22. package/esm/actions/logout.d.ts.map +1 -1
  23. package/esm/actions/logout.js +3 -7
  24. package/esm/actions/logout.js.map +1 -1
  25. package/esm/actions/not-found-action.d.ts +1 -3
  26. package/esm/actions/not-found-action.d.ts.map +1 -1
  27. package/esm/actions/not-found-action.js +1 -3
  28. package/esm/actions/not-found-action.js.map +1 -1
  29. package/esm/actions/password-login-action.d.ts +5 -12
  30. package/esm/actions/password-login-action.d.ts.map +1 -1
  31. package/esm/actions/password-login-action.js +6 -13
  32. package/esm/actions/password-login-action.js.map +1 -1
  33. package/esm/actions/password-login-action.spec.js +23 -28
  34. package/esm/actions/password-login-action.spec.js.map +1 -1
  35. package/esm/authenticate.d.ts +5 -11
  36. package/esm/authenticate.d.ts.map +1 -1
  37. package/esm/authenticate.js +6 -12
  38. package/esm/authenticate.js.map +1 -1
  39. package/esm/authenticate.spec.js +41 -33
  40. package/esm/authenticate.spec.js.map +1 -1
  41. package/esm/authentication-providers/authentication-provider.d.ts +17 -0
  42. package/esm/authentication-providers/authentication-provider.d.ts.map +1 -1
  43. package/esm/authentication-providers/basic-auth-provider.d.ts +7 -0
  44. package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -1
  45. package/esm/authentication-providers/basic-auth-provider.js +7 -0
  46. package/esm/authentication-providers/basic-auth-provider.js.map +1 -1
  47. package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -1
  48. package/esm/authentication-providers/cookie-auth-provider.js +8 -0
  49. package/esm/authentication-providers/cookie-auth-provider.js.map +1 -1
  50. package/esm/authorize.d.ts +5 -17
  51. package/esm/authorize.d.ts.map +1 -1
  52. package/esm/authorize.js +5 -17
  53. package/esm/authorize.js.map +1 -1
  54. package/esm/authorize.spec.js +34 -29
  55. package/esm/authorize.spec.js.map +1 -1
  56. package/esm/endpoint-generators/create-delete-endpoint.d.ts +6 -11
  57. package/esm/endpoint-generators/create-delete-endpoint.d.ts.map +1 -1
  58. package/esm/endpoint-generators/create-delete-endpoint.js +7 -11
  59. package/esm/endpoint-generators/create-delete-endpoint.js.map +1 -1
  60. package/esm/endpoint-generators/create-delete-endpoint.spec.js +14 -21
  61. package/esm/endpoint-generators/create-delete-endpoint.spec.js.map +1 -1
  62. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts +11 -11
  63. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts.map +1 -1
  64. package/esm/endpoint-generators/create-get-collection-endpoint.js +13 -12
  65. package/esm/endpoint-generators/create-get-collection-endpoint.js.map +1 -1
  66. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js +43 -70
  67. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js.map +1 -1
  68. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts +6 -11
  69. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts.map +1 -1
  70. package/esm/endpoint-generators/create-get-entity-endpoint.js +7 -11
  71. package/esm/endpoint-generators/create-get-entity-endpoint.js.map +1 -1
  72. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js +16 -33
  73. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js.map +1 -1
  74. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +1 -1
  75. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -1
  76. package/esm/endpoint-generators/create-get-openapi-document-action.js +1 -1
  77. package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -1
  78. package/esm/endpoint-generators/create-get-schema-action.d.ts +1 -1
  79. package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -1
  80. package/esm/endpoint-generators/create-get-schema-action.js.map +1 -1
  81. package/esm/endpoint-generators/create-patch-endpoint.d.ts +7 -13
  82. package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
  83. package/esm/endpoint-generators/create-patch-endpoint.js +8 -13
  84. package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
  85. package/esm/endpoint-generators/create-patch-endpoint.spec.js +10 -17
  86. package/esm/endpoint-generators/create-patch-endpoint.spec.js.map +1 -1
  87. package/esm/endpoint-generators/create-post-endpoint.d.ts +7 -13
  88. package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
  89. package/esm/endpoint-generators/create-post-endpoint.js +8 -13
  90. package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
  91. package/esm/endpoint-generators/create-post-endpoint.spec.js +41 -14
  92. package/esm/endpoint-generators/create-post-endpoint.spec.js.map +1 -1
  93. package/esm/endpoint-generators/utils.d.ts +17 -2
  94. package/esm/endpoint-generators/utils.d.ts.map +1 -1
  95. package/esm/endpoint-generators/utils.js +33 -20
  96. package/esm/endpoint-generators/utils.js.map +1 -1
  97. package/esm/get-schema-from-api.d.ts +1 -1
  98. package/esm/get-schema-from-api.d.ts.map +1 -1
  99. package/esm/get-schema-from-api.js +1 -0
  100. package/esm/get-schema-from-api.js.map +1 -1
  101. package/esm/get-schema-from-api.spec.js.map +1 -1
  102. package/esm/helpers.d.ts +54 -88
  103. package/esm/helpers.d.ts.map +1 -1
  104. package/esm/helpers.js +90 -92
  105. package/esm/helpers.js.map +1 -1
  106. package/esm/helpers.spec.d.ts +2 -1
  107. package/esm/helpers.spec.d.ts.map +1 -1
  108. package/esm/helpers.spec.js +41 -38
  109. package/esm/helpers.spec.js.map +1 -1
  110. package/esm/http-authentication-settings.d.ts +56 -12
  111. package/esm/http-authentication-settings.d.ts.map +1 -1
  112. package/esm/http-authentication-settings.js +27 -29
  113. package/esm/http-authentication-settings.js.map +1 -1
  114. package/esm/http-server-pool.d.ts +64 -0
  115. package/esm/http-server-pool.d.ts.map +1 -0
  116. package/esm/http-server-pool.js +95 -0
  117. package/esm/http-server-pool.js.map +1 -0
  118. package/esm/http-server-pool.spec.d.ts +2 -0
  119. package/esm/http-server-pool.spec.d.ts.map +1 -0
  120. package/esm/http-server-pool.spec.js +193 -0
  121. package/esm/http-server-pool.spec.js.map +1 -0
  122. package/esm/http-user-context.d.ts +34 -44
  123. package/esm/http-user-context.d.ts.map +1 -1
  124. package/esm/http-user-context.js +75 -95
  125. package/esm/http-user-context.js.map +1 -1
  126. package/esm/http-user-context.spec.d.ts +8 -2
  127. package/esm/http-user-context.spec.d.ts.map +1 -1
  128. package/esm/http-user-context.spec.js +226 -332
  129. package/esm/http-user-context.spec.js.map +1 -1
  130. package/esm/identity-cache-keys.d.ts +28 -0
  131. package/esm/identity-cache-keys.d.ts.map +1 -0
  132. package/esm/identity-cache-keys.js +13 -0
  133. package/esm/identity-cache-keys.js.map +1 -0
  134. package/esm/identity-cache-keys.spec.d.ts +2 -0
  135. package/esm/identity-cache-keys.spec.d.ts.map +1 -0
  136. package/esm/identity-cache-keys.spec.js +21 -0
  137. package/esm/identity-cache-keys.spec.js.map +1 -0
  138. package/esm/identity-event-bus.d.ts +102 -0
  139. package/esm/identity-event-bus.d.ts.map +1 -0
  140. package/esm/identity-event-bus.js +151 -0
  141. package/esm/identity-event-bus.js.map +1 -0
  142. package/esm/identity-event-bus.spec.d.ts +2 -0
  143. package/esm/identity-event-bus.spec.d.ts.map +1 -0
  144. package/esm/identity-event-bus.spec.js +392 -0
  145. package/esm/identity-event-bus.spec.js.map +1 -0
  146. package/esm/index.d.ts +9 -4
  147. package/esm/index.d.ts.map +1 -1
  148. package/esm/index.js +9 -4
  149. package/esm/index.js.map +1 -1
  150. package/esm/login-response-strategy.d.ts +11 -10
  151. package/esm/login-response-strategy.d.ts.map +1 -1
  152. package/esm/login-response-strategy.js +7 -6
  153. package/esm/login-response-strategy.js.map +1 -1
  154. package/esm/login-response-strategy.spec.js +25 -29
  155. package/esm/login-response-strategy.spec.js.map +1 -1
  156. package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
  157. package/esm/proxy-manager.spec.js +152 -176
  158. package/esm/proxy-manager.spec.js.map +1 -1
  159. package/esm/proxy-runtime.d.ts +27 -0
  160. package/esm/proxy-runtime.d.ts.map +1 -0
  161. package/esm/proxy-runtime.js +45 -0
  162. package/esm/proxy-runtime.js.map +1 -0
  163. package/esm/proxy-runtime.spec.d.ts +2 -0
  164. package/esm/proxy-runtime.spec.d.ts.map +1 -0
  165. package/esm/proxy-runtime.spec.js +39 -0
  166. package/esm/proxy-runtime.spec.js.map +1 -0
  167. package/esm/request-action-implementation.d.ts +25 -0
  168. package/esm/request-action-implementation.d.ts.map +1 -1
  169. package/esm/request-action-implementation.js +9 -0
  170. package/esm/request-action-implementation.js.map +1 -1
  171. package/esm/rest-api-runtime.d.ts +76 -0
  172. package/esm/rest-api-runtime.d.ts.map +1 -0
  173. package/esm/rest-api-runtime.js +131 -0
  174. package/esm/rest-api-runtime.js.map +1 -0
  175. package/esm/rest-api-runtime.spec.d.ts +2 -0
  176. package/esm/rest-api-runtime.spec.d.ts.map +1 -0
  177. package/esm/rest-api-runtime.spec.js +58 -0
  178. package/esm/rest-api-runtime.spec.js.map +1 -0
  179. package/esm/rest-service.integration.spec.d.ts +1 -1
  180. package/esm/rest-service.integration.spec.d.ts.map +1 -1
  181. package/esm/rest-service.integration.spec.js +57 -80
  182. package/esm/rest-service.integration.spec.js.map +1 -1
  183. package/esm/rest.integration.test.js +26 -84
  184. package/esm/rest.integration.test.js.map +1 -1
  185. package/esm/server-telemetry.d.ts +49 -0
  186. package/esm/server-telemetry.d.ts.map +1 -0
  187. package/esm/server-telemetry.js +25 -0
  188. package/esm/server-telemetry.js.map +1 -0
  189. package/esm/server-telemetry.spec.d.ts +2 -0
  190. package/esm/server-telemetry.spec.d.ts.map +1 -0
  191. package/esm/server-telemetry.spec.js +52 -0
  192. package/esm/server-telemetry.spec.js.map +1 -0
  193. package/esm/static-server-manager.spec.js +60 -125
  194. package/esm/static-server-manager.spec.js.map +1 -1
  195. package/esm/static-site-runtime.d.ts +21 -0
  196. package/esm/static-site-runtime.d.ts.map +1 -0
  197. package/esm/static-site-runtime.js +48 -0
  198. package/esm/static-site-runtime.js.map +1 -0
  199. package/esm/static-site-runtime.spec.d.ts +2 -0
  200. package/esm/static-site-runtime.spec.d.ts.map +1 -0
  201. package/esm/static-site-runtime.spec.js +30 -0
  202. package/esm/static-site-runtime.spec.js.map +1 -0
  203. package/esm/user-resolution-cache.d.ts +65 -0
  204. package/esm/user-resolution-cache.d.ts.map +1 -0
  205. package/esm/user-resolution-cache.js +101 -0
  206. package/esm/user-resolution-cache.js.map +1 -0
  207. package/esm/user-resolution-cache.spec.d.ts +2 -0
  208. package/esm/user-resolution-cache.spec.d.ts.map +1 -0
  209. package/esm/user-resolution-cache.spec.js +220 -0
  210. package/esm/user-resolution-cache.spec.js.map +1 -0
  211. package/esm/user-store.d.ts +39 -0
  212. package/esm/user-store.d.ts.map +1 -0
  213. package/esm/user-store.js +62 -0
  214. package/esm/user-store.js.map +1 -0
  215. package/esm/user-store.spec.d.ts +2 -0
  216. package/esm/user-store.spec.d.ts.map +1 -0
  217. package/esm/user-store.spec.js +72 -0
  218. package/esm/user-store.spec.js.map +1 -0
  219. package/esm/validate.d.ts +13 -12
  220. package/esm/validate.d.ts.map +1 -1
  221. package/esm/validate.integration.spec.js +27 -28
  222. package/esm/validate.integration.spec.js.map +1 -1
  223. package/esm/validate.js +12 -0
  224. package/esm/validate.js.map +1 -1
  225. package/package.json +14 -12
  226. package/src/actions/error-action.ts +7 -3
  227. package/src/actions/get-current-user.spec.ts +11 -10
  228. package/src/actions/get-current-user.ts +1 -4
  229. package/src/actions/is-authenticated.spec.ts +12 -7
  230. package/src/actions/is-authenticated.ts +3 -4
  231. package/src/actions/logout-action.spec.ts +12 -12
  232. package/src/actions/logout.ts +3 -7
  233. package/src/actions/not-found-action.ts +1 -3
  234. package/src/actions/password-login-action.spec.ts +30 -30
  235. package/src/actions/password-login-action.ts +6 -13
  236. package/src/authenticate.spec.ts +45 -51
  237. package/src/authenticate.ts +6 -12
  238. package/src/authentication-providers/authentication-provider.ts +17 -0
  239. package/src/authentication-providers/basic-auth-provider.ts +7 -0
  240. package/src/authentication-providers/cookie-auth-provider.ts +8 -0
  241. package/src/authorize.spec.ts +37 -39
  242. package/src/authorize.ts +6 -17
  243. package/src/endpoint-generators/create-delete-endpoint.spec.ts +16 -24
  244. package/src/endpoint-generators/create-delete-endpoint.ts +11 -17
  245. package/src/endpoint-generators/create-get-collection-endpoint.spec.ts +45 -78
  246. package/src/endpoint-generators/create-get-collection-endpoint.ts +17 -19
  247. package/src/endpoint-generators/create-get-entity-endpoint.spec.ts +17 -33
  248. package/src/endpoint-generators/create-get-entity-endpoint.ts +11 -17
  249. package/src/endpoint-generators/create-get-openapi-document-action.ts +2 -2
  250. package/src/endpoint-generators/create-get-schema-action.ts +1 -1
  251. package/src/endpoint-generators/create-patch-endpoint.spec.ts +11 -18
  252. package/src/endpoint-generators/create-patch-endpoint.ts +12 -23
  253. package/src/endpoint-generators/create-post-endpoint.spec.ts +45 -14
  254. package/src/endpoint-generators/create-post-endpoint.ts +13 -23
  255. package/src/endpoint-generators/utils.ts +36 -28
  256. package/src/get-schema-from-api.spec.ts +1 -1
  257. package/src/get-schema-from-api.ts +2 -1
  258. package/src/helpers.spec.ts +51 -41
  259. package/src/helpers.ts +140 -109
  260. package/src/http-authentication-settings.ts +78 -24
  261. package/src/http-server-pool.spec.ts +220 -0
  262. package/src/http-server-pool.ts +169 -0
  263. package/src/http-user-context.spec.ts +246 -378
  264. package/src/http-user-context.ts +122 -92
  265. package/src/identity-cache-keys.spec.ts +24 -0
  266. package/src/identity-cache-keys.ts +31 -0
  267. package/src/identity-event-bus.spec.ts +285 -0
  268. package/src/identity-event-bus.ts +230 -0
  269. package/src/index.ts +9 -4
  270. package/src/login-response-strategy.spec.ts +32 -31
  271. package/src/login-response-strategy.ts +14 -12
  272. package/src/openapi/generate-openapi-document.spec.ts +2 -2
  273. package/src/proxy-manager.spec.ts +158 -188
  274. package/src/proxy-runtime.spec.ts +50 -0
  275. package/src/proxy-runtime.ts +75 -0
  276. package/src/request-action-implementation.ts +25 -0
  277. package/src/rest-api-runtime.spec.ts +77 -0
  278. package/src/rest-api-runtime.ts +204 -0
  279. package/src/rest-service.integration.spec.ts +65 -85
  280. package/src/rest.integration.test.ts +27 -87
  281. package/src/server-telemetry.spec.ts +56 -0
  282. package/src/server-telemetry.ts +43 -0
  283. package/src/static-server-manager.spec.ts +63 -151
  284. package/src/static-site-runtime.spec.ts +39 -0
  285. package/src/static-site-runtime.ts +75 -0
  286. package/src/user-resolution-cache.spec.ts +241 -0
  287. package/src/user-resolution-cache.ts +164 -0
  288. package/src/user-store.spec.ts +86 -0
  289. package/src/user-store.ts +69 -0
  290. package/src/validate.integration.spec.ts +40 -35
  291. package/src/validate.ts +14 -12
  292. package/esm/api-manager.d.ts +0 -104
  293. package/esm/api-manager.d.ts.map +0 -1
  294. package/esm/api-manager.js +0 -179
  295. package/esm/api-manager.js.map +0 -1
  296. package/esm/proxy-manager.d.ts +0 -53
  297. package/esm/proxy-manager.d.ts.map +0 -1
  298. package/esm/proxy-manager.js +0 -84
  299. package/esm/proxy-manager.js.map +0 -1
  300. package/esm/server-manager.d.ts +0 -48
  301. package/esm/server-manager.d.ts.map +0 -1
  302. package/esm/server-manager.js +0 -100
  303. package/esm/server-manager.js.map +0 -1
  304. package/esm/static-server-manager.d.ts +0 -19
  305. package/esm/static-server-manager.d.ts.map +0 -1
  306. package/esm/static-server-manager.js +0 -65
  307. package/esm/static-server-manager.js.map +0 -1
  308. package/src/api-manager.ts +0 -304
  309. package/src/proxy-manager.ts +0 -102
  310. package/src/server-manager.ts +0 -135
  311. package/src/static-server-manager.ts +0 -90
@@ -4,10 +4,7 @@ import { Authenticate } from '../authenticate.js'
4
4
  import type { RequestAction } from '../request-action-implementation.js'
5
5
  import { JsonResult } from '../request-action-implementation.js'
6
6
 
7
- /**
8
- * Action that returns the current authenticated user
9
- * @param injector The injector from the current stack
10
- */
7
+ /** Returns the current authenticated user; 401 if no user is bound. */
11
8
  export const GetCurrentUser: RequestAction<{
12
9
  result: User
13
10
  }> = Authenticate()(async ({ injector }) => {
@@ -1,16 +1,21 @@
1
- import { Injector } from '@furystack/inject'
1
+ import { IdentityContext } from '@furystack/core'
2
+ import { createInjector } from '@furystack/inject'
2
3
  import { usingAsync } from '@furystack/utils'
3
- import { IsAuthenticated } from './is-authenticated.js'
4
4
  import type { IncomingMessage, ServerResponse } from 'http'
5
- import { IdentityContext } from '@furystack/core'
6
- import { describe, it, expect } from 'vitest'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { IsAuthenticated } from './is-authenticated.js'
7
7
 
8
8
  describe('isAuthenticated', () => {
9
9
  const request = { url: 'https://google.com' } as IncomingMessage
10
10
  const response = {} as ServerResponse
11
- it('exec', async () => {
12
- await usingAsync(new Injector(), async (i) => {
13
- i.setExplicitInstance({ isAuthenticated: async () => true }, IdentityContext)
11
+
12
+ it('returns the authentication status from the identity context', async () => {
13
+ await usingAsync(createInjector(), async (i) => {
14
+ i.bind(IdentityContext, () => ({
15
+ isAuthenticated: () => Promise.resolve(true),
16
+ isAuthorized: () => Promise.resolve(true),
17
+ getCurrentUser: () => Promise.reject(new Error('not needed')),
18
+ }))
14
19
  const result = await IsAuthenticated({ injector: i, request, response })
15
20
  expect(result.statusCode).toBe(200)
16
21
  expect(result.chunk).toEqual({ isAuthenticated: true })
@@ -3,10 +3,9 @@ import type { RequestAction } from '../request-action-implementation.js'
3
3
  import { JsonResult } from '../request-action-implementation.js'
4
4
 
5
5
  /**
6
- * Action that returns if the current user is authenticated
7
- * @param options The options for the Custom Action
8
- * @param options.injector The Injector from the current context
9
- * @returns A standard authentication result
6
+ * Returns `{ isAuthenticated: boolean }`. Unauthenticated callers receive
7
+ * a 200 with `false` rather than a 401 — use this when the client needs to
8
+ * branch on auth state without triggering a re-login flow.
10
9
  */
11
10
  export const IsAuthenticated: RequestAction<{ result: { isAuthenticated: boolean } }> = async ({ injector }) => {
12
11
  const isAuthenticatedResult = await isAuthenticated(injector)
@@ -1,28 +1,28 @@
1
- import { Injector } from '@furystack/inject'
1
+ import { createInjector } from '@furystack/inject'
2
+ import { EventHub } from '@furystack/utils'
2
3
  import { usingAsync } from '@furystack/utils'
3
4
  import type { IncomingMessage, ServerResponse } from 'http'
4
5
  import { describe, expect, it, vi } from 'vitest'
5
- import { HttpUserContext } from '../http-user-context.js'
6
+ import type { HttpUserContext } from '../http-user-context.js'
7
+ import { HttpUserContext as HttpUserContextToken } from '../http-user-context.js'
6
8
  import { LogoutAction } from './logout.js'
7
9
 
8
10
  describe('LogoutAction', () => {
9
11
  const request = { url: 'https://google.com' } as IncomingMessage
10
12
  const response = {} as ServerResponse
11
13
 
12
- it('exec', async () => {
13
- await usingAsync(new Injector(), async (i) => {
14
- const cookieLogout = vi.fn(async () => true)
15
- i.setExplicitInstance(
16
- {
17
- cookieLogout,
18
- },
19
- HttpUserContext,
20
- )
14
+ it('delegates to HttpUserContext.cookieLogout and returns an empty result', async () => {
15
+ await usingAsync(createInjector(), async (i) => {
16
+ const cookieLogout = vi.fn(async () => {
17
+ /* noop */
18
+ })
19
+ const stub = Object.assign(new EventHub(), { cookieLogout }) as unknown as HttpUserContext
20
+ i.bind(HttpUserContextToken, () => stub)
21
21
 
22
22
  const result = await LogoutAction({ request, response, injector: i })
23
23
  expect(result.statusCode).toBe(200)
24
24
  expect(result.chunk).toBe(undefined)
25
- expect(cookieLogout).toBeCalled()
25
+ expect(cookieLogout).toHaveBeenCalled()
26
26
  })
27
27
  })
28
28
  })
@@ -3,14 +3,10 @@ import type { RequestAction } from '../request-action-implementation.js'
3
3
  import { EmptyResult } from '../request-action-implementation.js'
4
4
 
5
5
  /**
6
- * Action that logs out the current user
7
- * @param root0 The Options object
8
- * @param root0.injector The injector from the context
9
- * @param root0.request The current Request object
10
- * @param root0.response The Response object
11
- * @returns An empty result that indicates the success
6
+ * Logs out via {@link HttpUserContext.cookieLogout} — clears the session
7
+ * cookie and removes the session row from the session DataSet.
12
8
  */
13
9
  export const LogoutAction: RequestAction<{ result: unknown }> = async ({ injector, request, response }) => {
14
- await injector.getInstance(HttpUserContext).cookieLogout(request, response)
10
+ await injector.get(HttpUserContext).cookieLogout(request, response)
15
11
  return EmptyResult()
16
12
  }
@@ -1,9 +1,7 @@
1
1
  import type { RequestAction } from '../request-action-implementation.js'
2
2
  import { JsonResult } from '../request-action-implementation.js'
3
3
 
4
- /**
5
- * @returns The standard Not Found action result
6
- */
4
+ /** Default `404` action. Returns `{ error: 'Content not found' }`. */
7
5
  export const NotFoundAction: RequestAction<{ result: { error: string } }> = async () => {
8
6
  return JsonResult(
9
7
  {
@@ -1,37 +1,37 @@
1
- import { InMemoryStore, StoreManager, User, addStore } from '@furystack/core'
2
- import { Injector } from '@furystack/inject'
3
- import { getRepository } from '@furystack/repository'
4
- import { PasswordAuthenticator, PasswordCredential, PasswordResetToken, usePasswordPolicy } from '@furystack/security'
1
+ import type { User } from '@furystack/core'
2
+ import { InMemoryStore, User as UserModel } from '@furystack/core'
3
+ import { createInjector, type Injector } from '@furystack/inject'
4
+ import {
5
+ PasswordCredential,
6
+ PasswordCredentialStore,
7
+ PasswordResetToken,
8
+ PasswordResetTokenStore,
9
+ usePasswordPolicy,
10
+ } from '@furystack/security'
5
11
  import { usingAsync } from '@furystack/utils'
6
12
  import type { IncomingMessage, ServerResponse } from 'http'
7
13
  import { describe, expect, it, vi } from 'vitest'
8
-
9
14
  import { useHttpAuthentication } from '../helpers.js'
10
15
  import type { LoginResponseStrategy } from '../login-response-strategy.js'
11
16
  import { DefaultSession } from '../models/default-session.js'
12
17
  import { JsonResult } from '../request-action-implementation.js'
18
+ import { SessionStore, UserStore } from '../user-store.js'
19
+ import { PasswordAuthenticator } from '@furystack/security'
13
20
  import { createPasswordLoginAction } from './password-login-action.js'
14
21
 
15
22
  const setupInjector = async (i: Injector, username: string, password: string) => {
16
- addStore(i, new InMemoryStore({ model: User, primaryKey: 'username' }))
17
- .addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
18
- .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
19
- .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' }))
20
-
21
- const repo = getRepository(i)
22
- repo.createDataSet(User, 'username')
23
- repo.createDataSet(DefaultSession, 'sessionId')
24
- repo.createDataSet(PasswordCredential, 'userName')
25
- repo.createDataSet(PasswordResetToken, 'token')
23
+ i.bind(UserStore, () => new InMemoryStore({ model: UserModel, primaryKey: 'username' }))
24
+ i.bind(SessionStore, () => new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
25
+ i.bind(PasswordCredentialStore, () => new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
26
+ i.bind(PasswordResetTokenStore, () => new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' }))
26
27
 
27
28
  usePasswordPolicy(i)
28
29
  useHttpAuthentication(i)
29
30
 
30
- const sm = i.getInstance(StoreManager)
31
- const pw = i.getInstance(PasswordAuthenticator)
32
- const cred = await pw.hasher.createCredential(username, password)
33
- await sm.getStoreFor(PasswordCredential, 'userName').add(cred)
34
- await sm.getStoreFor(User, 'username').add({ username, roles: ['admin'] })
31
+ const authenticator = i.get(PasswordAuthenticator)
32
+ const cred = await authenticator.hasher.createCredential(username, password)
33
+ await i.get(PasswordCredentialStore).add(cred)
34
+ await i.get(UserStore).add({ username, roles: ['admin'] })
35
35
  }
36
36
 
37
37
  const mockStrategy: LoginResponseStrategy<User> = {
@@ -42,8 +42,8 @@ describe('createPasswordLoginAction', () => {
42
42
  const request = {} as IncomingMessage
43
43
  const response = {} as ServerResponse
44
44
 
45
- it('Should delegate to the strategy on successful authentication', async () => {
46
- await usingAsync(new Injector(), async (i) => {
45
+ it('delegates to the strategy on successful authentication', async () => {
46
+ await usingAsync(createInjector(), async (i) => {
47
47
  await setupInjector(i, 'testuser', 'testpass')
48
48
  const action = createPasswordLoginAction(mockStrategy)
49
49
  const result = await action({
@@ -57,8 +57,8 @@ describe('createPasswordLoginAction', () => {
57
57
  })
58
58
  })
59
59
 
60
- it('Should pass the correct user to the strategy', async () => {
61
- await usingAsync(new Injector(), async (i) => {
60
+ it('passes the authenticated user to the strategy', async () => {
61
+ await usingAsync(createInjector(), async (i) => {
62
62
  await setupInjector(i, 'testuser', 'testpass')
63
63
  const strategyFn = vi.fn(async (user: User) => JsonResult(user, 200))
64
64
  const strategy: LoginResponseStrategy<User> = { createLoginResponse: strategyFn }
@@ -73,8 +73,8 @@ describe('createPasswordLoginAction', () => {
73
73
  })
74
74
  })
75
75
 
76
- it('Should throw RequestError on invalid credentials', async () => {
77
- await usingAsync(new Injector(), async (i) => {
76
+ it('throws RequestError on invalid password', async () => {
77
+ await usingAsync(createInjector(), async (i) => {
78
78
  await setupInjector(i, 'testuser', 'testpass')
79
79
  const action = createPasswordLoginAction(mockStrategy)
80
80
  await expect(
@@ -88,8 +88,8 @@ describe('createPasswordLoginAction', () => {
88
88
  })
89
89
  })
90
90
 
91
- it('Should throw RequestError for nonexistent user', async () => {
92
- await usingAsync(new Injector(), async (i) => {
91
+ it('throws RequestError for an unknown user', async () => {
92
+ await usingAsync(createInjector(), async (i) => {
93
93
  await setupInjector(i, 'testuser', 'testpass')
94
94
  const action = createPasswordLoginAction(mockStrategy)
95
95
  await expect(
@@ -103,8 +103,8 @@ describe('createPasswordLoginAction', () => {
103
103
  })
104
104
  })
105
105
 
106
- it('Should work with a custom result type from strategy', async () => {
107
- await usingAsync(new Injector(), async (i) => {
106
+ it('supports strategies returning custom result shapes', async () => {
107
+ await usingAsync(createInjector(), async (i) => {
108
108
  await setupInjector(i, 'testuser', 'testpass')
109
109
  const tokenStrategy: LoginResponseStrategy<{ accessToken: string }> = {
110
110
  createLoginResponse: async () => JsonResult({ accessToken: 'tok123' }, 200),
@@ -6,18 +6,11 @@ import type { LoginResponseStrategy } from '../login-response-strategy.js'
6
6
  import type { RequestAction } from '../request-action-implementation.js'
7
7
 
8
8
  /**
9
- * Creates a login {@link RequestAction} that authenticates a user by
10
- * username + password, then delegates session/token creation to the
11
- * provided {@link LoginResponseStrategy}.
12
- *
13
- * The return type is inferred from the strategy:
14
- * - Cookie strategy -> `ActionResult<User>`
15
- * - JWT strategy -> `ActionResult<{ accessToken: string; refreshToken: string }>`
16
- *
17
- * A random delay (0-1 s) is added on failure to mitigate timing attacks.
18
- *
19
- * @param strategy The login response strategy that produces the session/token result
20
- * @returns A `RequestAction` that can be wired into a REST API route
9
+ * Username + password login action. Authenticates via {@link HttpUserContext},
10
+ * then delegates session/token creation to `strategy`. The result type is
11
+ * inferred from the strategy (e.g. `User` for cookies, `{ accessToken,
12
+ * refreshToken }` for JWT). Adds a 0–1 s random delay on failure to mitigate
13
+ * timing attacks; rethrows as `RequestError(400)`.
21
14
  */
22
15
  export const createPasswordLoginAction = <TResult>(
23
16
  strategy: LoginResponseStrategy<TResult>,
@@ -25,7 +18,7 @@ export const createPasswordLoginAction = <TResult>(
25
18
  return async ({ injector, getBody }) => {
26
19
  const body = await getBody()
27
20
  try {
28
- const user = await injector.getInstance(HttpUserContext).authenticateUser(body.username, body.password)
21
+ const user = await injector.get(HttpUserContext).authenticateUser(body.username, body.password)
29
22
  return strategy.createLoginResponse(user, injector)
30
23
  } catch {
31
24
  await sleepAsync(Math.random() * 1000)
@@ -1,84 +1,78 @@
1
1
  import { IdentityContext } from '@furystack/core'
2
- import { Injector } from '@furystack/inject'
3
- import { usingAsync } from '@furystack/utils'
2
+ import { createInjector } from '@furystack/inject'
3
+ import { EventHub, usingAsync } from '@furystack/utils'
4
4
  import type { IncomingMessage, ServerResponse } from 'http'
5
5
  import { describe, expect, it, vi } from 'vitest'
6
6
  import { Authenticate } from './authenticate.js'
7
- import { HttpUserContext } from './http-user-context.js'
7
+ import type { HttpUserContext } from './http-user-context.js'
8
+ import { HttpUserContext as HttpUserContextToken } from './http-user-context.js'
8
9
  import { EmptyResult } from './request-action-implementation.js'
9
10
 
11
+ const makeHttpUserContextStub = (providers: Array<{ name: string }>): HttpUserContext =>
12
+ Object.assign(new EventHub(), {
13
+ authentication: { authenticationProviders: providers },
14
+ }) as unknown as HttpUserContext
15
+
10
16
  describe('Authenticate', () => {
11
- const response = {} as any as ServerResponse
17
+ const response = {} as ServerResponse
12
18
  const request = { url: 'http://google.com' } as IncomingMessage
13
19
 
14
- it('Should return 401 without WWW-Authenticate header when no basic-auth provider is registered', async () => {
15
- await usingAsync(new Injector(), async (i) => {
16
- const isAuthenticatedAction = vi.fn(async () => false)
17
-
18
- i.setExplicitInstance(
19
- { isAuthenticated: isAuthenticatedAction, getCurrentUser: async () => Promise.reject(new Error(':(')) },
20
- IdentityContext,
21
- )
20
+ it('returns 401 without WWW-Authenticate when no basic-auth provider is registered', async () => {
21
+ await usingAsync(createInjector(), async (i) => {
22
+ const isAuthenticated = vi.fn(async () => false)
23
+ i.bind(IdentityContext, () => ({
24
+ isAuthenticated,
25
+ getCurrentUser: () => Promise.reject(new Error(':(')),
26
+ isAuthorized: () => Promise.resolve(false),
27
+ }))
28
+ i.bind(HttpUserContextToken, () => makeHttpUserContextStub([]))
22
29
 
23
- i.setExplicitInstance(
24
- {
25
- authentication: { authenticationProviders: [] },
26
- },
27
- HttpUserContext,
28
- )
29
- const exampleAuthenticatedAction = vi.fn(async () => EmptyResult())
30
- const authorized = Authenticate()(exampleAuthenticatedAction)
30
+ const exampleAction = vi.fn(async () => EmptyResult())
31
+ const authorized = Authenticate()(exampleAction)
31
32
 
32
33
  const result = await authorized({ injector: i, request, response })
33
34
  expect(result.statusCode).toBe(401)
34
35
  expect(result.chunk).toEqual({ error: 'unauthorized' })
35
36
  expect(result.headers).toEqual({ 'Content-Type': 'application/json' })
36
- expect(exampleAuthenticatedAction).not.toBeCalled()
37
+ expect(exampleAction).not.toHaveBeenCalled()
37
38
  })
38
39
  })
39
40
 
40
- it('Should return 401 with WWW-Authenticate: Basic header when basic-auth provider is registered', async () => {
41
- await usingAsync(new Injector(), async (i) => {
42
- const isAuthenticatedAction = vi.fn(async () => false)
43
-
44
- i.setExplicitInstance(
45
- { isAuthenticated: isAuthenticatedAction, getCurrentUser: async () => Promise.reject(new Error(':(')) },
46
- IdentityContext,
47
- )
41
+ it('returns 401 with WWW-Authenticate: Basic when a basic-auth provider is registered', async () => {
42
+ await usingAsync(createInjector(), async (i) => {
43
+ const isAuthenticated = vi.fn(async () => false)
44
+ i.bind(IdentityContext, () => ({
45
+ isAuthenticated,
46
+ getCurrentUser: () => Promise.reject(new Error(':(')),
47
+ isAuthorized: () => Promise.resolve(false),
48
+ }))
49
+ i.bind(HttpUserContextToken, () => makeHttpUserContextStub([{ name: 'basic-auth' }]))
48
50
 
49
- i.setExplicitInstance(
50
- {
51
- authentication: {
52
- authenticationProviders: [{ name: 'basic-auth', authenticate: async () => null }],
53
- },
54
- },
55
- HttpUserContext,
56
- )
57
- const exampleAuthenticatedAction = vi.fn(async () => EmptyResult())
58
- const authorized = Authenticate()(exampleAuthenticatedAction)
51
+ const exampleAction = vi.fn(async () => EmptyResult())
52
+ const authorized = Authenticate()(exampleAction)
59
53
 
60
54
  const result = await authorized({ injector: i, request, response })
61
55
  expect(result.statusCode).toBe(401)
62
56
  expect(result.chunk).toEqual({ error: 'unauthorized' })
63
57
  expect(result.headers).toEqual({ 'Content-Type': 'application/json', 'WWW-Authenticate': 'Basic' })
64
- expect(exampleAuthenticatedAction).not.toBeCalled()
58
+ expect(exampleAction).not.toHaveBeenCalled()
65
59
  })
66
60
  })
67
61
 
68
- it('Should exec the original action if authorized', async () => {
69
- await usingAsync(new Injector(), async (i) => {
70
- const isAuthenticatedAction = vi.fn(async () => true)
71
- i.setExplicitInstance(
72
- { isAuthenticated: isAuthenticatedAction, getCurrentUser: async () => Promise.reject(new Error(':(')) },
73
- IdentityContext,
74
- )
75
- const exampleAuthenticatedAction = vi.fn(async () => EmptyResult())
76
- const authorized = Authenticate()(exampleAuthenticatedAction)
77
- const params = { injector: i, body: undefined, query: undefined, request, response }
62
+ it('invokes the wrapped action when the caller is authenticated', async () => {
63
+ await usingAsync(createInjector(), async (i) => {
64
+ i.bind(IdentityContext, () => ({
65
+ isAuthenticated: () => Promise.resolve(true),
66
+ getCurrentUser: () => Promise.reject(new Error(':(')),
67
+ isAuthorized: () => Promise.resolve(true),
68
+ }))
69
+ const exampleAction = vi.fn(async () => EmptyResult())
70
+ const authorized = Authenticate()(exampleAction)
71
+ const params = { injector: i, request, response }
78
72
  const result = await authorized(params)
79
73
  expect(result.statusCode).toBe(200)
80
74
  expect(result.chunk).toBe(undefined)
81
- expect(exampleAuthenticatedAction).toBeCalledWith(params)
75
+ expect(exampleAction).toHaveBeenCalledWith(params)
82
76
  })
83
77
  })
84
78
  })
@@ -5,21 +5,15 @@ import type { ActionResult, RequestAction, RequestActionOptions } from './reques
5
5
  import { JsonResult } from './request-action-implementation.js'
6
6
 
7
7
  /**
8
- * Higher-order function that wraps a request action to require authentication.
9
- * If the user is not authenticated, returns a 401 Unauthorized response.
10
- * Includes a random delay on unauthorized requests to mitigate timing attacks.
8
+ * Wraps a {@link RequestAction} to require authentication. Unauthenticated
9
+ * requests return `401 Unauthorized` (with `WWW-Authenticate: Basic` when
10
+ * the chain includes Basic Auth). Adds a 0–1 s random delay on rejection to
11
+ * mitigate timing attacks.
11
12
  *
12
- * @returns A function that wraps the provided action with authentication check
13
13
  * @example
14
14
  * ```ts
15
- * // Wrap an endpoint to require authentication
16
- * const protectedEndpoint = Authenticate()(myEndpoint)
17
- *
18
- * // Or use directly in API definition
19
15
  * api: {
20
- * GET: {
21
- * '/users': Authenticate()(getUsersEndpoint)
22
- * }
16
+ * GET: { '/users': Authenticate()(getUsersEndpoint) },
23
17
  * }
24
18
  * ```
25
19
  */
@@ -32,7 +26,7 @@ export const Authenticate =
32
26
  if (!authenticated) {
33
27
  await sleepAsync(Math.random() * 1000)
34
28
  const hasBasicAuth = injector
35
- .getInstance(HttpUserContext)
29
+ .get(HttpUserContext)
36
30
  .authentication.authenticationProviders.some((p) => p.name === 'basic-auth')
37
31
  return JsonResult(
38
32
  { error: 'unauthorized' },
@@ -22,4 +22,21 @@ export type AuthenticationProvider = {
22
22
  */
23
23
  readonly name: string
24
24
  authenticate: (request: Pick<IncomingMessage, 'headers'>) => Promise<User | null>
25
+ /**
26
+ * Optional cache-key resolver consulted by the user-resolution cache before
27
+ * the provider chain is walked.
28
+ *
29
+ * Returns a stable, opaque string identifying the authenticated principal
30
+ * for the given request, or `null` when this provider does not apply to
31
+ * the request (or refuses to participate in caching — e.g. Basic Auth,
32
+ * which must verify the password on every request).
33
+ *
34
+ * The first non-null key returned by the configured providers is used as
35
+ * the cache key. Keys returned by different providers must not collide;
36
+ * built-in providers prefix their keys (`cookie:`, `jwt:`).
37
+ *
38
+ * If no provider returns a key, the request bypasses the cache entirely
39
+ * and the provider chain is walked on every call.
40
+ */
41
+ readonly getCacheKey?: (request: Pick<IncomingMessage, 'headers'>) => string | null
25
42
  }
@@ -3,6 +3,13 @@ import type { AuthenticationProvider } from './authentication-provider.js'
3
3
 
4
4
  /**
5
5
  * Creates an authentication provider for HTTP Basic Authentication.
6
+ *
7
+ * **Caching note:** this provider intentionally does **not** implement
8
+ * `getCacheKey`. Caching authenticated users by username would bypass
9
+ * password verification on every cache hit — the cache cannot revalidate the
10
+ * password without re-running the authenticator. Basic Auth requests
11
+ * therefore always walk the provider chain, which is also where the password
12
+ * is verified.
6
13
  * @param authenticateUser Callback that verifies credentials and returns a User or throws
7
14
  */
8
15
  export const createBasicAuthProvider = (
@@ -1,5 +1,6 @@
1
1
  import type { User } from '@furystack/core'
2
2
  import { UnauthenticatedError } from '@furystack/security'
3
+ import { sessionCacheKey, type SessionCacheKey } from '../identity-cache-keys.js'
3
4
  import type { DefaultSession } from '../models/default-session.js'
4
5
  import type { AuthenticationProvider } from './authentication-provider.js'
5
6
  import { extractSessionIdFromCookies } from './helpers.js'
@@ -23,4 +24,11 @@ export const createCookieAuthProvider = (
23
24
  if (!session) throw new UnauthenticatedError()
24
25
  return await getUserByName(session.username)
25
26
  },
27
+ // The session id alone uniquely identifies the principal — caching by it is
28
+ // safe because session invalidation (logout, expiry) drops the session row,
29
+ // and the TTL on the user-resolution cache bounds staleness across nodes.
30
+ getCacheKey: (request): SessionCacheKey | null => {
31
+ const sessionId = extractSessionIdFromCookies(request, cookieName)
32
+ return sessionId ? sessionCacheKey(sessionId) : null
33
+ },
26
34
  })
@@ -1,6 +1,6 @@
1
1
  import type { User } from '@furystack/core'
2
2
  import { AuthorizationError, IdentityContext } from '@furystack/core'
3
- import { Injector } from '@furystack/inject'
3
+ import { createInjector } from '@furystack/inject'
4
4
  import { usingAsync } from '@furystack/utils'
5
5
  import type { IncomingMessage, ServerResponse } from 'http'
6
6
  import { describe, expect, it, vi } from 'vitest'
@@ -8,59 +8,57 @@ import { Authorize } from './authorize.js'
8
8
  import { EmptyResult } from './request-action-implementation.js'
9
9
 
10
10
  describe('Authorize', () => {
11
- const response = {} as any as ServerResponse
11
+ const response = {} as ServerResponse
12
12
  const request = { url: 'http://google.com' } as IncomingMessage
13
13
 
14
- it('Should return 403 when failed to get current user', async () => {
15
- await usingAsync(new Injector(), async (i) => {
16
- const isAuthorizedAction = vi.fn(async () => false)
17
- i.setExplicitInstance(
18
- { isAuthorized: isAuthorizedAction, getCurrentUser: () => Promise.reject(new Error(':(')) },
19
- IdentityContext,
20
- )
21
- const exampleAuthorizedAction = vi.fn(async () => EmptyResult())
22
- const authorized = Authorize('Role1')(exampleAuthorizedAction)
14
+ it('throws AuthorizationError when the current user cannot be resolved', async () => {
15
+ await usingAsync(createInjector(), async (i) => {
16
+ const isAuthorized = vi.fn(async () => false)
17
+ i.bind(IdentityContext, () => ({
18
+ isAuthorized,
19
+ getCurrentUser: () => Promise.reject(new Error(':(')),
20
+ isAuthenticated: () => Promise.resolve(false),
21
+ }))
22
+ const exampleAction = vi.fn(async () => EmptyResult())
23
+ const authorized = Authorize('Role1')(exampleAction)
23
24
 
24
25
  await expect(() => authorized({ injector: i, request, response })).rejects.toThrow(AuthorizationError)
25
- expect(exampleAuthorizedAction).not.toBeCalled()
26
+ expect(exampleAction).not.toHaveBeenCalled()
26
27
  })
27
28
  })
28
29
 
29
- it('Should return 403 if the current user does not have the role', async () => {
30
- await usingAsync(new Injector(), async (i) => {
31
- const isAuthorizedAction = vi.fn(async () => false)
32
- i.setExplicitInstance(
33
- {
34
- isAuthorized: isAuthorizedAction,
35
- getCurrentUser: async () => Promise.resolve<User>({ username: 'a', roles: ['Role1'] }),
36
- },
37
- IdentityContext,
38
- )
39
- const exampleAuthorizedAction = vi.fn(async () => EmptyResult())
40
- const authorized = Authorize('Role2')(exampleAuthorizedAction)
30
+ it('throws AuthorizationError when the current user does not have the required role', async () => {
31
+ await usingAsync(createInjector(), async (i) => {
32
+ const isAuthorized = vi.fn(async () => false)
33
+ i.bind(IdentityContext, () => ({
34
+ isAuthorized,
35
+ getCurrentUser: <TUser extends User>() =>
36
+ Promise.resolve({ username: 'a', roles: ['Role1'] } as unknown as TUser),
37
+ isAuthenticated: () => Promise.resolve(true),
38
+ }))
39
+ const exampleAction = vi.fn(async () => EmptyResult())
40
+ const authorized = Authorize('Role2')(exampleAction)
41
41
 
42
42
  await expect(() => authorized({ injector: i, request, response })).rejects.toThrow(AuthorizationError)
43
- expect(exampleAuthorizedAction).not.toBeCalled()
43
+ expect(exampleAction).not.toHaveBeenCalled()
44
44
  })
45
45
  })
46
46
 
47
- it('Should exec the original action if authorized', async () => {
48
- await usingAsync(new Injector(), async (i) => {
49
- const isAuthorizedAction = vi.fn(async () => true)
50
- i.setExplicitInstance(
51
- {
52
- isAuthorized: isAuthorizedAction,
53
- getCurrentUser: async () => Promise.resolve<User>({ username: 'a', roles: ['Role1'] }),
54
- },
55
- IdentityContext,
56
- )
57
- const exampleAuthorizedAction = vi.fn(async () => EmptyResult())
58
- const authorized = Authorize('Role1')(exampleAuthorizedAction)
59
- const params = { injector: i, body: undefined, query: undefined, request, response }
47
+ it('invokes the wrapped action when the current user holds the required role', async () => {
48
+ await usingAsync(createInjector(), async (i) => {
49
+ i.bind(IdentityContext, () => ({
50
+ isAuthorized: () => Promise.resolve(true),
51
+ getCurrentUser: <TUser extends User>() =>
52
+ Promise.resolve({ username: 'a', roles: ['Role1'] } as unknown as TUser),
53
+ isAuthenticated: () => Promise.resolve(true),
54
+ }))
55
+ const exampleAction = vi.fn(async () => EmptyResult())
56
+ const authorized = Authorize('Role1')(exampleAction)
57
+ const params = { injector: i, request, response }
60
58
  const result = await authorized(params)
61
59
  expect(result.statusCode).toBe(200)
62
60
  expect(result.chunk).toBe(undefined)
63
- expect(exampleAuthorizedAction).toBeCalledWith(params)
61
+ expect(exampleAction).toHaveBeenCalledWith(params)
64
62
  })
65
63
  })
66
64
  })
package/src/authorize.ts CHANGED
@@ -1,25 +1,14 @@
1
1
  import { AuthorizationError, isAuthorized } from '@furystack/core'
2
2
  import { sleepAsync } from '@furystack/utils'
3
+ import type { Authenticate } from './authenticate.js'
3
4
  import type { ActionResult, RequestAction, RequestActionOptions } from './request-action-implementation.js'
4
5
 
5
6
  /**
6
- * Higher-order function that wraps a request action to require specific roles.
7
- * If the user does not have the required roles, throws an AuthorizationError.
8
- * Includes a random delay on unauthorized requests to mitigate timing attacks.
9
- *
10
- * @param roles - The roles required to access the endpoint
11
- * @returns A function that wraps the provided action with authorization check
12
- * @example
13
- * ```ts
14
- * // Require 'admin' role
15
- * const adminEndpoint = Authorize('admin')(myEndpoint)
16
- *
17
- * // Require multiple roles (user must have ALL specified roles)
18
- * const superAdminEndpoint = Authorize('admin', 'superuser')(myEndpoint)
19
- *
20
- * // Chain with Authenticate for authentication + authorization
21
- * const protectedEndpoint = Authenticate()(Authorize('admin')(myEndpoint))
22
- * ```
7
+ * Wraps a {@link RequestAction} to require **all** specified roles. Throws
8
+ * {@link AuthorizationError} (translated to 403 by `ErrorAction`) when any
9
+ * role is missing. Adds a 0–1 s random delay on rejection to mitigate
10
+ * timing attacks. Compose with {@link Authenticate} to require both:
11
+ * `Authenticate()(Authorize('admin')(myEndpoint))`.
23
12
  */
24
13
  export const Authorize =
25
14
  (...roles: string[]) =>