@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,117 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { BlockList, isIP } from "node:net";
3
+
4
+ /**
5
+ * Validate URLs used for OIDC discovery / JWKS fetches to reduce SSRF risk.
6
+ *
7
+ * - http(s) only; https required except loopback (local IdP tests)
8
+ * - no credentials in the URL
9
+ * - hostname must not resolve to private, link-local, or metadata ranges
10
+ * (loopback allowed only when NODE_ENV is development/dev/test)
11
+ */
12
+ export async function assertSafeOutboundUrl(raw: string, label: string): Promise<URL> {
13
+ let url: URL;
14
+ try {
15
+ url = new URL(raw);
16
+ } catch {
17
+ throw new Error(`Invalid ${label}: ${raw}`);
18
+ }
19
+
20
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
21
+ throw new Error(`${label} must use http or https (got ${url.protocol})`);
22
+ }
23
+
24
+ if (url.username || url.password) {
25
+ throw new Error(`${label} must not include credentials`);
26
+ }
27
+
28
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
29
+ if (isBlockedHostname(hostname)) {
30
+ throw new Error(`${label} host is not allowed (${hostname})`);
31
+ }
32
+
33
+ const ips = await resolveIps(hostname);
34
+ const allowLoopback = isLoopbackAllowed();
35
+ for (const ip of ips) {
36
+ if (isBlockedIp(ip, allowLoopback)) {
37
+ throw new Error(`${label} resolves to a blocked address (${ip})`);
38
+ }
39
+ }
40
+
41
+ if (url.protocol === "http:") {
42
+ const loopbackOnly =
43
+ isLoopbackHostname(hostname) || (ips.length > 0 && ips.every((ip) => isLoopbackIp(ip)));
44
+ if (!loopbackOnly || !allowLoopback) {
45
+ throw new Error(`${label} must use https unless targeting loopback in development/test`);
46
+ }
47
+ }
48
+
49
+ return url;
50
+ }
51
+
52
+ function isLoopbackAllowed(): boolean {
53
+ const env = process.env.NODE_ENV ?? "";
54
+ return env === "development" || env === "dev" || env === "test";
55
+ }
56
+
57
+ function isLoopbackHostname(hostname: string): boolean {
58
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
59
+ }
60
+
61
+ function isBlockedHostname(hostname: string): boolean {
62
+ const lower = hostname.toLowerCase();
63
+ return (
64
+ lower === "metadata.google.internal" ||
65
+ lower === "metadata" ||
66
+ lower.endsWith(".metadata.google.internal")
67
+ );
68
+ }
69
+
70
+ async function resolveIps(hostname: string): Promise<string[]> {
71
+ if (isIP(hostname)) {
72
+ return [hostname];
73
+ }
74
+ const results = await lookup(hostname, { all: true, verbatim: true });
75
+ return results.map((r) => r.address);
76
+ }
77
+
78
+ function isLoopbackIp(ip: string): boolean {
79
+ const list = new BlockList();
80
+ list.addSubnet("127.0.0.0", 8, "ipv4");
81
+ list.addAddress("::1", "ipv6");
82
+ list.addSubnet("::ffff:127.0.0.0", 104, "ipv6");
83
+ return list.check(ip);
84
+ }
85
+
86
+ function isBlockedIp(ip: string, allowLoopback: boolean): boolean {
87
+ if (allowLoopback && isLoopbackIp(ip)) {
88
+ return false;
89
+ }
90
+
91
+ const list = new BlockList();
92
+ // IPv4 special-use / private
93
+ list.addSubnet("0.0.0.0", 8, "ipv4");
94
+ list.addSubnet("10.0.0.0", 8, "ipv4");
95
+ list.addSubnet("100.64.0.0", 10, "ipv4"); // shared address space
96
+ list.addSubnet("127.0.0.0", 8, "ipv4");
97
+ list.addSubnet("169.254.0.0", 16, "ipv4"); // link-local / cloud metadata
98
+ list.addSubnet("172.16.0.0", 12, "ipv4");
99
+ list.addSubnet("192.168.0.0", 16, "ipv4");
100
+ list.addSubnet("198.18.0.0", 15, "ipv4"); // benchmarking
101
+ // IPv6
102
+ list.addAddress("::1", "ipv6");
103
+ list.addSubnet("fc00::", 7, "ipv6"); // unique local
104
+ list.addSubnet("fe80::", 10, "ipv6"); // link-local
105
+
106
+ if (list.check(ip)) {
107
+ return true;
108
+ }
109
+
110
+ // Mapped IPv4 inside IPv6 (::ffff:a.b.c.d)
111
+ const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
112
+ if (mapped) {
113
+ return isBlockedIp(mapped[1], allowLoopback);
114
+ }
115
+
116
+ return false;
117
+ }
@@ -0,0 +1,192 @@
1
+ import {
2
+ createRemoteJWKSet,
3
+ importJWK,
4
+ importSPKI,
5
+ type JWTPayload,
6
+ type JWTVerifyGetKey,
7
+ jwtVerify,
8
+ SignJWT,
9
+ } from "jose";
10
+ import type { HttpRequest } from "../routing/anvil.types.js";
11
+ import { UnauthorizedError } from "../routing/anvil.types.js";
12
+ import { createPrincipalFromPayload } from "./ability.js";
13
+ import type {
14
+ Principal,
15
+ PrincipalMapper,
16
+ PublicKeyConfig,
17
+ SecretConfig,
18
+ TokenServiceConfig,
19
+ } from "./auth.types.js";
20
+ import {
21
+ assertDevStaticAllowed,
22
+ assertNonEmptyAudience,
23
+ isDevStaticConfig,
24
+ isDiscoveryConfig,
25
+ isJwksConfig,
26
+ isPublicKeyConfig,
27
+ isSecretConfig,
28
+ } from "./auth.types.js";
29
+ import { assertSafeOutboundUrl } from "./safe-outbound-url.js";
30
+
31
+ /** Key material + claim checks needed for a single jwtVerify call. */
32
+ type VerificationContext = {
33
+ key: JWTVerifyGetKey | Awaited<ReturnType<typeof importJWK>> | Uint8Array;
34
+ issuer?: string;
35
+ audience?: string;
36
+ };
37
+
38
+ export class TokenService {
39
+ private readonly config: TokenServiceConfig;
40
+ private readonly principalMapper?: PrincipalMapper;
41
+ /** Lazily resolved JWKS set when using OIDC discovery. */
42
+ private discoveryJwks?: ReturnType<typeof createRemoteJWKSet>;
43
+ private discoveryIssuer?: string;
44
+ /** Remote key set for `jwksUrl` mode, created once so jose can cache the keys (#499). */
45
+ private configuredJwks?: ReturnType<typeof createRemoteJWKSet>;
46
+
47
+ public constructor(config: TokenServiceConfig, principalMapper?: PrincipalMapper) {
48
+ if (isDevStaticConfig(config)) {
49
+ assertDevStaticAllowed();
50
+ } else {
51
+ assertNonEmptyAudience(config.audience);
52
+ }
53
+ this.config = config;
54
+ this.principalMapper = principalMapper;
55
+ }
56
+
57
+ public async verifyRequest(request: HttpRequest): Promise<Principal> {
58
+ const authHeader = request.headers.authorization;
59
+ if (!authHeader) {
60
+ throw new UnauthorizedError("Missing Authorization header");
61
+ }
62
+ const spaceIdx = authHeader.indexOf(" ");
63
+ if (spaceIdx === -1) {
64
+ throw new UnauthorizedError("Malformed Authorization header");
65
+ }
66
+ const scheme = authHeader.slice(0, spaceIdx);
67
+ if (scheme !== "Bearer") {
68
+ throw new UnauthorizedError(`Unsupported authorization scheme: ${scheme}`);
69
+ }
70
+ const credentials = authHeader.slice(spaceIdx + 1);
71
+ return this.verify(credentials);
72
+ }
73
+
74
+ public async verify(credentials: string): Promise<Principal> {
75
+ if (isDevStaticConfig(this.config)) {
76
+ return this.config.devStatic;
77
+ }
78
+
79
+ try {
80
+ const { key, issuer, audience } = await this.resolveVerificationContext();
81
+ // jose's overloads don't accept Key | JWKS-getter as a union; runtime does.
82
+ const { payload } = await jwtVerify(credentials, key as never, { issuer, audience });
83
+ return this.mapPrincipal(payload);
84
+ } catch (err) {
85
+ throw new UnauthorizedError(
86
+ err instanceof Error ? err.message : "Token verification failed",
87
+ );
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolve how to verify tokens for the configured strategy:
93
+ * - OIDC discovery → fetch JWKS + issuer from `/.well-known/openid-configuration`
94
+ * - explicit JWKS URL → remote key set + configured issuer/audience
95
+ * - public key / shared secret → static key material + configured issuer/audience
96
+ */
97
+ private async resolveVerificationContext(): Promise<VerificationContext> {
98
+ if (isDiscoveryConfig(this.config)) {
99
+ const { jwks, issuer } = await this.resolveOidcDiscovery();
100
+ return { key: jwks, issuer, audience: this.config.audience };
101
+ }
102
+ if (isJwksConfig(this.config)) {
103
+ this.configuredJwks ??= createRemoteJWKSet(
104
+ await assertSafeOutboundUrl(this.config.jwksUrl, "jwksUrl"),
105
+ );
106
+ return {
107
+ key: this.configuredJwks,
108
+ issuer: this.config.issuer,
109
+ audience: this.config.audience,
110
+ };
111
+ }
112
+ return {
113
+ key: await this.getStaticKey(),
114
+ issuer: (this.config as PublicKeyConfig | SecretConfig).issuer,
115
+ audience: (this.config as PublicKeyConfig | SecretConfig).audience,
116
+ };
117
+ }
118
+
119
+ private async resolveOidcDiscovery(): Promise<{
120
+ jwks: ReturnType<typeof createRemoteJWKSet>;
121
+ issuer: string;
122
+ }> {
123
+ if (this.discoveryJwks && this.discoveryIssuer) {
124
+ return { jwks: this.discoveryJwks, issuer: this.discoveryIssuer };
125
+ }
126
+ const { discoveryUrl } = this.config as import("./auth.types.js").DiscoveryConfig;
127
+ await assertSafeOutboundUrl(discoveryUrl, "discoveryUrl");
128
+ const resp = await fetch(
129
+ `${discoveryUrl.replace(/\/$/, "")}/.well-known/openid-configuration`,
130
+ {
131
+ redirect: "error",
132
+ },
133
+ );
134
+ if (!resp.ok) {
135
+ throw new Error(`OIDC discovery failed for ${discoveryUrl}: HTTP ${resp.status}`);
136
+ }
137
+ const doc = (await resp.json()) as { issuer: string; jwks_uri: string };
138
+ if (typeof doc.jwks_uri !== "string" || doc.jwks_uri.length === 0) {
139
+ throw new Error(`OIDC discovery for ${discoveryUrl} did not return jwks_uri`);
140
+ }
141
+ const jwksUrl = await assertSafeOutboundUrl(doc.jwks_uri, "jwks_uri");
142
+ this.discoveryIssuer = doc.issuer;
143
+ this.discoveryJwks = createRemoteJWKSet(jwksUrl);
144
+ return { jwks: this.discoveryJwks, issuer: this.discoveryIssuer };
145
+ }
146
+
147
+ /**
148
+ * Sign a payload and return a JWT string.
149
+ * Only available when configured with a SecretConfig (HS256).
150
+ * For RS256/ES256 issuance, the caller is responsible for key management.
151
+ */
152
+ public async sign(
153
+ payload: Omit<JWTPayload, "iss" | "aud"> & Record<string, unknown>,
154
+ expiresIn = "1h",
155
+ ): Promise<string> {
156
+ if (!isSecretConfig(this.config)) {
157
+ throw new Error("TokenService.sign() requires SecretConfig (shared secret / HS256)");
158
+ }
159
+ const key = new TextEncoder().encode(this.config.secret);
160
+ return new SignJWT(payload)
161
+ .setProtectedHeader({ alg: "HS256" })
162
+ .setIssuedAt()
163
+ .setIssuer(this.config.issuer ?? "")
164
+ .setAudience(this.config.audience ?? "")
165
+ .setExpirationTime(expiresIn)
166
+ .sign(key);
167
+ }
168
+
169
+ private async getStaticKey(): Promise<Awaited<ReturnType<typeof importJWK>> | Uint8Array> {
170
+ if (isPublicKeyConfig(this.config)) {
171
+ const cfg = this.config as PublicKeyConfig;
172
+ const trimmed = cfg.publicKey.trim();
173
+ if (trimmed.startsWith("{")) {
174
+ const jwk = JSON.parse(trimmed) as Parameters<typeof importJWK>[0];
175
+ return importJWK(jwk);
176
+ }
177
+ return importSPKI(trimmed, cfg.algorithms?.[0] ?? "RS256");
178
+ }
179
+
180
+ if (isSecretConfig(this.config)) {
181
+ const enc = new TextEncoder().encode((this.config as SecretConfig).secret);
182
+ return enc;
183
+ }
184
+
185
+ throw new Error("Invalid TokenService configuration");
186
+ }
187
+
188
+ private mapPrincipal(payload: JWTPayload): Principal {
189
+ if (this.principalMapper) return this.principalMapper(payload as Record<string, unknown>);
190
+ return createPrincipalFromPayload(payload as Record<string, unknown>);
191
+ }
192
+ }
@@ -0,0 +1,118 @@
1
+ import { type ContainerBuilder, ServiceContainerBuilder, type IContainer } from "@decohere/di";
2
+ import {
3
+ ConfigurationServiceToken,
4
+ type IConfigurationService,
5
+ } from "@decohere/core/configuration";
6
+ import type { Constructable } from "@decohere/core/reflection";
7
+ import { type Logger, scribe } from "@decohere/scribe";
8
+ import { App } from "../app/app.js";
9
+ import { HostedServiceHostToken } from "../const.js";
10
+ import type { HostedService } from "../hosting/hosted-service.js";
11
+ import { HostedServiceHost } from "../hosting/hosted-service-host.js";
12
+
13
+ export type BuilderOptions = {
14
+ containerBuilder?: ServiceContainerBuilder;
15
+ applicationName?: string;
16
+ };
17
+
18
+ export class Builder<T extends App = App> {
19
+ public static create<T extends App = App>(options?: BuilderOptions) {
20
+ return new Builder<T>(options);
21
+ }
22
+
23
+ public readonly containerBuilder: ServiceContainerBuilder;
24
+ public readonly applicationName?: string;
25
+ private readonly logger: Logger = scribe.logger("name");
26
+ private userServices: ((containerBuilder: ContainerBuilder) => void)[] = [];
27
+ private preBuildSteps: ((
28
+ config: IConfigurationService,
29
+ containerBuilder: ContainerBuilder,
30
+ ) => Promise<void> | void)[] = [];
31
+ private postBuildSteps: ((container: IContainer) => Promise<void> | void)[] = [];
32
+ private readonly hostedServices = new HostedServiceHost();
33
+
34
+ public constructor(options?: BuilderOptions) {
35
+ this.containerBuilder = options?.containerBuilder ?? new ServiceContainerBuilder();
36
+ this.applicationName = options?.applicationName;
37
+ }
38
+
39
+ public withServices(userServices: (containerBuilder: ContainerBuilder) => void): Builder<T> {
40
+ this.userServices.push(userServices);
41
+ return this;
42
+ }
43
+
44
+ /**
45
+ * Run {@link HostedService}s for the life of the app: started in this order before the HTTP
46
+ * server listens, stopped in reverse when the app stops (including SIGTERM and Flux reboots).
47
+ * Each is registered as a singleton, so other services can inject it by its class.
48
+ */
49
+ public withHostedService(...types: Constructable<HostedService>[]): Builder<T> {
50
+ for (const type of types) this.containerBuilder.registerSingleton(type);
51
+ this.hostedServices.add(...types);
52
+ return this;
53
+ }
54
+
55
+ public registerPreBuildStep(
56
+ step: (
57
+ config: IConfigurationService,
58
+ containerBuilder: ContainerBuilder,
59
+ ) => Promise<void> | void,
60
+ ) {
61
+ this.preBuildSteps.push(step);
62
+ }
63
+
64
+ public registerPostBuildStep(step: (container: IContainer) => Promise<void> | void) {
65
+ this.postBuildSteps.push(step);
66
+ }
67
+
68
+ public async buildAndStart<T extends App>(type?: Constructable<T>): Promise<T | App> {
69
+ const app = await this.build(type);
70
+ await app.start();
71
+ this.logger.info("App started...");
72
+ return app;
73
+ }
74
+
75
+ public async build<T extends App = App>(type?: Constructable<T>): Promise<T | App> {
76
+ this.logger.info("Building App...");
77
+ this.configureUserServices();
78
+ this.configureApp(type);
79
+ await this.runPreBuildSteps(this.containerBuilder);
80
+ const container = this.containerBuilder.build();
81
+ await this.runPostBuildSteps(container);
82
+ const app = this.resolveApp(container, type);
83
+ return app;
84
+ }
85
+
86
+ private configureUserServices() {
87
+ for (const userService of this.userServices) {
88
+ userService(this.containerBuilder);
89
+ }
90
+ }
91
+
92
+ private async runPreBuildSteps(containerBuilder: ContainerBuilder) {
93
+ if (this.preBuildSteps.length === 0) {
94
+ return;
95
+ }
96
+ const container = containerBuilder.build();
97
+ const config = container.resolve(ConfigurationServiceToken) as IConfigurationService;
98
+ for (const step of this.preBuildSteps) {
99
+ await step(config, containerBuilder);
100
+ }
101
+ }
102
+
103
+ private async runPostBuildSteps(container: IContainer) {
104
+ for (const step of this.postBuildSteps) {
105
+ await step(container);
106
+ }
107
+ }
108
+
109
+ private configureApp(type: Constructable) {
110
+ this.containerBuilder.registerSingleton(HostedServiceHostToken, this.hostedServices);
111
+ this.containerBuilder.registerSingleton(type ?? App);
112
+ }
113
+
114
+ private resolveApp<T extends App>(container: IContainer, type: Constructable<T>) {
115
+ const app = type ? container.resolve(type) : container.resolve(App);
116
+ return app;
117
+ }
118
+ }
@@ -1,5 +1,8 @@
1
+ import type { Metadata } from "./routing/anvil.types.js";
2
+
1
3
  const Anvil = "anvil";
2
4
  const RoutingKey = `${Anvil}:Routing`;
5
+
3
6
  export const RouteRegistryToken = Symbol("RouteRegistryToken");
4
7
  export const HttpProviderToken = Symbol("HttpProviderToken");
5
8
  export const RouterToken = Symbol("RouterToken");
@@ -12,22 +15,27 @@ export const ExceptionFilterHostToken = Symbol("ExceptionFilterHost");
12
15
  export const ReadinessCheckToken = Symbol("ReadinessCheck");
13
16
  export const ReadinessServiceToken = Symbol("ReadinessService");
14
17
  export const HostedServiceHostToken = Symbol("HostedServiceHost");
18
+ export type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS";
15
19
  export const HTTPMethods = {
16
- GET: "GET",
17
- PUT: "PUT",
18
- POST: "POST",
19
- PATCH: "PATCH",
20
- DELETE: "DELETE"
21
- };
20
+ GET: "GET",
21
+ PUT: "PUT",
22
+ POST: "POST",
23
+ PATCH: "PATCH",
24
+ DELETE: "DELETE",
25
+ } satisfies Record<string, HTTPMethod>;
26
+
27
+ type AuthMetadata = "authorize" | "allowAnonymous";
28
+ type ResponseMetadata = "httpCode" | "headers" | "catch" | "sse";
29
+
22
30
  export const MetadataKeys = {
23
- controller: `${RoutingKey}:controller`,
24
- route: `${RoutingKey}:route`,
25
- parameter: `${RoutingKey}:parameter`,
26
- fieldMap: `${RoutingKey}:fieldMap`,
27
- authorize: `${RoutingKey}:authorize`,
28
- allowAnonymous: `${RoutingKey}:allowAnonymous`,
29
- httpCode: `${RoutingKey}:httpCode`,
30
- headers: `${RoutingKey}:headers`,
31
- catch: `${RoutingKey}:catch`,
32
- sse: `${RoutingKey}:sse`
33
- };
31
+ controller: `${RoutingKey}:controller`,
32
+ route: `${RoutingKey}:route`,
33
+ parameter: `${RoutingKey}:parameter`,
34
+ fieldMap: `${RoutingKey}:fieldMap`,
35
+ authorize: `${RoutingKey}:authorize`,
36
+ allowAnonymous: `${RoutingKey}:allowAnonymous`,
37
+ httpCode: `${RoutingKey}:httpCode`,
38
+ headers: `${RoutingKey}:headers`,
39
+ catch: `${RoutingKey}:catch`,
40
+ sse: `${RoutingKey}:sse`,
41
+ } satisfies Record<Metadata | AuthMetadata | ResponseMetadata, string>;
@@ -0,0 +1,34 @@
1
+ import { inject, injectable } from "@decohere/di/decorators";
2
+ import { ReadinessServiceToken } from "../const.js";
3
+ import { HttpError } from "../routing/anvil.types.js";
4
+ import { AllowAnonymous, Controller, Get } from "../routing/index.js";
5
+ import { ReadinessService } from "./readiness.service.js";
6
+
7
+ /**
8
+ * Liveness (`/health`) and readiness (`/ready`) probes.
9
+ * Both are anonymous so orchestration can scrape them with auth enabled, and stay at the root
10
+ * under a global prefix, where platforms look for them.
11
+ */
12
+ @Controller({ prefix: "/", globalPrefix: false })
13
+ @injectable()
14
+ export class HealthController {
15
+ public constructor(
16
+ @inject(ReadinessServiceToken) private readonly readiness: ReadinessService,
17
+ ) {}
18
+
19
+ @AllowAnonymous()
20
+ @Get("/health")
21
+ public health() {
22
+ return { status: "ok" };
23
+ }
24
+
25
+ @AllowAnonymous()
26
+ @Get("/ready")
27
+ public async ready() {
28
+ const result = await this.readiness.checkAll();
29
+ if (!result.ok) {
30
+ throw new HttpError("Not ready", 503);
31
+ }
32
+ return { status: "ok" };
33
+ }
34
+ }
@@ -0,0 +1,53 @@
1
+ import type { ContainerBuilder } from "@decohere/di";
2
+ import { extension } from "@decohere/core/extensibility";
3
+ import { Builder } from "../builder/builder.js";
4
+ import { ReadinessCheckToken, ReadinessServiceToken } from "../const.js";
5
+ import "../http/http.extensions.js";
6
+ import { getOrCreateHttpOptions } from "../http/http.options.store.js";
7
+ import { HealthController } from "./health.controller.js";
8
+ import { ReadinessService } from "./readiness.service.js";
9
+ import type { IReadinessCheck } from "./readiness.types.js";
10
+
11
+ export type WithHealthOptions = {
12
+ /** Extra readiness probes (e.g. custom dependency checks). */
13
+ checks?: IReadinessCheck[];
14
+ /**
15
+ * Serve the probes under the global prefix (`/api/health`) instead of at the root. Default
16
+ * `false`: Cloud Run, Kubernetes and most load balancers probe the root.
17
+ */
18
+ underGlobalPrefix?: boolean;
19
+ };
20
+
21
+ declare module "@decohere/http" {
22
+ interface Builder {
23
+ /**
24
+ * Register anonymous `/health` (liveness) and `/ready` (readiness) routes.
25
+ *
26
+ * Readiness runs every {@link IReadinessCheck} registered under
27
+ * {@link ReadinessCheckToken} (TypeORM registers one per datasource when
28
+ * `.withTypeOrm()` is used). Pass `checks` for app-specific probes.
29
+ *
30
+ * The probes stay at the root under `withGlobalPrefix`, unless `underGlobalPrefix` is set.
31
+ *
32
+ * Requires `.withHttpProvider(...)` (or `@decohere/http/http` side-effect).
33
+ */
34
+ withHealth(options?: WithHealthOptions): Builder;
35
+ }
36
+ }
37
+
38
+ export class BuilderExtensions {
39
+ @extension(Builder)
40
+ public static withHealth(builder: Builder, options?: WithHealthOptions) {
41
+ builder.withServices((containerBuilder: ContainerBuilder) => {
42
+ containerBuilder.registerSingleton(ReadinessServiceToken, ReadinessService);
43
+ for (const check of options?.checks ?? []) {
44
+ containerBuilder.registerSingleton(ReadinessCheckToken, check);
45
+ }
46
+ });
47
+ if (options?.underGlobalPrefix === true) {
48
+ getOrCreateHttpOptions(builder).underGlobalPrefix.add(HealthController);
49
+ }
50
+ builder.withControllers(HealthController);
51
+ return builder;
52
+ }
53
+ }
@@ -0,0 +1,33 @@
1
+ import { inject, injectable } from "@decohere/di/decorators";
2
+ import { HostedServiceHostToken, ReadinessCheckToken } from "../const.js";
3
+ import type { HostedServiceHost } from "../hosting/hosted-service-host.js";
4
+ import type { IReadinessCheck, ReadinessResult } from "./readiness.types.js";
5
+
6
+ @injectable()
7
+ export class ReadinessService {
8
+ private readonly checks: readonly IReadinessCheck[];
9
+
10
+ public constructor(
11
+ @inject(ReadinessCheckToken) checks: IReadinessCheck[] = [],
12
+ @inject(HostedServiceHostToken) private readonly hostedServices?: HostedServiceHost,
13
+ ) {
14
+ this.checks = checks;
15
+ }
16
+
17
+ /** Registered checks, then each started hosted service that has a `check()` (still warming up). */
18
+ public async checkAll(): Promise<ReadinessResult> {
19
+ const failures: string[] = [];
20
+ const probes = [...this.checks, ...(this.hostedServices?.readinessChecks() ?? [])];
21
+ for (const probe of probes) {
22
+ try {
23
+ await probe.check();
24
+ } catch {
25
+ failures.push(probe.name);
26
+ }
27
+ }
28
+ if (failures.length > 0) {
29
+ return { ok: false, failures };
30
+ }
31
+ return { ok: true };
32
+ }
33
+ }
@@ -3,12 +3,8 @@
3
3
  * Register instances under {@link ReadinessCheckToken} (multi-inject).
4
4
  */
5
5
  export interface IReadinessCheck {
6
- readonly name: string;
7
- check(): Promise<void>;
6
+ readonly name: string;
7
+ check(): Promise<void>;
8
8
  }
9
- export type ReadinessResult = {
10
- ok: true;
11
- } | {
12
- ok: false;
13
- failures: string[];
14
- };
9
+
10
+ export type ReadinessResult = { ok: true } | { ok: false; failures: string[] };