@decohere/http 0.0.1-dev.126 → 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 (293) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -13
  3. package/src/app/app.ts +147 -0
  4. package/src/app/module.ts +19 -0
  5. package/src/auth/ability.ts +56 -0
  6. package/src/auth/auth.config.ts +34 -0
  7. package/src/auth/auth.extensions.ts +115 -0
  8. package/src/auth/auth.types.ts +124 -0
  9. package/src/auth/casl-authorization.service.ts +62 -0
  10. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  11. package/src/auth/safe-outbound-url.ts +117 -0
  12. package/src/auth/token-service.ts +192 -0
  13. package/src/builder/builder.ts +118 -0
  14. package/{es6/src/const.js → src/const.ts} +25 -17
  15. package/src/health/health.controller.ts +34 -0
  16. package/src/health/health.extensions.ts +53 -0
  17. package/src/health/readiness.service.ts +33 -0
  18. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  19. package/src/hosting/hosted-service-host.ts +102 -0
  20. package/src/hosting/hosted-service.ts +31 -0
  21. package/src/http/http.compression.ts +38 -0
  22. package/src/http/http.extensions.ts +244 -0
  23. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  24. package/src/http/http.options.ts +41 -0
  25. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  26. package/src/http/http.static.ts +359 -0
  27. package/{es6/src/index.js → src/index.ts} +1 -0
  28. package/src/openapi/index.ts +22 -0
  29. package/src/openapi/openapi.generator.ts +78 -0
  30. package/src/openapi/openapi.types.ts +60 -0
  31. package/src/openapi/request-schema.ts +155 -0
  32. package/src/openapi/request-validator.ts +95 -0
  33. package/src/providers/fastify.provider.ts +454 -0
  34. package/src/routing/anvil.types.ts +150 -0
  35. package/src/routing/decorators/argument.decorator.ts +33 -0
  36. package/src/routing/decorators/auth.decorator.ts +79 -0
  37. package/src/routing/decorators/controller.decorator.ts +9 -0
  38. package/src/routing/decorators/field.decorator.ts +15 -0
  39. package/src/routing/decorators/request.decorator.ts +34 -0
  40. package/src/routing/decorators/response.decorator.ts +18 -0
  41. package/src/routing/decorators/route.decorator.ts +52 -0
  42. package/src/routing/filters/exception-filter-host.ts +90 -0
  43. package/src/routing/filters/exception-filter.ts +29 -0
  44. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  45. package/src/routing/middleware/middleware.ts +26 -0
  46. package/src/routing/middleware/request-id.ts +18 -0
  47. package/src/routing/path.util.ts +38 -0
  48. package/src/routing/pipes/pipes.ts +132 -0
  49. package/src/routing/services/route-argument-resolver.ts +92 -0
  50. package/src/routing/services/route-registry.ts +229 -0
  51. package/src/routing/services/router.ts +166 -0
  52. package/src/sse/sse.decorator.ts +41 -0
  53. package/src/sse/sse.encoder.ts +86 -0
  54. package/src/sse/sse.types.ts +47 -0
  55. package/src/sse/sse.wire.ts +34 -0
  56. package/src/testing/boot-module.ts +63 -0
  57. package/src/testing/sse-reader.ts +120 -0
  58. package/tests/app.hosted-services.spec.ts +292 -0
  59. package/tests/app.shutdown.integration.spec.ts +160 -0
  60. package/tests/app.spec.ts +132 -0
  61. package/tests/auth.ability.spec.ts +48 -0
  62. package/tests/auth.decorators.spec.ts +20 -0
  63. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  64. package/tests/auth.integration.spec.ts +234 -0
  65. package/tests/auth.jwks-cache.spec.ts +52 -0
  66. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  67. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  68. package/tests/auth.oidc.integration.spec.ts +161 -0
  69. package/tests/auth.token-service.spec.ts +251 -0
  70. package/tests/builder.spec.ts +12 -0
  71. package/tests/health.integration.spec.ts +111 -0
  72. package/tests/helpers/listen-ephemeral.ts +13 -0
  73. package/tests/helpers/local-oidc-server.ts +100 -0
  74. package/tests/http.access-log.integration.spec.ts +111 -0
  75. package/tests/http.boolean-query.integration.spec.ts +59 -0
  76. package/tests/http.compression.integration.spec.ts +604 -0
  77. package/tests/http.date-query.integration.spec.ts +71 -0
  78. package/tests/http.global-prefix.integration.spec.ts +284 -0
  79. package/tests/http.number-query.integration.spec.ts +65 -0
  80. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  81. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  82. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  83. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  84. package/tests/http.security.integration.spec.ts +106 -0
  85. package/tests/http.static.integration.spec.ts +847 -0
  86. package/tests/http.static.spec.ts +111 -0
  87. package/tests/http.surface.integration.spec.ts +167 -0
  88. package/tests/openapi.integration.spec.ts +193 -0
  89. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  90. package/tests/routing.decorators.spec.ts +15 -0
  91. package/tests/routing.registry.spec.ts +44 -0
  92. package/tests/routing.resolver.spec.ts +92 -0
  93. package/tests/sse.integration.spec.ts +421 -0
  94. package/tests/sse.wire.spec.ts +33 -0
  95. package/LICENSE +0 -15
  96. package/commonjs/src/app/app.js +0 -166
  97. package/commonjs/src/app/index.js +0 -19
  98. package/commonjs/src/app/module.js +0 -17
  99. package/commonjs/src/auth/ability.js +0 -54
  100. package/commonjs/src/auth/auth.config.js +0 -23
  101. package/commonjs/src/auth/auth.extensions.js +0 -106
  102. package/commonjs/src/auth/auth.types.js +0 -65
  103. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  104. package/commonjs/src/auth/index.js +0 -23
  105. package/commonjs/src/auth/principal-provider.js +0 -19
  106. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  107. package/commonjs/src/auth/token-service.js +0 -151
  108. package/commonjs/src/builder/builder.js +0 -95
  109. package/commonjs/src/builder/index.js +0 -18
  110. package/commonjs/src/const.js +0 -87
  111. package/commonjs/src/health/health.controller.js +0 -75
  112. package/commonjs/src/health/health.extensions.js +0 -50
  113. package/commonjs/src/health/index.js +0 -21
  114. package/commonjs/src/health/readiness.service.js +0 -67
  115. package/commonjs/src/health/readiness.types.js +0 -7
  116. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  117. package/commonjs/src/hosting/hosted-service.js +0 -11
  118. package/commonjs/src/hosting/index.js +0 -19
  119. package/commonjs/src/http/http.compression.js +0 -27
  120. package/commonjs/src/http/http.extensions.js +0 -269
  121. package/commonjs/src/http/http.options.js +0 -26
  122. package/commonjs/src/http/http.options.store.js +0 -20
  123. package/commonjs/src/http/http.security.js +0 -27
  124. package/commonjs/src/http/http.static.js +0 -248
  125. package/commonjs/src/http/index.js +0 -22
  126. package/commonjs/src/index.js +0 -27
  127. package/commonjs/src/openapi/index.js +0 -45
  128. package/commonjs/src/openapi/openapi.generator.js +0 -103
  129. package/commonjs/src/openapi/openapi.types.js +0 -4
  130. package/commonjs/src/openapi/request-schema.js +0 -187
  131. package/commonjs/src/openapi/request-validator.js +0 -85
  132. package/commonjs/src/providers/fastify.provider.js +0 -391
  133. package/commonjs/src/providers/index.js +0 -18
  134. package/commonjs/src/routing/anvil.types.js +0 -99
  135. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  136. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  137. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  138. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  139. package/commonjs/src/routing/decorators/index.js +0 -24
  140. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  141. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  142. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  143. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  144. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  145. package/commonjs/src/routing/index.js +0 -25
  146. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  147. package/commonjs/src/routing/middleware/middleware.js +0 -20
  148. package/commonjs/src/routing/middleware/request-id.js +0 -30
  149. package/commonjs/src/routing/path.util.js +0 -51
  150. package/commonjs/src/routing/pipes/index.js +0 -18
  151. package/commonjs/src/routing/pipes/pipes.js +0 -145
  152. package/commonjs/src/routing/services/index.js +0 -18
  153. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  154. package/commonjs/src/routing/services/route-registry.js +0 -194
  155. package/commonjs/src/routing/services/router.js +0 -158
  156. package/commonjs/src/sse/index.js +0 -26
  157. package/commonjs/src/sse/sse.decorator.js +0 -42
  158. package/commonjs/src/sse/sse.encoder.js +0 -73
  159. package/commonjs/src/sse/sse.types.js +0 -36
  160. package/commonjs/src/sse/sse.wire.js +0 -40
  161. package/commonjs/src/testing/boot-module.js +0 -38
  162. package/commonjs/src/testing/index.js +0 -20
  163. package/commonjs/src/testing/sse-reader.js +0 -98
  164. package/es6/src/app/app.js +0 -148
  165. package/es6/src/app/index.js +0 -2
  166. package/es6/src/app/module.js +0 -7
  167. package/es6/src/auth/ability.js +0 -45
  168. package/es6/src/auth/auth.config.js +0 -19
  169. package/es6/src/auth/auth.extensions.js +0 -83
  170. package/es6/src/auth/auth.types.js +0 -35
  171. package/es6/src/auth/casl-authorization.service.js +0 -40
  172. package/es6/src/auth/index.js +0 -6
  173. package/es6/src/auth/principal-provider.js +0 -14
  174. package/es6/src/auth/safe-outbound-url.js +0 -99
  175. package/es6/src/auth/token-service.js +0 -141
  176. package/es6/src/builder/builder.js +0 -85
  177. package/es6/src/builder/index.js +0 -1
  178. package/es6/src/health/health.controller.js +0 -65
  179. package/es6/src/health/health.extensions.js +0 -40
  180. package/es6/src/health/index.js +0 -4
  181. package/es6/src/health/readiness.service.js +0 -57
  182. package/es6/src/health/readiness.types.js +0 -4
  183. package/es6/src/hosting/hosted-service-host.js +0 -81
  184. package/es6/src/hosting/hosted-service.js +0 -8
  185. package/es6/src/hosting/index.js +0 -2
  186. package/es6/src/http/http.compression.js +0 -9
  187. package/es6/src/http/http.extensions.js +0 -259
  188. package/es6/src/http/http.options.js +0 -16
  189. package/es6/src/http/http.options.store.js +0 -13
  190. package/es6/src/http/http.security.js +0 -9
  191. package/es6/src/http/http.static.js +0 -224
  192. package/es6/src/http/index.js +0 -5
  193. package/es6/src/openapi/index.js +0 -3
  194. package/es6/src/openapi/openapi.generator.js +0 -82
  195. package/es6/src/openapi/openapi.types.js +0 -1
  196. package/es6/src/openapi/request-schema.js +0 -160
  197. package/es6/src/openapi/request-validator.js +0 -71
  198. package/es6/src/providers/fastify.provider.js +0 -376
  199. package/es6/src/providers/index.js +0 -1
  200. package/es6/src/routing/anvil.types.js +0 -57
  201. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  202. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  203. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  204. package/es6/src/routing/decorators/field.decorator.js +0 -15
  205. package/es6/src/routing/decorators/index.js +0 -7
  206. package/es6/src/routing/decorators/request.decorator.js +0 -24
  207. package/es6/src/routing/decorators/response.decorator.js +0 -16
  208. package/es6/src/routing/decorators/route.decorator.js +0 -37
  209. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  210. package/es6/src/routing/filters/exception-filter.js +0 -10
  211. package/es6/src/routing/index.js +0 -8
  212. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  213. package/es6/src/routing/middleware/middleware.js +0 -10
  214. package/es6/src/routing/middleware/request-id.js +0 -14
  215. package/es6/src/routing/path.util.js +0 -33
  216. package/es6/src/routing/pipes/index.js +0 -1
  217. package/es6/src/routing/pipes/pipes.js +0 -118
  218. package/es6/src/routing/services/index.js +0 -1
  219. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  220. package/es6/src/routing/services/route-registry.js +0 -184
  221. package/es6/src/routing/services/router.js +0 -148
  222. package/es6/src/sse/index.js +0 -2
  223. package/es6/src/sse/sse.decorator.js +0 -31
  224. package/es6/src/sse/sse.encoder.js +0 -67
  225. package/es6/src/sse/sse.types.js +0 -22
  226. package/es6/src/sse/sse.wire.js +0 -30
  227. package/es6/src/testing/boot-module.js +0 -35
  228. package/es6/src/testing/index.js +0 -2
  229. package/es6/src/testing/sse-reader.js +0 -91
  230. package/types/src/app/app.d.ts +0 -41
  231. package/types/src/app/module.d.ts +0 -15
  232. package/types/src/auth/ability.d.ts +0 -19
  233. package/types/src/auth/auth.config.d.ts +0 -28
  234. package/types/src/auth/auth.extensions.d.ts +0 -53
  235. package/types/src/auth/auth.types.d.ts +0 -73
  236. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  237. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  238. package/types/src/auth/token-service.d.ts +0 -31
  239. package/types/src/builder/builder.d.ts +0 -36
  240. package/types/src/const.d.ts +0 -32
  241. package/types/src/health/health.controller.d.ts +0 -16
  242. package/types/src/health/health.extensions.d.ts +0 -31
  243. package/types/src/health/readiness.service.d.ts +0 -9
  244. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  245. package/types/src/hosting/hosted-service.d.ts +0 -31
  246. package/types/src/http/http.compression.d.ts +0 -32
  247. package/types/src/http/http.extensions.d.ts +0 -79
  248. package/types/src/http/http.options.d.ts +0 -39
  249. package/types/src/http/http.static.d.ts +0 -160
  250. package/types/src/index.d.ts +0 -10
  251. package/types/src/openapi/index.d.ts +0 -6
  252. package/types/src/openapi/openapi.generator.d.ts +0 -6
  253. package/types/src/openapi/openapi.types.d.ts +0 -58
  254. package/types/src/openapi/request-schema.d.ts +0 -21
  255. package/types/src/openapi/request-validator.d.ts +0 -9
  256. package/types/src/providers/fastify.provider.d.ts +0 -56
  257. package/types/src/routing/anvil.types.d.ts +0 -119
  258. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  259. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  260. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  261. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  262. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  263. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  264. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  265. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  266. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  267. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  268. package/types/src/routing/middleware/middleware.d.ts +0 -10
  269. package/types/src/routing/middleware/request-id.d.ts +0 -6
  270. package/types/src/routing/path.util.d.ts +0 -9
  271. package/types/src/routing/pipes/pipes.d.ts +0 -21
  272. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  273. package/types/src/routing/services/route-registry.d.ts +0 -20
  274. package/types/src/routing/services/router.d.ts +0 -22
  275. package/types/src/sse/sse.decorator.d.ts +0 -17
  276. package/types/src/sse/sse.encoder.d.ts +0 -16
  277. package/types/src/sse/sse.types.d.ts +0 -37
  278. package/types/src/sse/sse.wire.d.ts +0 -9
  279. package/types/src/testing/boot-module.d.ts +0 -34
  280. package/types/src/testing/sse-reader.d.ts +0 -29
  281. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  282. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  283. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  284. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  285. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  286. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  287. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  288. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  289. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  290. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  291. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  292. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  293. /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
package/README.md CHANGED
@@ -58,7 +58,7 @@ Builder.create()
58
58
 
59
59
  **`exclude`.** Patterns for paths the mount never answers, neither a file nor the fallback (the request gets the JSON `404`), for anything the defaults get wrong. They are globs ([picomatch](https://github.com/micromatch/picomatch), `dot` on) matched against the full request path after decoding it and collapsing repeated slashes, not relative to the prefix: `*` is any characters within one segment, `**` crosses segments, `?` is one character. `"/docs/**"` covers `/docs`, `/docs/` and everything below.
60
60
 
61
- Why this shape, and what is left (running the UI's own build): [ADR 0007](../../docs/adr/0007-serving-a-built-ui-from-http.md).
61
+ Why this shape, and what is left (running the UI's own build is [#381](https://github.com/typesmiths/decohere/issues/381)): [ADR 0007](../../docs/adr/0007-serving-a-built-ui-from-http.md).
62
62
 
63
63
  ## Global prefix
64
64
 
@@ -113,7 +113,7 @@ What to know about the plugin's behaviour:
113
113
  - **A `304`** for a file can carry the `Content-Encoding` header of the `200` it stands for. It has no body.
114
114
  - **Helmet, CORS and the rate limit** are unaffected, and an error reply (a `401`, a `400` with a large body) is compressed like any other.
115
115
  - **BREACH.** Compressing a response served over HTTPS that contains both a secret and input an attacker controls can leak the secret to someone who can watch response sizes. It mostly concerns HTML that reflects input next to a CSRF token. A JSON API that authenticates with a bearer token in a header is low risk, because the token is not in the body. If you render such pages, mask per-response secrets, do not reflect input beside them, or turn compression off for them.
116
- - **Pre-compressed files.** Serving `.br`/`.gz` files built ahead of time is a possible later step; today static files are compressed on the fly.
116
+ - **Pre-compressed files.** Serving `.br`/`.gz` files built ahead of time is a possible later step ([#642](https://github.com/typesmiths/decohere/issues/642)); today static files are compressed on the fly.
117
117
  - **Testing.** Node's `fetch` negotiates and decodes compression itself. To see what is on the wire, use `node:http` and read the body undecoded.
118
118
 
119
119
  Why this shape: [ADR 0008](../../docs/adr/0008-response-compression-in-http.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/http",
3
- "version": "0.0.1-dev.126",
3
+ "version": "0.0.1-dev.5",
4
4
  "description": "Web API and microservice platform built on Fastify with decorator-based routing",
5
5
  "keywords": [
6
6
  "typescript",
@@ -12,15 +12,6 @@
12
12
  "decorators",
13
13
  "decohere"
14
14
  ],
15
- "homepage": "https://github.com/typesmiths/decohere/tree/main/projects/http#readme",
16
- "bugs": {
17
- "url": "https://github.com/typesmiths/decohere/issues"
18
- },
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/typesmiths/decohere.git",
22
- "directory": "projects/http"
23
- },
24
15
  "license": "ISC",
25
16
  "author": "Typesmiths",
26
17
  "type": "module",
@@ -56,9 +47,9 @@
56
47
  },
57
48
  "dependencies": {
58
49
  "@casl/ability": "^6.8.1",
59
- "@decohere/core": "0.0.1-dev.126",
60
- "@decohere/crucible": "0.0.1-dev.126",
61
- "@decohere/scribe": "0.0.1-dev.126",
50
+ "@decohere/di": "0.0.1-dev.5",
51
+ "@decohere/core": "0.0.1-dev.5",
52
+ "@decohere/scribe": "0.0.1-dev.5",
62
53
  "@fastify/compress": "^9.2.1",
63
54
  "@fastify/cors": "^11.3.0",
64
55
  "@fastify/helmet": "^13.1.0",
package/src/app/app.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { type LifetimeScope, LifetimeScopeToken } from "@decohere/di";
2
+ import { inject, optional } from "@decohere/di/decorators";
3
+ import {
4
+ ConfigurationServiceToken,
5
+ type IConfigurationService,
6
+ } from "@decohere/core/configuration";
7
+ import { scribe } from "@decohere/scribe";
8
+ import { HostedServiceHostToken, HttpProviderToken } from "../const.js";
9
+ import type { HostedServiceHost } from "../hosting/hosted-service-host.js";
10
+ import type { HttpProvider, StartResponse } from "../routing/anvil.types.js";
11
+
12
+ const DEFAULT_PORT = 3000;
13
+ const DEFAULT_SHUTDOWN_SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
14
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
15
+
16
+ export class ApiConfiguration {
17
+ public port: number;
18
+ }
19
+
20
+ export type StartOptions = {
21
+ port: number;
22
+ host?: string;
23
+ /**
24
+ * Signals that stop the app gracefully: stop accepting connections, let in-flight
25
+ * requests finish, stop hosted services, then dispose services. Default SIGTERM and SIGINT; `false` to
26
+ * handle signals yourself.
27
+ */
28
+ shutdownSignals?: readonly NodeJS.Signals[] | false;
29
+ /** How long a signalled shutdown may take before the process exits with code 1. Default 10s. */
30
+ shutdownTimeoutMs?: number;
31
+ };
32
+
33
+ export class App {
34
+ private _addresses = Array.of<string>();
35
+ private logger = scribe.logger(App.name);
36
+ private disposing: Promise<void> | undefined;
37
+ private disposingContainer = false;
38
+ private signalHandler: ((signal: NodeJS.Signals) => void) | undefined;
39
+ private shutdownSignals: readonly NodeJS.Signals[] = [];
40
+
41
+ public constructor(
42
+ @inject(LifetimeScopeToken) private readonly container: LifetimeScope,
43
+ @inject(ConfigurationServiceToken)
44
+ @optional()
45
+ private configurationService?: IConfigurationService,
46
+ @inject(HttpProviderToken) @optional() private httpProvider?: HttpProvider,
47
+ @inject(HostedServiceHostToken) @optional() private hostedServices?: HostedServiceHost,
48
+ ) {}
49
+
50
+ public get addresses(): readonly string[] {
51
+ return this._addresses;
52
+ }
53
+
54
+ public async start(options?: StartOptions): Promise<StartResponse> {
55
+ this.logger.info("Starting app");
56
+ // Before HTTP, so a request never reaches a service that has not started.
57
+ await this.hostedServices?.start(this.container);
58
+ // The app was stopped while a hosted service was still starting.
59
+ if (this.disposing != null) return;
60
+ const hasHostedServices = this.hostedServices != null && !this.hostedServices.isEmpty;
61
+ if (!this.httpProvider) {
62
+ if (hasHostedServices) this.stopOnSignals(options);
63
+ return;
64
+ }
65
+ const port = await this.getPort(options);
66
+ this.logger.info(`Starting http on port ${port}`);
67
+ const startResponse = await this.httpProvider?.start({
68
+ port,
69
+ ...(options?.host != null ? { host: options.host } : {}),
70
+ });
71
+ this._addresses = [...startResponse.addresses];
72
+ this.logger.info("Listening on:", this._addresses);
73
+ this.stopOnSignals(options);
74
+ return startResponse;
75
+ }
76
+
77
+ public [Symbol.asyncDispose]() {
78
+ // The container holds this App too; when stop() disposes the container it lands
79
+ // here, and awaiting our own shutdown would deadlock.
80
+ if (this.disposingContainer) return Promise.resolve();
81
+ return this.dispose();
82
+ }
83
+
84
+ /** Stop the app. Calling it again (or from a signal) returns the same shutdown. */
85
+ public dispose(): Promise<void> {
86
+ this.disposing ??= this.stop();
87
+ return this.disposing;
88
+ }
89
+
90
+ private async stop(): Promise<void> {
91
+ // Removed first, so a second Ctrl+C / SIGTERM gets Node's default: exit now.
92
+ this.removeSignalHandlers();
93
+ this.logger.info("Stopping");
94
+ // Hosted services hear the signal at once, so background work winds down while HTTP drains.
95
+ this.hostedServices?.abort();
96
+ try {
97
+ // HTTP first: in-flight requests still need the services (DB pools etc.) that
98
+ // disposing the container would close.
99
+ await this.httpProvider?.stop();
100
+ // Hosted services next, while what they use is still open.
101
+ await this.hostedServices?.stop();
102
+ } finally {
103
+ this.disposingContainer = true;
104
+ await this.container[Symbol.asyncDispose]();
105
+ await scribe.stop();
106
+ }
107
+ }
108
+
109
+ private stopOnSignals(options?: StartOptions) {
110
+ const signals = options?.shutdownSignals ?? DEFAULT_SHUTDOWN_SIGNALS;
111
+ const timeoutMs = options?.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
112
+ if (signals === false || signals.length === 0) return;
113
+ this.signalHandler = (signal) => {
114
+ this.logger.info(`Received ${signal}; finishing in-flight requests`);
115
+ // console.error, not the logger: scribe may be the thing that's stuck.
116
+ const timer = setTimeout(() => {
117
+ console.error(`Shutdown did not finish within ${timeoutMs}ms; exiting.`);
118
+ process.exit(1);
119
+ }, timeoutMs);
120
+ timer.unref();
121
+ this.dispose().then(
122
+ () => clearTimeout(timer),
123
+ (error: unknown) => {
124
+ clearTimeout(timer);
125
+ console.error("Shutdown failed:", error);
126
+ process.exit(1);
127
+ },
128
+ );
129
+ };
130
+ this.shutdownSignals = signals;
131
+ for (const signal of signals) process.on(signal, this.signalHandler);
132
+ }
133
+
134
+ private removeSignalHandlers() {
135
+ if (!this.signalHandler) return;
136
+ for (const signal of this.shutdownSignals) process.off(signal, this.signalHandler);
137
+ }
138
+
139
+ private async getPort(options?: StartOptions) {
140
+ const port =
141
+ options?.port ??
142
+ (await this.configurationService?.get(ApiConfiguration))?.port ??
143
+ DEFAULT_PORT;
144
+
145
+ return port;
146
+ }
147
+ }
@@ -0,0 +1,19 @@
1
+ import type { ContainerBuilder } from "@decohere/di";
2
+ import type { Constructable } from "@decohere/core/reflection";
3
+
4
+ export type ModuleDefinition = {
5
+ name: string;
6
+ entities?: Constructable[];
7
+ repositories?: Constructable[];
8
+ registerServices?(containerBuilder: ContainerBuilder): void;
9
+ };
10
+
11
+ export abstract class Module {
12
+ public abstract name: string;
13
+ public constructor(data: Partial<Module>) {
14
+ Object.assign(this, data);
15
+ }
16
+ public readonly entities?: readonly Constructable[];
17
+ public readonly repositories?: readonly Constructable[];
18
+ public abstract registerServices?(containerBuilder: ContainerBuilder): void;
19
+ }
@@ -0,0 +1,56 @@
1
+ import { AbilityBuilder, createMongoAbility } from "@casl/ability";
2
+ import type { Principal } from "./auth.types.js";
3
+
4
+ /** Root role — expands to CASL `manage`/`all` (covers every future ability). */
5
+ export const ADMIN_ROLE = "admin";
6
+
7
+ /**
8
+ * Default CASL ability factory.
9
+ *
10
+ * - `admin` role → `manage` / `all` (wildcard)
11
+ * - otherwise map `principal.scopes` of the form `"action:Subject"` to CASL rules
12
+ *
13
+ * Pass a custom factory to `withAuth()` (or override the app stub) for product-specific rules.
14
+ */
15
+ export function defineAbilitiesFor(principal: Principal) {
16
+ const { can, build } = new AbilityBuilder(createMongoAbility);
17
+ if (principal.roles?.includes(ADMIN_ROLE)) {
18
+ can("manage", "all");
19
+ return build();
20
+ }
21
+ for (const scope of principal.scopes ?? []) {
22
+ const [action, subject] = scope.split(":");
23
+ if (action && subject) can(action, subject);
24
+ }
25
+ return build();
26
+ }
27
+
28
+ /**
29
+ * Map a raw JWT payload → {@link Principal}.
30
+ *
31
+ * Merges OAuth `scope` / `scopes` with an IdP `permissions` claim (e.g. Auth0 API permissions).
32
+ * Override via `withAuth(..., { principalMapper })` when claims differ.
33
+ */
34
+ export function createPrincipalFromPayload(payload: Record<string, unknown>): Principal {
35
+ const sub = typeof payload.sub === "string" ? payload.sub : "";
36
+ const fromScopeClaim =
37
+ typeof payload.scope === "string"
38
+ ? payload.scope.split(" ").filter(Boolean)
39
+ : Array.isArray(payload.scopes)
40
+ ? (payload.scopes as unknown[]).filter((s): s is string => typeof s === "string")
41
+ : [];
42
+ const fromPermissions = Array.isArray(payload.permissions)
43
+ ? (payload.permissions as unknown[]).filter((s): s is string => typeof s === "string")
44
+ : [];
45
+ const merged = [...new Set([...fromScopeClaim, ...fromPermissions])];
46
+ const scopes = merged.length > 0 ? merged : undefined;
47
+ const roles = Array.isArray(payload.roles)
48
+ ? (payload.roles as unknown[]).filter((r): r is string => typeof r === "string")
49
+ : undefined;
50
+ return {
51
+ ...payload,
52
+ sub,
53
+ roles: roles && roles.length > 0 ? roles : undefined,
54
+ scopes,
55
+ };
56
+ }
@@ -0,0 +1,34 @@
1
+ import type { Principal } from "./auth.types.js";
2
+
3
+ /**
4
+ * Configuration for the TokenService, read from the `auth` section of the
5
+ * application config file. Exactly one of the discriminant fields must be set.
6
+ *
7
+ * Production → use `jwksUrl`, `publicKey`, or `secret`.
8
+ * Development → use `devStatic` (only works when NODE_ENV=development).
9
+ */
10
+ export class AuthConfig {
11
+ /** OIDC discovery URL — the issuer base URL, e.g. https://accounts.google.com.
12
+ * The framework appends `/.well-known/openid-configuration` automatically.
13
+ * Use this instead of `jwksUrl` for any standard OIDC-compliant IdP.
14
+ */
15
+ public discoveryUrl?: string;
16
+
17
+ /** JWKS endpoint URL (RS256 / ES256 via remote key set). */
18
+ public jwksUrl?: string;
19
+
20
+ /** PEM-encoded public key or JWK JSON string (RS256 / ES256 static key). */
21
+ public publicKey?: string;
22
+ public algorithms?: string[];
23
+
24
+ /** Shared secret for HS256. */
25
+ public secret?: string;
26
+
27
+ /** Issuer claim to validate (all non-devStatic variants). */
28
+ public issuer?: string;
29
+ /** Audience claim to validate (all non-devStatic variants). */
30
+ public audience?: string;
31
+
32
+ /** Static principal injected for every request (development only). */
33
+ public devStatic?: Principal;
34
+ }
@@ -0,0 +1,115 @@
1
+ import type { ContainerBuilder } from "@decohere/di";
2
+ import { extension } from "@decohere/core/extensibility";
3
+ import { Builder } from "../builder/builder.js";
4
+ import { AuthorizationServiceToken, TokenServiceToken } from "../const.js";
5
+ import { defineAbilitiesFor } from "./ability.js";
6
+ import { AuthConfig } from "./auth.config.js";
7
+ import type { PrincipalMapper, TokenServiceConfig } from "./auth.types.js";
8
+ import { assertDevStaticAllowed, assertNonEmptyAudience } from "./auth.types.js";
9
+ import type { AbilityFactory } from "./casl-authorization.service.js";
10
+ import { CaslAuthorizationService } from "./casl-authorization.service.js";
11
+ import { TokenService } from "./token-service.js";
12
+
13
+ export { ADMIN_ROLE, createPrincipalFromPayload, defineAbilitiesFor } from "./ability.js";
14
+
15
+ export type WithAuthOptions = {
16
+ /**
17
+ * Override the default JWT payload → Principal mapping.
18
+ * Use this when your IdP stores roles or scopes in non-standard claims.
19
+ *
20
+ * @example Keycloak realm roles:
21
+ * ```ts
22
+ * builder.withAuth(factory, {
23
+ * principalMapper: (p) => ({
24
+ * sub: p.sub as string,
25
+ * scopes: (p.realm_access as any)?.roles?.map((r: string) => `manage:${r}`) ?? [],
26
+ * }),
27
+ * });
28
+ * ```
29
+ */
30
+ principalMapper?: PrincipalMapper;
31
+ };
32
+
33
+ declare module "@decohere/http" {
34
+ interface Builder {
35
+ /**
36
+ * Configure JWT authentication from the `auth` config section and wire
37
+ * up a CASL authorization service.
38
+ *
39
+ * By default, `defineAbilitiesFor` grants `manage`/`all` for the `admin`
40
+ * role and maps `principal.scopes` of the form `"action:Subject"` to CASL
41
+ * rules. Pass a custom `abilityFactory` to override that behaviour.
42
+ *
43
+ * Requires `.withConfiguration(...)` to be called on the builder.
44
+ *
45
+ * @example Default (scope-based, no boilerplate):
46
+ * ```ts
47
+ * builder.withConfiguration(YamlDataSource).withAuth();
48
+ * ```
49
+ *
50
+ * @example Custom override:
51
+ * ```ts
52
+ * builder.withAuth((principal) => {
53
+ * const { can, build } = new AbilityBuilder(createMongoAbility);
54
+ * // custom logic ...
55
+ * return build();
56
+ * });
57
+ * ```
58
+ */
59
+ withAuth(abilityFactory?: AbilityFactory, options?: WithAuthOptions): Builder;
60
+ }
61
+ }
62
+
63
+ export class BuilderExtensions {
64
+ @extension(Builder)
65
+ public static withAuth(
66
+ builder: Builder,
67
+ abilityFactory: AbilityFactory = defineAbilitiesFor,
68
+ options?: WithAuthOptions,
69
+ ) {
70
+ builder.registerPreBuildStep(async (configService, containerBuilder: ContainerBuilder) => {
71
+ const authConfig = await configService.get(AuthConfig);
72
+ const tokenConfig = toTokenServiceConfig(authConfig);
73
+ const tokenService = new TokenService(tokenConfig, options?.principalMapper);
74
+ containerBuilder.registerSingleton(TokenServiceToken, tokenService);
75
+ containerBuilder.registerSingleton(
76
+ AuthorizationServiceToken,
77
+ new CaslAuthorizationService(abilityFactory, tokenService),
78
+ );
79
+ });
80
+ return builder;
81
+ }
82
+ }
83
+
84
+ function toTokenServiceConfig(config: AuthConfig): TokenServiceConfig {
85
+ // Prefer real verification strategies over devStatic so a leftover
86
+ // development block cannot disable OIDC/JWKS in any environment.
87
+ if (config.discoveryUrl != null) {
88
+ assertNonEmptyAudience(config.audience);
89
+ return { discoveryUrl: config.discoveryUrl, audience: config.audience };
90
+ }
91
+ if (config.jwksUrl != null) {
92
+ assertNonEmptyAudience(config.audience);
93
+ return { jwksUrl: config.jwksUrl, issuer: config.issuer, audience: config.audience };
94
+ }
95
+ if (config.publicKey != null) {
96
+ assertNonEmptyAudience(config.audience);
97
+ return {
98
+ publicKey: config.publicKey,
99
+ algorithms: config.algorithms,
100
+ issuer: config.issuer,
101
+ audience: config.audience,
102
+ };
103
+ }
104
+ if (config.secret != null) {
105
+ assertNonEmptyAudience(config.audience);
106
+ return { secret: config.secret, issuer: config.issuer, audience: config.audience };
107
+ }
108
+ if (config.devStatic != null) {
109
+ assertDevStaticAllowed();
110
+ return { devStatic: config.devStatic };
111
+ }
112
+ throw new Error(
113
+ "AuthConfig must specify one of: discoveryUrl, jwksUrl, publicKey, secret, or devStatic",
114
+ );
115
+ }
@@ -0,0 +1,124 @@
1
+ export type Principal = {
2
+ sub: string;
3
+ roles?: string[];
4
+ scopes?: string[];
5
+ [key: string]: unknown;
6
+ };
7
+
8
+ /** Maps a raw JWT payload to a Principal. Override the default via withAuth() options. */
9
+ export type PrincipalMapper = (payload: Record<string, unknown>) => Principal;
10
+
11
+ export type JwksConfig = {
12
+ jwksUrl: string;
13
+ issuer?: string;
14
+ /** Required — empty/undefined disables jose `aud` checks (cross-API token accept). */
15
+ audience: string;
16
+ };
17
+
18
+ export type PublicKeyConfig = {
19
+ publicKey: string; // PEM or JWK JSON string
20
+ algorithms?: string[];
21
+ issuer?: string;
22
+ /** Required — empty/undefined disables jose `aud` checks (cross-API token accept). */
23
+ audience: string;
24
+ };
25
+
26
+ export type SecretConfig = {
27
+ secret: string;
28
+ issuer?: string;
29
+ /** Required — empty/undefined disables jose `aud` checks (cross-API token accept). */
30
+ audience: string;
31
+ };
32
+
33
+ export type DevStaticConfig = {
34
+ devStatic: Principal;
35
+ };
36
+
37
+ /**
38
+ * Resolve IdP configuration automatically from an OIDC discovery endpoint
39
+ * (`{discoveryUrl}/.well-known/openid-configuration`). The `discoveryUrl` is
40
+ * also used as the expected `iss` claim unless the discovery document provides
41
+ * a different value.
42
+ */
43
+ export type DiscoveryConfig = {
44
+ discoveryUrl: string;
45
+ /** Required — empty/undefined disables jose `aud` checks (cross-API token accept). */
46
+ audience: string;
47
+ };
48
+
49
+ /**
50
+ * Framework-agnostic authorization service. Implement this interface (e.g.
51
+ * via CaslAuthorizationService) and register it under AuthorizationServiceToken.
52
+ * Throw ForbiddenError to deny access.
53
+ */
54
+ export interface IAuthorizationService {
55
+ authorizeRequest(
56
+ request: import("../routing/anvil.types.js").HttpRequest,
57
+ action: string,
58
+ subject: string,
59
+ ): Promise<Principal>;
60
+ /**
61
+ * Verify the request has a valid principal without checking a CASL ability.
62
+ * Used when auth is enabled but the route has no `@Authorize` metadata.
63
+ */
64
+ authenticateRequest(
65
+ request: import("../routing/anvil.types.js").HttpRequest,
66
+ ): Promise<Principal>;
67
+ authorize(principal: Principal, action: string, subject: string): Promise<void>;
68
+ }
69
+
70
+ export type TokenServiceConfig =
71
+ JwksConfig | PublicKeyConfig | SecretConfig | DevStaticConfig | DiscoveryConfig;
72
+
73
+ export function isDiscoveryConfig(config: TokenServiceConfig): config is DiscoveryConfig {
74
+ return "discoveryUrl" in config;
75
+ }
76
+
77
+ export function isDevStaticConfig(config: TokenServiceConfig): config is DevStaticConfig {
78
+ return "devStatic" in config;
79
+ }
80
+
81
+ export function isJwksConfig(config: TokenServiceConfig): config is JwksConfig {
82
+ return "jwksUrl" in config;
83
+ }
84
+
85
+ export function isPublicKeyConfig(config: TokenServiceConfig): config is PublicKeyConfig {
86
+ return "publicKey" in config;
87
+ }
88
+
89
+ export function isSecretConfig(config: TokenServiceConfig): config is SecretConfig {
90
+ return "secret" in config;
91
+ }
92
+
93
+ /** `devStatic` is only valid for local development NODE_ENV values. */
94
+ export function isDevelopmentNodeEnv(nodeEnv = process.env.NODE_ENV): boolean {
95
+ const env = nodeEnv?.toLowerCase();
96
+ return env === "development" || env === "dev";
97
+ }
98
+
99
+ /**
100
+ * Refuse `devStatic` outside development — otherwise any Bearer string is
101
+ * accepted as the configured principal (auth bypass in production).
102
+ */
103
+ export function assertDevStaticAllowed(nodeEnv = process.env.NODE_ENV): void {
104
+ if (!isDevelopmentNodeEnv(nodeEnv)) {
105
+ throw new Error(
106
+ `AuthConfig.devStatic is only allowed when NODE_ENV is "development" or "dev" (got ${JSON.stringify(nodeEnv ?? "")}).`,
107
+ );
108
+ }
109
+ }
110
+
111
+ /**
112
+ * jose skips `aud` validation when `audience` is undefined or "". Require a
113
+ * non-empty audience for every non-devStatic verification strategy.
114
+ */
115
+ export function assertNonEmptyAudience(
116
+ audience: string | undefined,
117
+ ): asserts audience is string {
118
+ if (typeof audience !== "string" || audience.trim().length === 0) {
119
+ throw new Error(
120
+ `JWT audience must be a non-empty string (got ${JSON.stringify(audience)}). ` +
121
+ "Empty audience disables aud validation and accepts tokens minted for other APIs.",
122
+ );
123
+ }
124
+ }
@@ -0,0 +1,62 @@
1
+ import type { AnyAbility } from "@casl/ability";
2
+ import type { HttpRequest } from "../routing/anvil.types.js";
3
+ import { ForbiddenError } from "../routing/anvil.types.js";
4
+ import type { IAuthorizationService, Principal } from "./auth.types.js";
5
+ import type { TokenService } from "./token-service.js";
6
+
7
+ export type AbilityFactory = (principal: Principal) => AnyAbility;
8
+
9
+ /**
10
+ * IAuthorizationService implementation backed by CASL.
11
+ *
12
+ * Register it under AuthorizationServiceToken:
13
+ * ```ts
14
+ * builder.withServices((c) =>
15
+ * c.registerSingleton(AuthorizationServiceToken, new CaslAuthorizationService((principal) => {
16
+ * const { can, build } = new AbilityBuilder(createMongoAbility);
17
+ * if (principal.roles?.includes("admin")) can("manage", "all");
18
+ * else can("read", "Post");
19
+ * return build();
20
+ * }))
21
+ * );
22
+ * ```
23
+ */
24
+ export class CaslAuthorizationService implements IAuthorizationService {
25
+ private readonly factory: AbilityFactory;
26
+ private readonly tokenService?: TokenService;
27
+
28
+ public constructor(factory: AbilityFactory, tokenService?: TokenService) {
29
+ this.factory = factory;
30
+ this.tokenService = tokenService;
31
+ }
32
+
33
+ public async authorizeRequest(
34
+ request: HttpRequest,
35
+ action: string,
36
+ subject: string,
37
+ ): Promise<Principal> {
38
+ const principal = await this.authenticateRequest(request);
39
+ await this.authorize(principal, action, subject);
40
+ return principal;
41
+ }
42
+
43
+ public async authenticateRequest(request: HttpRequest): Promise<Principal> {
44
+ if (!this.tokenService) {
45
+ throw new Error(
46
+ "CaslAuthorizationService requires a TokenService to authenticate requests. Register it via .withAuth().",
47
+ );
48
+ }
49
+ return this.tokenService.verifyRequest(request);
50
+ }
51
+
52
+ public async authorize(
53
+ principal: Principal,
54
+ action: string,
55
+ subject: string,
56
+ ): Promise<void> {
57
+ const ability = this.factory(principal);
58
+ if (ability.cannot(action, subject)) {
59
+ throw new ForbiddenError(`Cannot perform "${action}" on "${subject}"`);
60
+ }
61
+ }
62
+ }
@@ -1,20 +1,28 @@
1
1
  import type { Principal } from "./auth.types.js";
2
+
2
3
  /**
3
4
  * Read-only view of the authenticated principal for the current request.
4
5
  * Inject this interface (via IPrincipalProviderToken) in any request-scoped
5
6
  * service to access the verified identity without coupling to the routing layer.
6
7
  */
7
8
  export interface IPrincipalProvider {
8
- readonly currentPrincipal: Principal | undefined;
9
+ readonly currentPrincipal: Principal | undefined;
9
10
  }
11
+
10
12
  /**
11
13
  * Concrete, request-scoped implementation. The Router sets the principal
12
14
  * after JWT verification; downstream services consume it via IPrincipalProvider.
13
15
  *
14
16
  * @internal — framework use only. Inject IPrincipalProvider, not this class.
15
17
  */
16
- export declare class PrincipalProvider implements IPrincipalProvider {
17
- private _principal;
18
- get currentPrincipal(): Principal | undefined;
19
- setPrincipal(principal: Principal | undefined): void;
18
+ export class PrincipalProvider implements IPrincipalProvider {
19
+ private _principal: Principal | undefined;
20
+
21
+ public get currentPrincipal(): Principal | undefined {
22
+ return this._principal;
23
+ }
24
+
25
+ public setPrincipal(principal: Principal | undefined): void {
26
+ this._principal = principal;
27
+ }
20
28
  }