@decohere/http 0.0.1-dev.125 → 0.0.1-dev.5

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 (292) hide show
  1. package/package.json +4 -13
  2. package/src/app/app.ts +147 -0
  3. package/src/app/module.ts +19 -0
  4. package/src/auth/ability.ts +56 -0
  5. package/src/auth/auth.config.ts +34 -0
  6. package/src/auth/auth.extensions.ts +115 -0
  7. package/src/auth/auth.types.ts +124 -0
  8. package/src/auth/casl-authorization.service.ts +62 -0
  9. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  10. package/src/auth/safe-outbound-url.ts +117 -0
  11. package/src/auth/token-service.ts +192 -0
  12. package/src/builder/builder.ts +118 -0
  13. package/{es6/src/const.js → src/const.ts} +25 -17
  14. package/src/health/health.controller.ts +34 -0
  15. package/src/health/health.extensions.ts +53 -0
  16. package/src/health/readiness.service.ts +33 -0
  17. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  18. package/src/hosting/hosted-service-host.ts +102 -0
  19. package/src/hosting/hosted-service.ts +31 -0
  20. package/src/http/http.compression.ts +38 -0
  21. package/src/http/http.extensions.ts +244 -0
  22. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  23. package/src/http/http.options.ts +41 -0
  24. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  25. package/src/http/http.static.ts +359 -0
  26. package/{es6/src/index.js → src/index.ts} +1 -0
  27. package/src/openapi/index.ts +22 -0
  28. package/src/openapi/openapi.generator.ts +78 -0
  29. package/src/openapi/openapi.types.ts +60 -0
  30. package/src/openapi/request-schema.ts +155 -0
  31. package/src/openapi/request-validator.ts +95 -0
  32. package/src/providers/fastify.provider.ts +454 -0
  33. package/src/routing/anvil.types.ts +150 -0
  34. package/src/routing/decorators/argument.decorator.ts +33 -0
  35. package/src/routing/decorators/auth.decorator.ts +79 -0
  36. package/src/routing/decorators/controller.decorator.ts +9 -0
  37. package/src/routing/decorators/field.decorator.ts +15 -0
  38. package/src/routing/decorators/request.decorator.ts +34 -0
  39. package/src/routing/decorators/response.decorator.ts +18 -0
  40. package/src/routing/decorators/route.decorator.ts +52 -0
  41. package/src/routing/filters/exception-filter-host.ts +90 -0
  42. package/src/routing/filters/exception-filter.ts +29 -0
  43. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  44. package/src/routing/middleware/middleware.ts +26 -0
  45. package/src/routing/middleware/request-id.ts +18 -0
  46. package/src/routing/path.util.ts +38 -0
  47. package/src/routing/pipes/pipes.ts +132 -0
  48. package/src/routing/services/route-argument-resolver.ts +92 -0
  49. package/src/routing/services/route-registry.ts +229 -0
  50. package/src/routing/services/router.ts +166 -0
  51. package/src/sse/sse.decorator.ts +41 -0
  52. package/src/sse/sse.encoder.ts +86 -0
  53. package/src/sse/sse.types.ts +47 -0
  54. package/src/sse/sse.wire.ts +34 -0
  55. package/src/testing/boot-module.ts +63 -0
  56. package/src/testing/sse-reader.ts +120 -0
  57. package/tests/app.hosted-services.spec.ts +292 -0
  58. package/tests/app.shutdown.integration.spec.ts +160 -0
  59. package/tests/app.spec.ts +132 -0
  60. package/tests/auth.ability.spec.ts +48 -0
  61. package/tests/auth.decorators.spec.ts +20 -0
  62. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  63. package/tests/auth.integration.spec.ts +234 -0
  64. package/tests/auth.jwks-cache.spec.ts +52 -0
  65. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  66. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  67. package/tests/auth.oidc.integration.spec.ts +161 -0
  68. package/tests/auth.token-service.spec.ts +251 -0
  69. package/tests/builder.spec.ts +12 -0
  70. package/tests/health.integration.spec.ts +111 -0
  71. package/tests/helpers/listen-ephemeral.ts +13 -0
  72. package/tests/helpers/local-oidc-server.ts +100 -0
  73. package/tests/http.access-log.integration.spec.ts +111 -0
  74. package/tests/http.boolean-query.integration.spec.ts +59 -0
  75. package/tests/http.compression.integration.spec.ts +604 -0
  76. package/tests/http.date-query.integration.spec.ts +71 -0
  77. package/tests/http.global-prefix.integration.spec.ts +284 -0
  78. package/tests/http.number-query.integration.spec.ts +65 -0
  79. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  80. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  81. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  82. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  83. package/tests/http.security.integration.spec.ts +106 -0
  84. package/tests/http.static.integration.spec.ts +847 -0
  85. package/tests/http.static.spec.ts +111 -0
  86. package/tests/http.surface.integration.spec.ts +167 -0
  87. package/tests/openapi.integration.spec.ts +193 -0
  88. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  89. package/tests/routing.decorators.spec.ts +15 -0
  90. package/tests/routing.registry.spec.ts +44 -0
  91. package/tests/routing.resolver.spec.ts +92 -0
  92. package/tests/sse.integration.spec.ts +421 -0
  93. package/tests/sse.wire.spec.ts +33 -0
  94. package/LICENSE +0 -15
  95. package/commonjs/src/app/app.js +0 -166
  96. package/commonjs/src/app/index.js +0 -19
  97. package/commonjs/src/app/module.js +0 -17
  98. package/commonjs/src/auth/ability.js +0 -54
  99. package/commonjs/src/auth/auth.config.js +0 -23
  100. package/commonjs/src/auth/auth.extensions.js +0 -106
  101. package/commonjs/src/auth/auth.types.js +0 -65
  102. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  103. package/commonjs/src/auth/index.js +0 -23
  104. package/commonjs/src/auth/principal-provider.js +0 -19
  105. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  106. package/commonjs/src/auth/token-service.js +0 -151
  107. package/commonjs/src/builder/builder.js +0 -95
  108. package/commonjs/src/builder/index.js +0 -18
  109. package/commonjs/src/const.js +0 -87
  110. package/commonjs/src/health/health.controller.js +0 -75
  111. package/commonjs/src/health/health.extensions.js +0 -50
  112. package/commonjs/src/health/index.js +0 -21
  113. package/commonjs/src/health/readiness.service.js +0 -67
  114. package/commonjs/src/health/readiness.types.js +0 -7
  115. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  116. package/commonjs/src/hosting/hosted-service.js +0 -11
  117. package/commonjs/src/hosting/index.js +0 -19
  118. package/commonjs/src/http/http.compression.js +0 -27
  119. package/commonjs/src/http/http.extensions.js +0 -269
  120. package/commonjs/src/http/http.options.js +0 -26
  121. package/commonjs/src/http/http.options.store.js +0 -20
  122. package/commonjs/src/http/http.security.js +0 -27
  123. package/commonjs/src/http/http.static.js +0 -248
  124. package/commonjs/src/http/index.js +0 -22
  125. package/commonjs/src/index.js +0 -27
  126. package/commonjs/src/openapi/index.js +0 -45
  127. package/commonjs/src/openapi/openapi.generator.js +0 -103
  128. package/commonjs/src/openapi/openapi.types.js +0 -4
  129. package/commonjs/src/openapi/request-schema.js +0 -187
  130. package/commonjs/src/openapi/request-validator.js +0 -85
  131. package/commonjs/src/providers/fastify.provider.js +0 -391
  132. package/commonjs/src/providers/index.js +0 -18
  133. package/commonjs/src/routing/anvil.types.js +0 -99
  134. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  135. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  136. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  137. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  138. package/commonjs/src/routing/decorators/index.js +0 -24
  139. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  140. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  141. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  142. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  143. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  144. package/commonjs/src/routing/index.js +0 -25
  145. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  146. package/commonjs/src/routing/middleware/middleware.js +0 -20
  147. package/commonjs/src/routing/middleware/request-id.js +0 -30
  148. package/commonjs/src/routing/path.util.js +0 -51
  149. package/commonjs/src/routing/pipes/index.js +0 -18
  150. package/commonjs/src/routing/pipes/pipes.js +0 -145
  151. package/commonjs/src/routing/services/index.js +0 -18
  152. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  153. package/commonjs/src/routing/services/route-registry.js +0 -194
  154. package/commonjs/src/routing/services/router.js +0 -158
  155. package/commonjs/src/sse/index.js +0 -26
  156. package/commonjs/src/sse/sse.decorator.js +0 -42
  157. package/commonjs/src/sse/sse.encoder.js +0 -73
  158. package/commonjs/src/sse/sse.types.js +0 -36
  159. package/commonjs/src/sse/sse.wire.js +0 -40
  160. package/commonjs/src/testing/boot-module.js +0 -38
  161. package/commonjs/src/testing/index.js +0 -20
  162. package/commonjs/src/testing/sse-reader.js +0 -98
  163. package/es6/src/app/app.js +0 -148
  164. package/es6/src/app/index.js +0 -2
  165. package/es6/src/app/module.js +0 -7
  166. package/es6/src/auth/ability.js +0 -45
  167. package/es6/src/auth/auth.config.js +0 -19
  168. package/es6/src/auth/auth.extensions.js +0 -83
  169. package/es6/src/auth/auth.types.js +0 -35
  170. package/es6/src/auth/casl-authorization.service.js +0 -40
  171. package/es6/src/auth/index.js +0 -6
  172. package/es6/src/auth/principal-provider.js +0 -14
  173. package/es6/src/auth/safe-outbound-url.js +0 -99
  174. package/es6/src/auth/token-service.js +0 -141
  175. package/es6/src/builder/builder.js +0 -85
  176. package/es6/src/builder/index.js +0 -1
  177. package/es6/src/health/health.controller.js +0 -65
  178. package/es6/src/health/health.extensions.js +0 -40
  179. package/es6/src/health/index.js +0 -4
  180. package/es6/src/health/readiness.service.js +0 -57
  181. package/es6/src/health/readiness.types.js +0 -4
  182. package/es6/src/hosting/hosted-service-host.js +0 -81
  183. package/es6/src/hosting/hosted-service.js +0 -8
  184. package/es6/src/hosting/index.js +0 -2
  185. package/es6/src/http/http.compression.js +0 -9
  186. package/es6/src/http/http.extensions.js +0 -259
  187. package/es6/src/http/http.options.js +0 -16
  188. package/es6/src/http/http.options.store.js +0 -13
  189. package/es6/src/http/http.security.js +0 -9
  190. package/es6/src/http/http.static.js +0 -224
  191. package/es6/src/http/index.js +0 -5
  192. package/es6/src/openapi/index.js +0 -3
  193. package/es6/src/openapi/openapi.generator.js +0 -82
  194. package/es6/src/openapi/openapi.types.js +0 -1
  195. package/es6/src/openapi/request-schema.js +0 -160
  196. package/es6/src/openapi/request-validator.js +0 -71
  197. package/es6/src/providers/fastify.provider.js +0 -376
  198. package/es6/src/providers/index.js +0 -1
  199. package/es6/src/routing/anvil.types.js +0 -57
  200. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  201. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  202. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  203. package/es6/src/routing/decorators/field.decorator.js +0 -15
  204. package/es6/src/routing/decorators/index.js +0 -7
  205. package/es6/src/routing/decorators/request.decorator.js +0 -24
  206. package/es6/src/routing/decorators/response.decorator.js +0 -16
  207. package/es6/src/routing/decorators/route.decorator.js +0 -37
  208. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  209. package/es6/src/routing/filters/exception-filter.js +0 -10
  210. package/es6/src/routing/index.js +0 -8
  211. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  212. package/es6/src/routing/middleware/middleware.js +0 -10
  213. package/es6/src/routing/middleware/request-id.js +0 -14
  214. package/es6/src/routing/path.util.js +0 -33
  215. package/es6/src/routing/pipes/index.js +0 -1
  216. package/es6/src/routing/pipes/pipes.js +0 -118
  217. package/es6/src/routing/services/index.js +0 -1
  218. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  219. package/es6/src/routing/services/route-registry.js +0 -184
  220. package/es6/src/routing/services/router.js +0 -148
  221. package/es6/src/sse/index.js +0 -2
  222. package/es6/src/sse/sse.decorator.js +0 -31
  223. package/es6/src/sse/sse.encoder.js +0 -67
  224. package/es6/src/sse/sse.types.js +0 -22
  225. package/es6/src/sse/sse.wire.js +0 -30
  226. package/es6/src/testing/boot-module.js +0 -35
  227. package/es6/src/testing/index.js +0 -2
  228. package/es6/src/testing/sse-reader.js +0 -91
  229. package/types/src/app/app.d.ts +0 -41
  230. package/types/src/app/module.d.ts +0 -15
  231. package/types/src/auth/ability.d.ts +0 -19
  232. package/types/src/auth/auth.config.d.ts +0 -28
  233. package/types/src/auth/auth.extensions.d.ts +0 -53
  234. package/types/src/auth/auth.types.d.ts +0 -73
  235. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  236. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  237. package/types/src/auth/token-service.d.ts +0 -31
  238. package/types/src/builder/builder.d.ts +0 -36
  239. package/types/src/const.d.ts +0 -32
  240. package/types/src/health/health.controller.d.ts +0 -16
  241. package/types/src/health/health.extensions.d.ts +0 -31
  242. package/types/src/health/readiness.service.d.ts +0 -9
  243. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  244. package/types/src/hosting/hosted-service.d.ts +0 -31
  245. package/types/src/http/http.compression.d.ts +0 -32
  246. package/types/src/http/http.extensions.d.ts +0 -79
  247. package/types/src/http/http.options.d.ts +0 -39
  248. package/types/src/http/http.static.d.ts +0 -160
  249. package/types/src/index.d.ts +0 -10
  250. package/types/src/openapi/index.d.ts +0 -6
  251. package/types/src/openapi/openapi.generator.d.ts +0 -6
  252. package/types/src/openapi/openapi.types.d.ts +0 -58
  253. package/types/src/openapi/request-schema.d.ts +0 -21
  254. package/types/src/openapi/request-validator.d.ts +0 -9
  255. package/types/src/providers/fastify.provider.d.ts +0 -56
  256. package/types/src/routing/anvil.types.d.ts +0 -119
  257. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  258. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  259. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  260. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  261. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  262. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  263. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  264. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  265. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  266. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  267. package/types/src/routing/middleware/middleware.d.ts +0 -10
  268. package/types/src/routing/middleware/request-id.d.ts +0 -6
  269. package/types/src/routing/path.util.d.ts +0 -9
  270. package/types/src/routing/pipes/pipes.d.ts +0 -21
  271. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  272. package/types/src/routing/services/route-registry.d.ts +0 -20
  273. package/types/src/routing/services/router.d.ts +0 -22
  274. package/types/src/sse/sse.decorator.d.ts +0 -17
  275. package/types/src/sse/sse.encoder.d.ts +0 -16
  276. package/types/src/sse/sse.types.d.ts +0 -37
  277. package/types/src/sse/sse.wire.d.ts +0 -9
  278. package/types/src/testing/boot-module.d.ts +0 -34
  279. package/types/src/testing/sse-reader.d.ts +0 -29
  280. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  281. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  282. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  283. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  284. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  285. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  286. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  287. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  288. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  289. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  290. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  291. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  292. /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
@@ -0,0 +1,284 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
5
+ import "../src/health/health.extensions.js";
6
+ import { Controller, Get } from "../src/index.js";
7
+ import type { OpenApiDocument } from "../src/openapi/openapi.types.js";
8
+ import { bootModule } from "../src/testing/index.js";
9
+
10
+ // Static files are read by the real server from real disk.
11
+ vi.unmock("fs");
12
+ vi.unmock("fs/promises");
13
+ vi.unmock("node:fs");
14
+ vi.unmock("node:fs/promises");
15
+
16
+ @Controller({ prefix: "/lp" })
17
+ class PortfolioController {
18
+ @Get("/portfolio")
19
+ public portfolio() {
20
+ return { positions: [] };
21
+ }
22
+ }
23
+
24
+ @Controller({ prefix: "/" })
25
+ class PingController {
26
+ @Get("/ping")
27
+ public ping() {
28
+ return { pong: true };
29
+ }
30
+ }
31
+
32
+ @Controller({ prefix: "/webhooks", globalPrefix: false })
33
+ class WebhookController {
34
+ @Get("/:source")
35
+ public receive() {
36
+ return { received: true };
37
+ }
38
+ }
39
+
40
+ /** Declares `/health` itself: only the opt-out moves a route, never its name. */
41
+ @Controller({ prefix: "/" })
42
+ class OwnHealthController {
43
+ @Get("/health")
44
+ public health() {
45
+ return { mine: true };
46
+ }
47
+ }
48
+
49
+ /** What a browser sends when it navigates: the SPA fallback only answers this kind of request. */
50
+ const browser = { accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8" };
51
+
52
+ let ui: string;
53
+
54
+ beforeAll(async () => {
55
+ ui = await mkdtemp(path.join(tmpdir(), "http-prefix-"));
56
+ await writeFile(path.join(ui, "index.html"), "<!doctype html><title>the app</title>");
57
+ });
58
+
59
+ afterAll(async () => {
60
+ await rm(ui, { recursive: true, force: true });
61
+ });
62
+
63
+ type Configure = NonNullable<Parameters<typeof bootModule>[0]["configure"]>;
64
+ type TestBuilder = Parameters<Configure>[0];
65
+
66
+ function boot(configure: Configure) {
67
+ return bootModule({ controllers: [PortfolioController, PingController], configure });
68
+ }
69
+
70
+ /** Status of a GET. The body is read so a streamed file never holds up stopping the app. */
71
+ async function status(
72
+ api: { baseUrl: string },
73
+ url: string,
74
+ headers?: Record<string, string>,
75
+ ) {
76
+ const response = await fetch(`${api.baseUrl}${url}`, { headers });
77
+ await response.arrayBuffer();
78
+ return response.status;
79
+ }
80
+
81
+ describe("the global prefix and the probes (#646)", () => {
82
+ it("keeps /health and /ready at the root, and prefixes the controllers", async () => {
83
+ await using api = await boot((b) => b.withHealth().withGlobalPrefix("/api"));
84
+
85
+ expect(await status(api, "/health")).toBe(200);
86
+ expect(await status(api, "/ready")).toBe(200);
87
+ expect(await status(api, "/api/health")).toBe(404);
88
+ expect(await status(api, "/api/ready")).toBe(404);
89
+ expect(await status(api, "/api/lp/portfolio")).toBe(200);
90
+ expect(await status(api, "/api/ping")).toBe(200);
91
+ expect(await status(api, "/lp/portfolio")).toBe(404);
92
+ expect(await status(api, "/ping")).toBe(404);
93
+ });
94
+
95
+ it("keeps the probes at the root whichever order the builder is called in", async () => {
96
+ await using api = await boot((b) => b.withGlobalPrefix("/api").withHealth());
97
+
98
+ expect(await status(api, "/health")).toBe(200);
99
+ expect(await status(api, "/api/health")).toBe(404);
100
+ });
101
+
102
+ it("leaves a controller that opts out unprefixed, parameters included", async () => {
103
+ await using api = await bootModule({
104
+ controllers: [PortfolioController, WebhookController],
105
+ configure: (b) => b.withGlobalPrefix("/api"),
106
+ });
107
+
108
+ expect(await status(api, "/webhooks/stripe")).toBe(200);
109
+ expect(await status(api, "/api/webhooks/stripe")).toBe(404);
110
+ expect(await status(api, "/api/lp/portfolio")).toBe(200);
111
+ });
112
+
113
+ it("prefixes a controller that declares /health itself: the path alone moves nothing", async () => {
114
+ await using api = await bootModule({
115
+ controllers: [OwnHealthController],
116
+ configure: (b) => b.withGlobalPrefix("/api"),
117
+ });
118
+
119
+ expect(await status(api, "/api/health")).toBe(200);
120
+ expect(await status(api, "/health")).toBe(404);
121
+ });
122
+
123
+ it("puts the probes under the prefix with underGlobalPrefix", async () => {
124
+ await using api = await boot((b) =>
125
+ b.withHealth({ underGlobalPrefix: true }).withGlobalPrefix("/api"),
126
+ );
127
+
128
+ expect(await status(api, "/api/health")).toBe(200);
129
+ expect(await status(api, "/api/ready")).toBe(200);
130
+ expect(await status(api, "/health")).toBe(404);
131
+ expect(await status(api, "/ready")).toBe(404);
132
+ });
133
+
134
+ it("applies underGlobalPrefix whichever order the builder is called in", async () => {
135
+ await using api = await boot((b) =>
136
+ b.withGlobalPrefix("/api").withHealth({ underGlobalPrefix: true }),
137
+ );
138
+
139
+ expect(await status(api, "/api/health")).toBe(200);
140
+ expect(await status(api, "/health")).toBe(404);
141
+ });
142
+
143
+ it("serves the probes at the root with underGlobalPrefix and no prefix", async () => {
144
+ await using api = await boot((b) => b.withHealth({ underGlobalPrefix: true }));
145
+
146
+ expect(await status(api, "/health")).toBe(200);
147
+ expect(await status(api, "/ready")).toBe(200);
148
+ });
149
+
150
+ it("keeps the OpenAPI path where it was, and lists the prefixed routes", async () => {
151
+ await using api = await boot((b) =>
152
+ b
153
+ .withHealth()
154
+ .withGlobalPrefix("/api")
155
+ .withOpenApi({ info: { title: "t", version: "1" } }),
156
+ );
157
+
158
+ expect(await status(api, "/openapi.json")).toBe(200);
159
+ expect(await status(api, "/api/openapi.json")).toBe(404);
160
+ const doc = (await (await fetch(`${api.baseUrl}/openapi.json`)).json()) as OpenApiDocument;
161
+ expect(Object.keys(doc.paths)).toContain("/api/lp/portfolio");
162
+ });
163
+
164
+ it("changes nothing without a global prefix", async () => {
165
+ await using api = await boot((b) => b.withHealth());
166
+
167
+ expect(await status(api, "/health")).toBe(200);
168
+ expect(await status(api, "/ready")).toBe(200);
169
+ expect(await status(api, "/lp/portfolio")).toBe(200);
170
+ expect(await status(api, "/ping")).toBe(200);
171
+ });
172
+ });
173
+
174
+ describe("the static fallback with a global prefix (#647)", () => {
175
+ const withUi = (b: TestBuilder) =>
176
+ b.withHealth().withGlobalPrefix("/api").withStatic("/", ui);
177
+
178
+ it("gives the app to a UI route that shares a first segment with a prefixed controller", async () => {
179
+ await using api = await boot(withUi);
180
+
181
+ const page = await fetch(`${api.baseUrl}/lp/positions`, { headers: browser });
182
+ expect(page.status).toBe(200);
183
+ expect(await page.text()).toContain("the app");
184
+ });
185
+
186
+ it("keeps the JSON 404 at and below a route outside the prefix, even to a browser", async () => {
187
+ await using api = await bootModule({
188
+ controllers: [PortfolioController, WebhookController],
189
+ configure: (b) => b.withHealth().withGlobalPrefix("/api").withStatic("/", ui),
190
+ });
191
+
192
+ for (const url of ["/health/x", "/ready/", "/webhooks", "/webhooks/stripe/x"]) {
193
+ const response = await fetch(`${api.baseUrl}${url}`, { headers: browser });
194
+ expect(response.status, url).toBe(404);
195
+ expect(await response.text()).not.toContain("the app");
196
+ }
197
+ // The routes themselves answer, and names that only start the same get the app.
198
+ expect(await status(api, "/health", browser)).toBe(200);
199
+ expect(await status(api, "/webhooks/stripe", browser)).toBe(200);
200
+ expect(await status(api, "/healthy", browser)).toBe(200);
201
+ });
202
+
203
+ it("gives the app to a UI route that shares a first segment with the prefix", async () => {
204
+ await using api = await boot((b) => b.withGlobalPrefix("/api/v1").withStatic("/", ui));
205
+
206
+ const page = await fetch(`${api.baseUrl}/api/docs`, { headers: browser });
207
+ expect(page.status).toBe(200);
208
+ expect(await page.text()).toContain("the app");
209
+ expect(await status(api, "/api/v1/nothing", browser)).toBe(404);
210
+ });
211
+
212
+ it("keeps the JSON 404 for any path under the prefix, even to a browser", async () => {
213
+ await using api = await boot(withUi);
214
+
215
+ for (const url of ["/api", "/api/", "/api/lp/portfolo", "/api/nothing/here"]) {
216
+ const response = await fetch(`${api.baseUrl}${url}`, { headers: browser });
217
+ expect(response.status).toBe(404);
218
+ expect(await response.text()).not.toContain("the app");
219
+ }
220
+ });
221
+
222
+ it("keeps the JSON 404 under the prefix when no controller is under it", async () => {
223
+ await using api = await bootModule({
224
+ controllers: [],
225
+ configure: (b) => b.withGlobalPrefix("/api").withStatic("/", ui),
226
+ });
227
+
228
+ expect(await status(api, "/api/anything", browser)).toBe(404);
229
+ expect(await status(api, "/anything", browser)).toBe(200);
230
+ });
231
+
232
+ it("does not treat a name that only starts with the prefix as the API", async () => {
233
+ await using api = await boot(withUi);
234
+
235
+ const page = await fetch(`${api.baseUrl}/apiary`, { headers: browser });
236
+ expect(page.status).toBe(200);
237
+ expect(await page.text()).toContain("the app");
238
+ });
239
+
240
+ it("keeps the OpenAPI path out of the fallback", async () => {
241
+ await using api = await boot((b) =>
242
+ withUi(b).withOpenApi({
243
+ info: { title: "t", version: "1" },
244
+ path: "/spec/openapi.json",
245
+ }),
246
+ );
247
+
248
+ expect(await status(api, "/spec/openapi.json", browser)).toBe(200);
249
+ expect(await status(api, "/spec/openapi.json/more", browser)).toBe(404);
250
+ expect(await status(api, "/elsewhere", browser)).toBe(200);
251
+ });
252
+
253
+ it("keeps the other rules: method, extension, Accept and exclude", async () => {
254
+ await using api = await boot((b) =>
255
+ b.withGlobalPrefix("/api").withStatic("/", ui, { exclude: ["/docs/**"] }),
256
+ );
257
+ const call = (method: string) =>
258
+ fetch(`${api.baseUrl}/lp/positions`, { method, headers: browser }).then((r) => r.status);
259
+
260
+ expect(await status(api, "/lp/positions", { accept: "application/json" })).toBe(404);
261
+ expect(await status(api, "/lp/positions")).toBe(404);
262
+ expect(await status(api, "/lp/gone.js", browser)).toBe(404);
263
+ expect(await status(api, "/docs/x", browser)).toBe(404);
264
+ expect(await call("POST")).toBe(404);
265
+ expect(await call("HEAD")).toBe(200);
266
+ });
267
+
268
+ it("still prefixes the controllers and keeps the probes at the root", async () => {
269
+ await using api = await boot(withUi);
270
+
271
+ expect(await status(api, "/api/lp/portfolio")).toBe(200);
272
+ expect(await status(api, "/health")).toBe(200);
273
+ expect(await status(api, "/")).toBe(200);
274
+ });
275
+
276
+ it("keeps the first-segment rule without a global prefix", async () => {
277
+ await using api = await boot((b) => b.withHealth().withStatic("/", ui));
278
+
279
+ expect(await status(api, "/lp/positions", browser)).toBe(404);
280
+ expect(await status(api, "/health/x", browser)).toBe(404);
281
+ expect(await status(api, "/portfolio", browser)).toBe(200);
282
+ expect(await status(api, "/lpx", browser)).toBe(200);
283
+ });
284
+ });
@@ -0,0 +1,65 @@
1
+ import axios from "axios";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { Builder } from "../src/builder/builder.js";
5
+ import "../src/http/http.extensions.js";
6
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
7
+ import { Controller, Get, Query } from "../src/routing/index.js";
8
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
9
+
10
+ @Controller({ prefix: "/page" })
11
+ class PageController {
12
+ @Get()
13
+ public get(@Query("size") size: number) {
14
+ return { size: size ?? null };
15
+ }
16
+ }
17
+
18
+ // #503: a value that isn't a number used to reach the handler as `undefined`, so
19
+ // `?size=abc` behaved like `?size` was missing. It must be rejected with 400, like the
20
+ // boolean and date pipes do.
21
+ describe("NumberPipe over HTTP query", () => {
22
+ let app: App;
23
+
24
+ afterEach(async () => {
25
+ await app?.dispose();
26
+ });
27
+
28
+ async function start() {
29
+ app = await Builder.create()
30
+ .withHttpProvider(FastifyProvider)
31
+ .withControllers(PageController)
32
+ .build();
33
+ axios.defaults.baseURL = await listenEphemeral(app);
34
+ }
35
+
36
+ it.each([
37
+ ["10", 10],
38
+ ["-3", -3],
39
+ ["2.5", 2.5],
40
+ [" 7 ", 7],
41
+ ] as const)("binds ?size=%s to %s", async (raw, expected) => {
42
+ await start();
43
+ const res = await axios.get("/page", { params: { size: raw } });
44
+ expect(res.data).toEqual({ size: expected });
45
+ });
46
+
47
+ it("leaves a missing value undefined", async () => {
48
+ await start();
49
+ const res = await axios.get("/page");
50
+ expect(res.data).toEqual({ size: null });
51
+ });
52
+
53
+ it.each(["abc", "", " ", "12abc", "Infinity", "NaN"])(
54
+ "rejects ?size=%j with 400",
55
+ async (raw) => {
56
+ await start();
57
+ const res = await axios.get("/page", {
58
+ params: { size: raw },
59
+ validateStatus: () => true,
60
+ });
61
+ expect(res.status).toBe(400);
62
+ expect(res.data).toMatchObject({ statusCode: 400, error: "Bad Request" });
63
+ },
64
+ );
65
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Reproduces Flux hot-reload + pino-roll: App.dispose must finish so reboot can listen again.
3
+ *
4
+ * Scaffold pattern: scribe.withPinoPretty().and.withPinoRoll({ file }) then export default app.
5
+ * Flux disposeEntry → App.dispose → container asyncDispose → scribe.stop() must not hang on ThreadStream.end().
6
+ */
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import "@decohere/scribe-pino";
10
+ import { scribe } from "@decohere/scribe";
11
+ import axios from "axios";
12
+ import { afterEach, describe, expect, it } from "vitest";
13
+ import { realFs } from "../../../test/fs.setup.js";
14
+ import type { App } from "../src/app/app.js";
15
+ import { Builder } from "../src/builder/builder.js";
16
+ import "../src/http/http.extensions.js";
17
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
18
+ import { Controller, Get } from "../src/routing/index.js";
19
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
20
+
21
+ @Controller({ prefix: "/funding-arb" })
22
+ class FundingArbStubController {
23
+ @Get()
24
+ public state() {
25
+ return { ok: true };
26
+ }
27
+ }
28
+
29
+ async function bootWithPinoRoll(logFile: string): Promise<App> {
30
+ scribe.withPinoPretty().and.withPinoRoll({
31
+ file: logFile,
32
+ when: "always",
33
+ size: "1m",
34
+ keep: 2,
35
+ mkdir: true,
36
+ });
37
+ return Builder.create()
38
+ .withHttp(FastifyProvider)
39
+ .withControllers(FundingArbStubController)
40
+ .build();
41
+ }
42
+
43
+ describe("Flux-style reboot with pino-roll", () => {
44
+ let logDir: string;
45
+ let app: App | undefined;
46
+
47
+ afterEach(async () => {
48
+ if (app != null) {
49
+ await app.dispose();
50
+ app = undefined;
51
+ }
52
+ await scribe.stop();
53
+ if (logDir != null) {
54
+ realFs.rmSync(logDir, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ it("listens again after App.dispose + withPinoRoll reconfigure (HMR cycle)", async () => {
59
+ logDir = realFs.mkdtempSync(join(tmpdir(), "http-pino-roll-hmr-"));
60
+ const logFile = join(logDir, "funding-fee.api.log");
61
+
62
+ app = await bootWithPinoRoll(logFile);
63
+ const origin1 = await listenEphemeral(app);
64
+ const first = await axios.get(`${origin1}/funding-arb`);
65
+ expect(first.status).toBe(200);
66
+ expect(first.data).toEqual({ ok: true });
67
+
68
+ const dying = app;
69
+ app = undefined;
70
+ await dying.dispose();
71
+
72
+ app = await bootWithPinoRoll(logFile);
73
+ const origin2 = await listenEphemeral(app);
74
+ const second = await axios.get(`${origin2}/funding-arb`, { timeout: 5_000 });
75
+
76
+ expect(second.status).toBe(200);
77
+ expect(second.data).toEqual({ ok: true });
78
+ }, 20_000);
79
+ });
@@ -0,0 +1,95 @@
1
+ import type { IConfigurationService } from "@decohere/core/configuration";
2
+ import { ConfigurationServiceToken } from "@decohere/core/configuration";
3
+ import type { Constructable } from "@decohere/core/reflection";
4
+ import { scribe } from "@decohere/scribe";
5
+ import axios from "axios";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import type { App } from "../src/app/app.js";
8
+ import { AuthConfig } from "../src/auth/auth.config.js";
9
+ import "../src/auth/auth.extensions.js";
10
+ import { Builder } from "../src/builder/builder.js";
11
+ import "../src/http/http.extensions.js";
12
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
13
+ import {
14
+ Authorize,
15
+ Controller,
16
+ Field,
17
+ HttpAccessLogMiddleware,
18
+ Post,
19
+ RequestParts,
20
+ } from "../src/routing/index.js";
21
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
22
+
23
+ class SecretConfigService implements IConfigurationService {
24
+ public get dataSources() {
25
+ return [];
26
+ }
27
+ public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
28
+ if (type === AuthConfig) {
29
+ const config = new AuthConfig();
30
+ config.secret = "test-secret-at-least-32-bytes-long!!";
31
+ config.audience = "test-audience";
32
+ return config as unknown as T;
33
+ }
34
+ return {} as T;
35
+ }
36
+ }
37
+
38
+ class OrderBody {
39
+ @Field(RequestParts.body, "item")
40
+ public item!: string;
41
+ }
42
+
43
+ @Controller({ prefix: "/orders" })
44
+ class OrderController {
45
+ @Authorize("create", "Order")
46
+ @Post()
47
+ public create(body: OrderBody) {
48
+ return { item: body.item };
49
+ }
50
+ }
51
+
52
+ // #501: auth must run before validation (anonymous callers learn nothing about the body
53
+ // schema), and both must run inside the middleware chain so rejections are access-logged.
54
+ describe("request pipeline order", () => {
55
+ let app: App;
56
+ const accessLog = { info: vi.fn() };
57
+
58
+ beforeEach(async () => {
59
+ const original = scribe.logger.bind(scribe);
60
+ vi.spyOn(scribe, "logger").mockImplementation((name: string) =>
61
+ name === HttpAccessLogMiddleware.name ? (accessLog as never) : original(name),
62
+ );
63
+ app = await Builder.create()
64
+ .withOpenApi({ info: { title: "Orders", version: "1.0.0" }, path: "/openapi.json" })
65
+ .withHttp(FastifyProvider)
66
+ .withServices((c) => {
67
+ c.registerSingleton(ConfigurationServiceToken, new SecretConfigService());
68
+ })
69
+ .withAuth()
70
+ .withControllers(OrderController)
71
+ .build();
72
+ axios.defaults.baseURL = await listenEphemeral(app);
73
+ });
74
+
75
+ afterEach(async () => {
76
+ await app.dispose();
77
+ vi.restoreAllMocks();
78
+ accessLog.info.mockReset();
79
+ });
80
+
81
+ it("rejects an anonymous request with 401 before looking at the body", async () => {
82
+ const res = await axios.post("/orders", { wrong: 1 }, { validateStatus: () => true });
83
+
84
+ expect(res.status).toBe(401);
85
+ expect(JSON.stringify(res.data)).not.toMatch(/item|additional property/);
86
+ });
87
+
88
+ it("access-logs requests rejected by auth", async () => {
89
+ await axios.post("/orders", {}, { validateStatus: () => true });
90
+
91
+ expect(accessLog.info).toHaveBeenCalledWith(
92
+ expect.objectContaining({ msg: "access", path: "/orders", status: 401 }),
93
+ );
94
+ });
95
+ });
@@ -0,0 +1,57 @@
1
+ import { inject, injectable } from "@decohere/di/decorators";
2
+ import axios from "axios";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import type { App } from "../src/app/app.js";
5
+ import { Builder } from "../src/builder/builder.js";
6
+ import "../src/http/http.extensions.js";
7
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
8
+ import { Controller, Get } from "../src/routing/index.js";
9
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
10
+
11
+ const cleanedUp: string[] = [];
12
+ const WorkToken = Symbol("RequestWork");
13
+
14
+ /** Stands in for a unit of work: its async cleanup must finish before the response goes out. */
15
+ @injectable()
16
+ class RequestWork {
17
+ public async [Symbol.asyncDispose]() {
18
+ await new Promise((resolve) => setTimeout(resolve, 20));
19
+ cleanedUp.push("done");
20
+ }
21
+ }
22
+
23
+ @Controller({ prefix: "/work" })
24
+ class WorkController {
25
+ public constructor(@inject(WorkToken) private readonly work: RequestWork) {}
26
+
27
+ @Get("/fail")
28
+ public fail() {
29
+ void this.work;
30
+ throw new Error("boom");
31
+ }
32
+ }
33
+
34
+ // #502: per-request objects were disposed synchronously, so async cleanup (rolling back a
35
+ // transaction, releasing a DB connection) was started but never awaited.
36
+ describe("per-request cleanup", () => {
37
+ let app: App;
38
+
39
+ afterEach(async () => {
40
+ await app?.dispose();
41
+ cleanedUp.length = 0;
42
+ });
43
+
44
+ it("awaits async cleanup of request-scoped services, even when the handler throws", async () => {
45
+ app = await Builder.create()
46
+ .withHttp(FastifyProvider)
47
+ .withServices((c) => c.registerRequestScoped(WorkToken, RequestWork))
48
+ .withControllers(WorkController)
49
+ .build();
50
+ const baseURL = await listenEphemeral(app);
51
+
52
+ const res = await axios.get("/work/fail", { baseURL, validateStatus: () => true });
53
+
54
+ expect(res.status).toBe(500);
55
+ expect(cleanedUp).toEqual(["done"]);
56
+ });
57
+ });
@@ -0,0 +1,50 @@
1
+ import axios from "axios";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { Builder } from "../src/builder/builder.js";
5
+ import "../src/http/http.extensions.js";
6
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
7
+ import { Controller, Get, REQUEST_ID_HEADER } from "../src/routing/index.js";
8
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
9
+
10
+ @Controller({ prefix: "/slow" })
11
+ class SlowController {
12
+ @Get()
13
+ public async slow() {
14
+ // Keep the request open so a second one with the same id overlaps it.
15
+ await new Promise((resolve) => setTimeout(resolve, 50));
16
+ return { ok: true };
17
+ }
18
+ }
19
+
20
+ // #497: the request id comes from the client. Two overlapping requests that send the same
21
+ // x-request-id (a retry, a proxy, or an attacker) must both succeed.
22
+ describe("pen test: client-chosen x-request-id", () => {
23
+ let app: App;
24
+
25
+ afterEach(async () => {
26
+ await app?.dispose();
27
+ });
28
+
29
+ it("serves concurrent requests that share an x-request-id", async () => {
30
+ app = await Builder.create()
31
+ .withHttp(FastifyProvider)
32
+ .withControllers(SlowController)
33
+ .build();
34
+ const baseURL = await listenEphemeral(app);
35
+ const headers = { [REQUEST_ID_HEADER]: "same-id" };
36
+
37
+ const responses = await Promise.all(
38
+ [1, 2, 3].map(() =>
39
+ axios.get("/slow", { baseURL, headers, validateStatus: () => true }),
40
+ ),
41
+ );
42
+
43
+ expect(responses.map((r) => r.status)).toEqual([200, 200, 200]);
44
+ expect(responses.map((r) => r.headers[REQUEST_ID_HEADER])).toEqual([
45
+ "same-id",
46
+ "same-id",
47
+ "same-id",
48
+ ]);
49
+ });
50
+ });