@harperfast/harper 5.2.0-beta.4 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/bin/backup.ts +169 -0
  2. package/bin/cliOperations.ts +92 -73
  3. package/bin/copyDb.ts +21 -4
  4. package/bin/harper.ts +43 -55
  5. package/bin/help.ts +216 -0
  6. package/components/Application.ts +236 -46
  7. package/components/ApplicationScope.ts +26 -0
  8. package/components/EntryHandler.ts +410 -105
  9. package/components/RuntimeModuleTracker.ts +189 -0
  10. package/components/Scope.ts +120 -30
  11. package/components/componentLoader.ts +177 -25
  12. package/components/deployLifecycle.ts +119 -33
  13. package/components/mcp/toolRegistry.ts +10 -0
  14. package/components/mcp/tools/application.ts +12 -5
  15. package/components/mcp/tools/operations.ts +9 -0
  16. package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
  17. package/components/mcp/tools/schemas/operations.ts +9 -0
  18. package/components/operations.js +5 -6
  19. package/components/operationsValidation.js +32 -2
  20. package/components/scopeMount.ts +150 -0
  21. package/config/configUtils.ts +12 -9
  22. package/config-root.schema.json +14 -0
  23. package/dataLayer/backupManifest.ts +102 -0
  24. package/dataLayer/blobBackup.ts +286 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +69 -5
  26. package/dataLayer/hdbInfoController.ts +8 -0
  27. package/dataLayer/restoreMarker.ts +276 -0
  28. package/dataLayer/rocksdbBackup.ts +1100 -0
  29. package/dataLayer/schemaDescribe.ts +2 -1
  30. package/dist/bin/backup.d.ts +9 -0
  31. package/dist/bin/backup.js +192 -0
  32. package/dist/bin/backup.js.map +1 -0
  33. package/dist/bin/cliOperations.d.ts +13 -0
  34. package/dist/bin/cliOperations.js +89 -70
  35. package/dist/bin/cliOperations.js.map +1 -1
  36. package/dist/bin/copyDb.js +13 -2
  37. package/dist/bin/copyDb.js.map +1 -1
  38. package/dist/bin/harper.d.ts +13 -0
  39. package/dist/bin/harper.js +45 -56
  40. package/dist/bin/harper.js.map +1 -1
  41. package/dist/bin/help.d.ts +8 -0
  42. package/dist/bin/help.js +192 -0
  43. package/dist/bin/help.js.map +1 -0
  44. package/dist/components/Application.d.ts +16 -1
  45. package/dist/components/Application.js +210 -38
  46. package/dist/components/Application.js.map +1 -1
  47. package/dist/components/ApplicationScope.d.ts +7 -0
  48. package/dist/components/ApplicationScope.js +22 -0
  49. package/dist/components/ApplicationScope.js.map +1 -1
  50. package/dist/components/EntryHandler.d.ts +4 -4
  51. package/dist/components/EntryHandler.js +386 -95
  52. package/dist/components/EntryHandler.js.map +1 -1
  53. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  54. package/dist/components/RuntimeModuleTracker.js +189 -0
  55. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  56. package/dist/components/Scope.d.ts +38 -1
  57. package/dist/components/Scope.js +117 -28
  58. package/dist/components/Scope.js.map +1 -1
  59. package/dist/components/componentLoader.d.ts +2 -6
  60. package/dist/components/componentLoader.js +160 -18
  61. package/dist/components/componentLoader.js.map +1 -1
  62. package/dist/components/deployLifecycle.d.ts +6 -2
  63. package/dist/components/deployLifecycle.js +109 -31
  64. package/dist/components/deployLifecycle.js.map +1 -1
  65. package/dist/components/mcp/toolRegistry.d.ts +7 -0
  66. package/dist/components/mcp/toolRegistry.js +10 -0
  67. package/dist/components/mcp/toolRegistry.js.map +1 -1
  68. package/dist/components/mcp/tools/application.js +11 -5
  69. package/dist/components/mcp/tools/application.js.map +1 -1
  70. package/dist/components/mcp/tools/operations.js +9 -0
  71. package/dist/components/mcp/tools/operations.js.map +1 -1
  72. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
  73. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  74. package/dist/components/mcp/tools/schemas/operations.js +9 -0
  75. package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
  76. package/dist/components/operations.js +6 -6
  77. package/dist/components/operations.js.map +1 -1
  78. package/dist/components/operationsValidation.js +34 -2
  79. package/dist/components/operationsValidation.js.map +1 -1
  80. package/dist/components/scopeMount.d.ts +86 -0
  81. package/dist/components/scopeMount.js +131 -0
  82. package/dist/components/scopeMount.js.map +1 -0
  83. package/dist/config/configUtils.js +13 -9
  84. package/dist/config/configUtils.js.map +1 -1
  85. package/dist/dataLayer/backupManifest.d.ts +26 -0
  86. package/dist/dataLayer/backupManifest.js +97 -0
  87. package/dist/dataLayer/backupManifest.js.map +1 -0
  88. package/dist/dataLayer/blobBackup.d.ts +87 -0
  89. package/dist/dataLayer/blobBackup.js +282 -0
  90. package/dist/dataLayer/blobBackup.js.map +1 -0
  91. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
  92. package/dist/dataLayer/harperBridge/ResourceBridge.js +45 -5
  93. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  94. package/dist/dataLayer/hdbInfoController.js +4 -0
  95. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  96. package/dist/dataLayer/restoreMarker.d.ts +122 -0
  97. package/dist/dataLayer/restoreMarker.js +261 -0
  98. package/dist/dataLayer/restoreMarker.js.map +1 -0
  99. package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
  100. package/dist/dataLayer/rocksdbBackup.js +1039 -0
  101. package/dist/dataLayer/rocksdbBackup.js.map +1 -0
  102. package/dist/dataLayer/schemaDescribe.js +2 -1
  103. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  104. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  105. package/dist/resources/DatabaseTransaction.js +282 -109
  106. package/dist/resources/DatabaseTransaction.js.map +1 -1
  107. package/dist/resources/ResourceInterface.d.ts +8 -5
  108. package/dist/resources/ResourceInterface.js.map +1 -1
  109. package/dist/resources/Resources.js +22 -4
  110. package/dist/resources/Resources.js.map +1 -1
  111. package/dist/resources/Table.d.ts +6 -5
  112. package/dist/resources/Table.js +114 -37
  113. package/dist/resources/Table.js.map +1 -1
  114. package/dist/resources/analytics/write.js +6 -6
  115. package/dist/resources/analytics/write.js.map +1 -1
  116. package/dist/resources/blob.d.ts +8 -1
  117. package/dist/resources/blob.js +31 -14
  118. package/dist/resources/blob.js.map +1 -1
  119. package/dist/resources/databases.d.ts +59 -1
  120. package/dist/resources/databases.js +386 -41
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/jsResource.d.ts +4 -26
  123. package/dist/resources/jsResource.js +5 -59
  124. package/dist/resources/jsResource.js.map +1 -1
  125. package/dist/resources/models/Models.d.ts +11 -1
  126. package/dist/resources/models/Models.js +10 -1
  127. package/dist/resources/models/Models.js.map +1 -1
  128. package/dist/resources/models/backendRegistry.d.ts +9 -0
  129. package/dist/resources/models/backendRegistry.js +10 -0
  130. package/dist/resources/models/backendRegistry.js.map +1 -1
  131. package/dist/resources/models/openaiStream.d.ts +16 -1
  132. package/dist/resources/models/openaiStream.js +113 -21
  133. package/dist/resources/models/openaiStream.js.map +1 -1
  134. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  135. package/dist/resources/models/v1/chatCompletions.js +115 -0
  136. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  137. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  138. package/dist/resources/models/v1/embeddings.js +71 -0
  139. package/dist/resources/models/v1/embeddings.js.map +1 -0
  140. package/dist/resources/models/v1/errors.d.ts +54 -0
  141. package/dist/resources/models/v1/errors.js +130 -0
  142. package/dist/resources/models/v1/errors.js.map +1 -0
  143. package/dist/resources/models/v1/index.d.ts +36 -0
  144. package/dist/resources/models/v1/index.js +75 -0
  145. package/dist/resources/models/v1/index.js.map +1 -0
  146. package/dist/resources/models/v1/models.d.ts +26 -0
  147. package/dist/resources/models/v1/models.js +44 -0
  148. package/dist/resources/models/v1/models.js.map +1 -0
  149. package/dist/resources/models/v1/translation.d.ts +133 -0
  150. package/dist/resources/models/v1/translation.js +298 -0
  151. package/dist/resources/models/v1/translation.js.map +1 -0
  152. package/dist/resources/roles.d.ts +1 -1
  153. package/dist/resources/roles.js +54 -7
  154. package/dist/resources/roles.js.map +1 -1
  155. package/dist/resources/transaction.js +0 -3
  156. package/dist/resources/transaction.js.map +1 -1
  157. package/dist/security/jsLoader.js +84 -33
  158. package/dist/security/jsLoader.js.map +1 -1
  159. package/dist/security/role.js +4 -0
  160. package/dist/security/role.js.map +1 -1
  161. package/dist/security/superUserGuard.d.ts +7 -0
  162. package/dist/security/superUserGuard.js +23 -0
  163. package/dist/security/superUserGuard.js.map +1 -0
  164. package/dist/security/tokenAuthentication.d.ts +0 -1
  165. package/dist/security/tokenAuthentication.js +6 -2
  166. package/dist/security/tokenAuthentication.js.map +1 -1
  167. package/dist/security/user.d.ts +6 -1
  168. package/dist/security/user.js +23 -1
  169. package/dist/security/user.js.map +1 -1
  170. package/dist/server/REST.js +25 -9
  171. package/dist/server/REST.js.map +1 -1
  172. package/dist/server/fastifyRoutes.js +15 -1
  173. package/dist/server/fastifyRoutes.js.map +1 -1
  174. package/dist/server/http.d.ts +23 -2
  175. package/dist/server/http.js +99 -12
  176. package/dist/server/http.js.map +1 -1
  177. package/dist/server/itc/serverHandlers.js +7 -1
  178. package/dist/server/itc/serverHandlers.js.map +1 -1
  179. package/dist/server/jobs/jobProcess.js +20 -1
  180. package/dist/server/jobs/jobProcess.js.map +1 -1
  181. package/dist/server/jobs/jobRunner.js +10 -0
  182. package/dist/server/jobs/jobRunner.js.map +1 -1
  183. package/dist/server/jobs/jobs.js +11 -0
  184. package/dist/server/jobs/jobs.js.map +1 -1
  185. package/dist/server/middlewareChain.d.ts +10 -1
  186. package/dist/server/middlewareChain.js +81 -21
  187. package/dist/server/middlewareChain.js.map +1 -1
  188. package/dist/server/operationsServer.d.ts +0 -2
  189. package/dist/server/operationsServer.js.map +1 -1
  190. package/dist/server/serverHelpers/multipartParser.js +9 -0
  191. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  192. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  193. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  194. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  195. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  196. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  197. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  198. package/dist/server/serverHelpers/serverHandlers.js +27 -7
  199. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  200. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  201. package/dist/server/serverHelpers/serverUtilities.js +21 -7
  202. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  203. package/dist/server/static.js +68 -21
  204. package/dist/server/static.js.map +1 -1
  205. package/dist/server/storageReclamation.d.ts +17 -0
  206. package/dist/server/storageReclamation.js +90 -6
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/socketRouter.js +20 -0
  209. package/dist/server/threads/socketRouter.js.map +1 -1
  210. package/dist/server/threads/threadServer.js +12 -0
  211. package/dist/server/threads/threadServer.js.map +1 -1
  212. package/dist/sqlEngine/diff/differential.js +7 -3
  213. package/dist/sqlEngine/diff/differential.js.map +1 -1
  214. package/dist/sqlTranslator/index.js +6 -1
  215. package/dist/sqlTranslator/index.js.map +1 -1
  216. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  217. package/dist/upgrade/upgradePrompt.js +22 -3
  218. package/dist/upgrade/upgradePrompt.js.map +1 -1
  219. package/dist/utility/OperationFunctionCaller.js +25 -6
  220. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  221. package/dist/utility/errors/commonErrors.d.ts +1 -0
  222. package/dist/utility/errors/commonErrors.js +1 -0
  223. package/dist/utility/errors/commonErrors.js.map +1 -1
  224. package/dist/utility/hdbTerms.d.ts +13 -1
  225. package/dist/utility/hdbTerms.js +13 -1
  226. package/dist/utility/hdbTerms.js.map +1 -1
  227. package/dist/utility/install/installer.js +32 -0
  228. package/dist/utility/install/installer.js.map +1 -1
  229. package/dist/utility/logging/harper_logger.d.ts +47 -0
  230. package/dist/utility/logging/harper_logger.js +803 -38
  231. package/dist/utility/logging/harper_logger.js.map +1 -1
  232. package/dist/utility/operation_authorization.js +13 -3
  233. package/dist/utility/operation_authorization.js.map +1 -1
  234. package/npm-shrinkwrap.json +198 -193
  235. package/package.json +12 -7
  236. package/resources/DESIGN.md +2 -0
  237. package/resources/DatabaseTransaction.ts +310 -104
  238. package/resources/ResourceInterface.ts +8 -5
  239. package/resources/Resources.ts +22 -4
  240. package/resources/Table.ts +341 -263
  241. package/resources/analytics/write.ts +22 -20
  242. package/resources/blob.ts +31 -14
  243. package/resources/databases.ts +387 -46
  244. package/resources/jsResource.ts +5 -62
  245. package/resources/models/Models.ts +14 -1
  246. package/resources/models/backendRegistry.ts +10 -0
  247. package/resources/models/openaiStream.ts +131 -19
  248. package/resources/models/v1/chatCompletions.ts +128 -0
  249. package/resources/models/v1/embeddings.ts +70 -0
  250. package/resources/models/v1/errors.ts +141 -0
  251. package/resources/models/v1/index.ts +72 -0
  252. package/resources/models/v1/models.ts +53 -0
  253. package/resources/models/v1/translation.ts +362 -0
  254. package/resources/roles.ts +67 -7
  255. package/resources/transaction.ts +0 -3
  256. package/security/jsLoader.ts +84 -30
  257. package/security/role.ts +7 -0
  258. package/security/superUserGuard.ts +20 -0
  259. package/security/tokenAuthentication.ts +6 -3
  260. package/security/user.ts +26 -1
  261. package/server/DESIGN.md +61 -34
  262. package/server/REST.ts +25 -9
  263. package/server/fastifyRoutes.ts +20 -1
  264. package/server/http.ts +100 -13
  265. package/server/itc/serverHandlers.js +7 -1
  266. package/server/jobs/jobProcess.ts +18 -1
  267. package/server/jobs/jobRunner.ts +10 -0
  268. package/server/jobs/jobs.ts +11 -0
  269. package/server/middlewareChain.ts +79 -20
  270. package/server/operationsServer.ts +0 -2
  271. package/server/serverHelpers/multipartParser.ts +9 -0
  272. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  273. package/server/serverHelpers/registeredOperations.ts +19 -15
  274. package/server/serverHelpers/serverHandlers.js +28 -7
  275. package/server/serverHelpers/serverUtilities.ts +29 -7
  276. package/server/static.ts +87 -23
  277. package/server/storageReclamation.ts +104 -8
  278. package/server/threads/socketRouter.ts +20 -0
  279. package/server/threads/threadServer.js +11 -0
  280. package/sqlTranslator/index.ts +6 -1
  281. package/static/defaultConfig.yaml +3 -0
  282. package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-aApwhRmz.js} +2 -2
  283. package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-aApwhRmz.js.map} +1 -1
  284. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-sC0H91n9.js} +4 -4
  285. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
  286. package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-DZ8JhHOI.js} +2 -2
  287. package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
  288. package/studio/web/assets/{applications-BDfH8urd.js → applications-9SEVLIO5.js} +2 -2
  289. package/studio/web/assets/{applications-BDfH8urd.js.map → applications-9SEVLIO5.js.map} +1 -1
  290. package/studio/web/assets/{index-BHo3c2Gk.js → index-Dy3uDGXb.js} +6 -6
  291. package/studio/web/assets/index-Dy3uDGXb.js.map +1 -0
  292. package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-CN1zq4I4.js} +4 -4
  293. package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
  294. package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-BFAF07xr.js} +2 -2
  295. package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-BFAF07xr.js.map} +1 -1
  296. package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CKlYVvVN.js} +2 -2
  297. package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CKlYVvVN.js.map} +1 -1
  298. package/studio/web/assets/{profile-Doj5FVDE.js → profile-A1zhEdFG.js} +2 -2
  299. package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-A1zhEdFG.js.map} +1 -1
  300. package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-SEtBt_GV.js} +2 -2
  301. package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
  302. package/studio/web/assets/{setup-DJwR0BHd.js → setup-BisINqdH.js} +2 -2
  303. package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-BisINqdH.js.map} +1 -1
  304. package/studio/web/assets/{status-Br_AbsJs.js → status-BuQoCc7l.js} +2 -2
  305. package/studio/web/assets/{status-Br_AbsJs.js.map → status-BuQoCc7l.js.map} +1 -1
  306. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-CAi_s1PC.js} +2 -2
  307. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
  308. package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-DT74tlkM.js} +2 -2
  309. package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-DT74tlkM.js.map} +1 -1
  310. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-CGRGc1n7.js} +2 -2
  311. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
  312. package/studio/web/index.html +1 -1
  313. package/upgrade/upgradePrompt.ts +22 -3
  314. package/utility/OperationFunctionCaller.ts +24 -3
  315. package/utility/errors/commonErrors.ts +2 -0
  316. package/utility/hdbTerms.ts +13 -1
  317. package/utility/install/installer.ts +37 -0
  318. package/utility/logging/harper_logger.ts +786 -41
  319. package/utility/operation_authorization.ts +31 -3
  320. package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
package/server/REST.ts CHANGED
@@ -23,7 +23,6 @@ import { entryMap } from '../resources/RecordEncoder.ts';
23
23
  const { errorToString, errorForLog } = harperLogger;
24
24
  const etagBytes = new Uint8Array(8);
25
25
  const etagFloat = new Float64Array(etagBytes.buffer, 0, 1);
26
- let httpOptions = {};
27
26
 
28
27
  const OPENAPI_DOMAIN = 'openapi';
29
28
 
@@ -118,7 +117,7 @@ async function findInactiveComponent(url: string): Promise<string | undefined> {
118
117
  }
119
118
  }
120
119
 
121
- async function http(request: Request, nextHandler) {
120
+ async function http(request: Request, nextHandler, resources: Resources, httpOptions: any) {
122
121
  const headersObject = request.headers.asObject;
123
122
  const isSse = headersObject.accept === 'text/event-stream';
124
123
  const method = isSse ? 'CONNECT' : request.method;
@@ -395,24 +394,41 @@ async function http(request: Request, nextHandler) {
395
394
  }
396
395
  }
397
396
 
398
- let started = false;
399
- let resources: Resources;
397
+ // One registration per distinct route mount, not one per process. Two applications that both
398
+ // enable `rest` under different root-config mounts each need their own chain — a single global
399
+ // `started` flag registered only the first, silently 404ing the other application's REST API.
400
+ const startedMounts = new Set<string>();
400
401
  let addedMetrics;
401
402
  let connectionCount = 0;
402
403
 
403
404
  export function handleApplication(scope: import('../components/Scope.ts').Scope) {
404
- httpOptions = scope.options.getAll();
405
+ // A deploy pre-flight validation scope exists only to validate config, and may share a live
406
+ // component's identity — registering real HTTP/WS handlers from it would splice a validation
407
+ // run into the live request path, and permanently marking its mount as started below would
408
+ // leave the REAL scope's later registration silently skipped (review finding).
409
+ if (scope.isTransientValidation) return;
410
+
411
+ const httpOptions = scope.options.getAll();
405
412
  if ((httpOptions as any).includeExpensiveRecordCountEstimates) {
406
413
  // If they really want to enable expensive record count estimates
407
414
  (Request.prototype as any).includeExpensiveRecordCountEstimates = true;
408
415
  }
409
- resources = scope.resources;
410
- if (started) return;
411
- started = true;
416
+ // Captured per-mount in this closure — not a shared module-level variable — so each mount's
417
+ // registered handler always reads ITS OWN resources/options, not whichever mount happened to
418
+ // register last (review finding: two mounts sharing mutable module globals).
419
+ const resources = scope.resources;
420
+ // Key on the route this registration will actually answer on, not on the parts it is composed
421
+ // from: two configurations that resolve to the same route are the same chain and must dedupe,
422
+ // while two that resolve to different routes must not collide. Concatenating the mount and the
423
+ // plugin's raw urlPath did collide (mount '/a' + 'bc' and mount '/ab' + 'c' both gave '/abc').
424
+ const route = scope.routeFor(httpOptions as any);
425
+ const mountKey = `${route.host ?? ''}|${route.urlPath ?? ''}`;
426
+ if (startedMounts.has(mountKey)) return;
427
+ startedMounts.add(mountKey);
412
428
  scope.server.http(
413
429
  async (request: any, nextHandler) => {
414
430
  if (request.isWebSocket) return;
415
- return http(request, nextHandler);
431
+ return http(request, nextHandler, resources, httpOptions);
416
432
  },
417
433
  { after: 'authentication', ...(httpOptions as any) }
418
434
  );
@@ -1,6 +1,7 @@
1
1
  import { dirname, basename } from 'path';
2
2
  import { existsSync } from 'fs';
3
3
  import { deriveRoutePrefix } from './fastifyRoutes/helpers/deriveRoutePrefix.ts';
4
+ import { resolveBaseURLPath } from '../components/resolveBaseURLPath.ts';
4
5
  import fastify from 'fastify';
5
6
  import fastifyCors from '@fastify/cors';
6
7
  import requestTimePlugin from './serverHelpers/requestTimePlugin.js';
@@ -38,6 +39,19 @@ const routeFolders = new Set();
38
39
  export function handleApplication(scope: import('../components/Scope.ts').Scope) {
39
40
  // if we have a secure port, need to use the secure HTTP server for fastify (it can be used for HTTP as well)
40
41
  const isHttps = (scope.options.getAll() as { securePort?: number }).securePort > 0;
42
+ // Fastify is registered as a global fallback below (`server.http(built.server)` on the bare
43
+ // server, not the scoped one), so it never joins a routed middleware chain. A `urlPath` mount
44
+ // still works — it becomes the fastify route prefix, which is matched against the full external
45
+ // path — but a `host` mount cannot constrain it: these routes would stay reachable under every
46
+ // Host header, silently dropping the isolation the operator declared. Refuse to load rather than
47
+ // imply isolation the fallback doesn't provide (review finding) — contained to this component by
48
+ // the loader's per-component try/catch, so it does not take down the rest of the application.
49
+ const mountedHost = scope.mount?.host ?? (scope.options.getAll() as { host?: string })?.host;
50
+ if (mountedHost) {
51
+ throw new Error(
52
+ `Application '${basename(scope.appName)}' is mounted on host '${mountedHost}', but its fastifyRoutes cannot be constrained by host: legacy fastify routes are registered as a global fallback and would remain reachable on every host. Port them to server.http() to get host routing, or drop the host mount.`
53
+ );
54
+ }
41
55
  scope.handleEntry(async (entry) => {
42
56
  if (entry.eventType !== 'add') {
43
57
  scope.requestRestart();
@@ -62,7 +76,12 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
62
76
  // ignores the deprecated `path` alias, so it can't express the app-name namespace.
63
77
  // `appName` may be an absolute path (loaded via RUN_HDB_APP), hence basename().
64
78
  const config = (scope.options.getAll() as { urlPath?: string; path?: string }) ?? {};
65
- const prefix = deriveRoutePrefix(basename(scope.appName), config.urlPath ?? config.path);
79
+ // Fastify is registered on the bare server, so nothing strips the application's mount before
80
+ // it matches — unlike a routed chain, its prefix must be the full external path.
81
+ const prefix = deriveRoutePrefix(
82
+ basename(scope.appName),
83
+ scope.externalBasePath(resolveBaseURLPath(basename(scope.appName), config.urlPath ?? config.path))
84
+ );
66
85
  if (!routeFolders.has(routeFolder)) {
67
86
  routeFolders.add(routeFolder);
68
87
  try {
package/server/http.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  import { Blob } from '../resources/blob.ts';
29
29
  import { recordAction, recordActionBinary } from '../resources/analytics/write.ts';
30
30
  import { Readable, Writable, pipeline } from 'node:stream';
31
- import { mkdirSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
31
+ import { mkdirSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'node:fs';
32
32
  import { join } from 'node:path';
33
33
  import { server, type ServerOptions, type HttpOptions, type UpgradeOptions, UpgradeListener } from './Server.ts';
34
34
  import { setPortServerMap, SERVERS, socketOptionDefaults } from './serverRegistry.ts';
@@ -67,7 +67,14 @@ export const uwsServeConfigs: Record<string, any> = {};
67
67
  // backends that don't register their own Node http server (Bun, and the uWS HTTP path). Keeping
68
68
  // them out of SERVERS is what prevents a competing Node server from binding the same port.
69
69
  const fallbackServers: Record<string | number, any> = {};
70
- const udsCleanupPaths: { socketPath: string; yamlPath: string }[] = [];
70
+ // `ino`/`dev` are set once this worker's own bind is confirmed (see recordUdsBindSuccess) and are
71
+ // what make cleanupUdsFiles/markUdsBindFailed ownership-aware: on an overlapping restart (see
72
+ // restartWorkers()), the replacement worker rebinds this exact path before the outgoing worker
73
+ // exits, so by the time the outgoing worker cleans up, the identity on disk may already belong to
74
+ // the replacement, not to it. Captured as bigint (statSync's `{ bigint: true }` form): a plain
75
+ // Number loses precision above 2^53, which some filesystems (e.g. XFS with inode64) can exceed,
76
+ // letting two genuinely different inodes compare equal.
77
+ const udsCleanupPaths: { socketPath: string; yamlPath: string; identity?: { dev: bigint; ino: bigint } }[] = [];
71
78
  // Sockets whose listen() has failed (see threadServer.js's fail-soft UDS handling). A TLS mirror's
72
79
  // YAML metadata is written on its own schedule (SNICallback readiness / cert reload), independent of
73
80
  // whether the mirror socket ever actually bound, so a failed bind must both cancel any metadata write
@@ -79,24 +86,100 @@ export function registerUdsCleanupPaths(socketPath: string, yamlPath: string) {
79
86
  udsCleanupPaths.push({ socketPath, yamlPath });
80
87
  }
81
88
 
82
- /** Mark a Unix domain socket's listen() as failed: suppress its metadata and remove any already written. */
89
+ /**
90
+ * Record that this worker's own bind at `socketPath` succeeded, capturing the (dev, inode) pair that
91
+ * proves ownership. Call right after a mirror socket starts listening (see the bind sites in
92
+ * threadServer.js — the Node/h2/raw-socket mirrors via listenOnDomainSocket's onListening, the Bun
93
+ * path right after Bun.serve() returns, and the uWS path once createUwsServer() resolves). A no-op
94
+ * if `socketPath` was never registered.
95
+ */
96
+ export function recordUdsBindSuccess(socketPath: string) {
97
+ const entry = udsCleanupPaths.find((candidate) => candidate.socketPath === socketPath);
98
+ if (!entry) return;
99
+ try {
100
+ const stat = statSync(socketPath, { bigint: true });
101
+ entry.identity = { dev: stat.dev, ino: stat.ino };
102
+ } catch (error) {
103
+ // Extremely unlikely (we just bound this path), but silent failure here is expensive: the
104
+ // entry is permanently treated as unowned, so this worker leaks both files at shutdown with
105
+ // nothing in the logs to explain why.
106
+ harperLogger.warn(`Could not stat freshly bound UDS mirror at ${socketPath}`, error);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 'owned' — the (dev, inode) this worker recorded at bind time still matches what's on disk.
112
+ * 'foreign' — a socket exists at the path, but with a different identity (or none was ever
113
+ * recorded): something else — almost always a replacement worker — currently owns it. Also the
114
+ * safe default when statSync fails for any reason other than ENOENT (e.g. EACCES): we can't
115
+ * confirm the path is genuinely empty, so treat it as though something we can't verify owns it.
116
+ * 'absent' — statSync confirmed ENOENT: nothing is on disk at the path. There is no live owner to
117
+ * protect.
118
+ */
119
+ function ownershipOf(entry: {
120
+ socketPath: string;
121
+ identity?: { dev: bigint; ino: bigint };
122
+ }): 'owned' | 'foreign' | 'absent' {
123
+ let current: { dev: bigint; ino: bigint };
124
+ try {
125
+ const stat = statSync(entry.socketPath, { bigint: true });
126
+ current = { dev: stat.dev, ino: stat.ino };
127
+ } catch (error) {
128
+ return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'absent' : 'foreign';
129
+ }
130
+ return entry.identity !== undefined && current.dev === entry.identity.dev && current.ino === entry.identity.ino
131
+ ? 'owned'
132
+ : 'foreign';
133
+ }
134
+
135
+ /**
136
+ * Mark a Unix domain socket's listen() as failed: suppress its metadata, and remove any already
137
+ * written UNLESS a live socket at this path currently belongs to someone else (a replacement worker
138
+ * that already rebound it). Unlike cleanupUdsFiles, a failed bind never produced a socket of its own,
139
+ * so 'absent' here just means no bind ever won this path — not evidence of a live replacement — and
140
+ * is safe to retract stale metadata for (see server/DESIGN.md's UDS ownership note for why this can't
141
+ * require 'owned': the only real caller, an overlong path, fails before listen() ever runs, so this
142
+ * worker's own identity is never recorded).
143
+ */
83
144
  export function markUdsBindFailed(socketPath: string) {
84
145
  failedUdsPaths.add(socketPath);
85
146
  const entry = udsCleanupPaths.find((candidate) => candidate.socketPath === socketPath);
86
- if (entry) {
87
- try {
88
- unlinkSync(entry.yamlPath);
89
- } catch {}
90
- }
147
+ if (!entry || ownershipOf(entry) === 'foreign') return;
148
+ try {
149
+ unlinkSync(entry.yamlPath);
150
+ } catch {}
91
151
  }
92
152
 
93
153
  export function cleanupUdsFiles() {
94
- for (const { socketPath, yamlPath } of udsCleanupPaths) {
154
+ for (const entry of udsCleanupPaths) {
155
+ // ownershipOf() and the unlinks below are separate syscalls, so a replacement that unlinks
156
+ // and rebinds in the gap between them would still lose its files — unavoidable in Node
157
+ // (no unlink-if-inode-matches primitive), but this narrows that window from the
158
+ // seconds-wide one this fix closes down to microseconds.
159
+ const ownership = ownershipOf(entry);
160
+ // Consume this entry's recorded identity on first use, regardless of outcome. This function
161
+ // runs twice per worker (threadServer.js's SHUTDOWN handler, while this worker's own
162
+ // socket is still open, then again from process.on('exit') after closeServers() has
163
+ // released it) — and a freed inode can be handed to an unrelated later bind before the
164
+ // second call runs. Leaving the stale value in place would let that second call
165
+ // coincidentally re-match a path it no longer has any claim to; clearing it here makes a
166
+ // repeat call a guaranteed no-op instead of a second chance to delete a stranger's files.
167
+ entry.identity = undefined;
168
+ // Unlike markUdsBindFailed (where 'absent' is genuinely safe — its only caller runs
169
+ // synchronously before any bind attempt, with no window for anyone else to be mid-flight),
170
+ // this function's second call (process.on('exit')) can land seconds after the first, while
171
+ // a concurrently-booting replacement — on the non-overlapping-restart platforms, started
172
+ // right after this worker's SHUTDOWN, not after it fully exits — may have already written
173
+ // its own fresh yaml (SNICallback.ready resolves independently of its bind) without having
174
+ // bound its socket yet. Treating that window's 'absent' as retractable would delete the
175
+ // replacement's yaml out from under it. Only 'owned' is safe here; a stale yaml this worker
176
+ // never bound (or no longer owns) is left for markUdsBindFailed or the startup sweep.
177
+ if (ownership !== 'owned') continue;
95
178
  try {
96
- unlinkSync(socketPath);
179
+ unlinkSync(entry.socketPath);
97
180
  } catch {}
98
181
  try {
99
- unlinkSync(yamlPath);
182
+ unlinkSync(entry.yamlPath);
100
183
  } catch {}
101
184
  }
102
185
  }
@@ -161,9 +244,13 @@ export function writeUdsMetadata(
161
244
  }
162
245
  }
163
246
 
164
- /** Clean all files in the sockets directory. Call from main thread on process startup. */
247
+ /**
248
+ * Clean all files in the sockets directory. Call from main thread on process startup, before any
249
+ * worker can bind. Unconditional on the current TLS_UNIXDOMAINSOCKETS setting — the directory is
250
+ * Harper-owned and holds nothing but mirror .sock/.yaml files, so a crash that left stale ones
251
+ * behind while UDS was enabled must still be swept even if this boot has since disabled it.
252
+ */
165
253
  export function cleanupSocketsDirectory() {
166
- if (!env.get(terms.CONFIG_PARAMS.TLS_UNIXDOMAINSOCKETS)) return;
167
254
  const socketsDir = join(env.getHdbBasePath(), 'sockets');
168
255
  try {
169
256
  for (const file of readdirSync(socketsDir)) {
@@ -12,7 +12,7 @@ const harperBridge =
12
12
  require('../../dataLayer/harperBridge/harperBridge.ts');
13
13
  const process = require('process');
14
14
  const { isMainThread, workerData } = require('worker_threads');
15
- const { resetDatabases } = require('../../resources/databases.ts');
15
+ const { resetDatabases, closeDatabase } = require('../../resources/databases.ts');
16
16
 
17
17
  /**
18
18
  * This object/functions are passed to the ITC client instance and dynamically added as event handlers.
@@ -46,6 +46,12 @@ async function schemaHandler(event) {
46
46
  }
47
47
 
48
48
  hdbLogger.trace(`ITC schemaHandler received schema event:`, event);
49
+ // restore_backup: this thread must release its store handles so the restore can purge and
50
+ // rewrite the database directory. The rescan below (resetDatabases) skips reloading it while
51
+ // the restoring marker is present, and reloads it on the completion signal (marker gone).
52
+ if (event.message?.operation === hdbTerms.OPERATIONS_ENUM.RESTORE_BACKUP && event.message.schema) {
53
+ closeDatabase(event.message.schema);
54
+ }
49
55
  await cleanLmdbMap(event.message);
50
56
  await syncSchemaMetadata(event.message);
51
57
  for (let listener of schemaListeners) {
@@ -77,7 +77,24 @@ const JOB_ID = JOB_NAME.substring(4);
77
77
  jobObj.message = err.message ? err.message : err;
78
78
  jobObj.end_datetime = moment().valueOf();
79
79
  } finally {
80
- await jobs.updateJob(jobObj);
80
+ // A rejected updateJob must not skip handle cleanup and exit scheduling below (that would
81
+ // leak this worker's process-global RocksDB handles and leave the worker hanging).
82
+ try {
83
+ await jobs.updateJob(jobObj);
84
+ } catch (updateErr) {
85
+ harperLogger.error('Error updating job record on job worker exit:', updateErr);
86
+ }
87
+ // Release this worker's RocksDB handles before it exits. A job worker opens the whole
88
+ // database graph via getDatabases(); rocksdb-js's registry is process-global and a thread
89
+ // that exits without closing leaks its handles process-wide, which (among other costs)
90
+ // blocks an online restore_backup from confirming the target database is closed. Best
91
+ // effort — never let cleanup mask the job result.
92
+ try {
93
+ const { closeLoadedDatabases } = await import('../../resources/databases.ts');
94
+ closeLoadedDatabases();
95
+ } catch (closeErr) {
96
+ harperLogger.warn('Error releasing database handles on job worker exit:', closeErr);
97
+ }
81
98
  // On Bun 1.3.13, calling process.exit() in a worker thread with lmdb-js loaded
82
99
  // while sibling workers are running causes a NAPI fatal error crash. Unref
83
100
  // parentPort (which broadcastWithAcknowledgement may have ref'd during schema
@@ -10,6 +10,7 @@ import log from '../../utility/logging/harper_logger.ts';
10
10
  import * as jobs from './jobs.ts';
11
11
  import * as hdbExport from '../../dataLayer/export.ts';
12
12
  import * as hdbDelete from '../../dataLayer/delete.ts';
13
+ import * as rocksdbBackup from '../../dataLayer/rocksdbBackup.ts';
13
14
  import * as threadsStart from '../threads/manageThreads.js';
14
15
  import * as transactionLog from '../../utility/logging/transactionLog.ts';
15
16
  import * as restart from '../../bin/restart.ts';
@@ -79,6 +80,15 @@ async function parseMessage(runnerMessage: any) {
79
80
  case hdbTerms.JOB_TYPE_ENUM.delete_transaction_logs_before:
80
81
  await runJob(runnerMessage, transactionLog.deleteTransactionLogsBefore);
81
82
  break;
83
+ case hdbTerms.JOB_TYPE_ENUM.create_backup:
84
+ await runJob(runnerMessage, rocksdbBackup.createBackup);
85
+ break;
86
+ case hdbTerms.JOB_TYPE_ENUM.verify_backup:
87
+ await runJob(runnerMessage, rocksdbBackup.verifyBackup);
88
+ break;
89
+ case hdbTerms.JOB_TYPE_ENUM.restore_backup:
90
+ await runJob(runnerMessage, rocksdbBackup.restoreBackup);
91
+ break;
82
92
  case hdbTerms.JOB_TYPE_ENUM.restart_service:
83
93
  await runJob(runnerMessage, restart.restartService);
84
94
  return `Restarting ${runnerMessage.json.service}`;
@@ -20,6 +20,7 @@ import * as hdbUtil from '../../utility/common_utils.ts';
20
20
  import { promisify } from 'util';
21
21
  import moment from 'moment';
22
22
  import * as fileLoadValidator from '../../validation/fileLoadValidator.ts';
23
+ import * as rocksdbBackup from '../../dataLayer/rocksdbBackup.ts';
23
24
  import bulkDeleteValidator from '../../validation/bulkDeleteValidator.ts';
24
25
  import { deleteTransactionLogsBeforeValidator } from '../../validation/transactionLogValidator.ts';
25
26
  import { handleHDBError, ClientError } from '../../utility/errors/hdbError.ts';
@@ -122,6 +123,16 @@ export async function addJob(jsonBody: any) {
122
123
  throw handleHDBError(new Error(), 'Invalid service', HTTP_STATUS_CODES.BAD_REQUEST, undefined, undefined, true);
123
124
  }
124
125
  break;
126
+ // backup job validators throw ClientError (with statusCode) directly on failure
127
+ case hdbTerms.OPERATIONS_ENUM.CREATE_BACKUP:
128
+ await rocksdbBackup.validateCreateBackup(jsonBody);
129
+ break;
130
+ case hdbTerms.OPERATIONS_ENUM.VERIFY_BACKUP:
131
+ await rocksdbBackup.validateVerifyBackup(jsonBody);
132
+ break;
133
+ case hdbTerms.OPERATIONS_ENUM.RESTORE_BACKUP:
134
+ await rocksdbBackup.validateRestoreBackup(jsonBody);
135
+ break;
125
136
  default:
126
137
  break;
127
138
  }
@@ -151,17 +151,43 @@ export function normalizeUrlPath(urlPath: string | undefined): string | undefine
151
151
  return urlPath.length <= 1 ? undefined : urlPath;
152
152
  }
153
153
 
154
+ /**
155
+ * Extracts the hostname from a Host/`:authority` header value: the port is dropped and an IPv6
156
+ * literal is unwrapped from its brackets ('[::1]:9926' -> '::1'), so a bracket-less configured
157
+ * host can match. Lowercased because hostnames are case-insensitive (RFC 4343) — a configured
158
+ * 'API.example.com' must match the 'api.example.com' a client actually sends. A trailing dot
159
+ * (the absolute-FQDN form some resolvers/clients emit, e.g. 'api.example.com.') is stripped —
160
+ * it names the same origin per RFC 1035.
161
+ */
162
+ export function hostnameFromHeader(hostHeader: string): string {
163
+ if (hostHeader.startsWith('[')) {
164
+ const end = hostHeader.indexOf(']');
165
+ if (end !== -1) return hostHeader.slice(1, end).toLowerCase();
166
+ }
167
+ const colon = hostHeader.indexOf(':');
168
+ const hostname = (colon === -1 ? hostHeader : hostHeader.slice(0, colon)).toLowerCase();
169
+ return hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
170
+ }
171
+
172
+ /**
173
+ * Reads the request's authority for host matching: `request.host` (Harper's `Request` class)
174
+ * already resolves this correctly for both HTTP/1 (`Host` header) and HTTP/2 (`:authority`
175
+ * pseudo-header — HTTP/2 clients don't send `Host` at all). The header fallbacks below only
176
+ * cover request-like objects that don't carry that getter (e.g. bare fakes in tests).
177
+ */
178
+ function requestAuthority(request: any): string {
179
+ return request.host ?? request.headers?.asObject?.[':authority'] ?? request.headers?.asObject?.host ?? '';
180
+ }
181
+
154
182
  /**
155
183
  * Returns true when `request` satisfies the route's host and urlPath constraints.
156
184
  * urlPath matching is prefix-based and segment-boundary-aware:
157
185
  * '/api' matches '/api' and '/api/foo' but NOT '/api2'.
158
- * Trailing slashes on `route.urlPath` are ignored.
186
+ * Trailing slashes on `route.urlPath` are ignored. Host matching ignores the port and case.
159
187
  */
160
188
  export function matchesRoute(request: any, route: { host?: string; urlPath?: string }): boolean {
161
189
  if (route.host) {
162
- const hostHeader: string = request.headers?.asObject?.host ?? '';
163
- const requestHost = hostHeader.split(':')[0];
164
- if (requestHost !== route.host) return false;
190
+ if (hostnameFromHeader(requestAuthority(request)) !== route.host.toLowerCase()) return false;
165
191
  }
166
192
  const urlPath = normalizeUrlPath(route.urlPath);
167
193
  if (urlPath) {
@@ -230,10 +256,18 @@ export function stripPrefix(request: any, prefix: string): any {
230
256
  * and `describeChains` reports it, so the observed order can never drift from the served one.
231
257
  */
232
258
  export function resolveRoutedChains(portEntries: HttpEntry[], onCycle?: () => void): ResolvedChain[] {
233
- // Global name registry across all routes (first registration wins)
234
- const nameToEntry = new Map<string, HttpEntry>();
259
+ // Global name registry, but restricted to unmounted (no host/urlPath) entries — e.g.
260
+ // `authentication` so it only ever supplies dependencies that are meant to apply everywhere.
261
+ // A mounted route's own plugins are resolved separately per-group below; if this stayed global
262
+ // across ALL routes (mounted or not), two applications mounted at different hosts/paths that
263
+ // each happen to register a same-named plugin (e.g. both enable `rest`) would resolve `after:
264
+ // 'rest'` to whichever one registered first — silently splicing one application's handler into
265
+ // another's request chain (review finding).
266
+ const globalNameToEntry = new Map<string, HttpEntry>();
235
267
  for (const entry of portEntries) {
236
- if (entry.name && !nameToEntry.has(entry.name)) nameToEntry.set(entry.name, entry);
268
+ if (entry.name && !entry.host && !entry.urlPath && !globalNameToEntry.has(entry.name)) {
269
+ globalNameToEntry.set(entry.name, entry);
270
+ }
237
271
  }
238
272
 
239
273
  // Group entries by (host, normalized urlPath) so that '/api' and '/api/' coalesce.
@@ -249,11 +283,24 @@ export function resolveRoutedChains(portEntries: HttpEntry[], onCycle?: () => vo
249
283
  const defaultGroup = routeGroups.find((g) => !g.host && !g.urlPath);
250
284
  const subRouteGroups = routeGroups.filter((g) => g.host || g.urlPath);
251
285
 
252
- const subRoutes: ResolvedChain[] = subRouteGroups.map((group) => ({
253
- host: group.host,
254
- urlPath: group.urlPath,
255
- order: topoSort(resolveDeps(group.entries, nameToEntry), onCycle),
256
- }));
286
+ const subRoutes: ResolvedChain[] = subRouteGroups.map((group) => {
287
+ // This route's own plugins take priority over the global (unmounted) registry for the
288
+ // same name, so a same-named handler local to this mount always wins over an unrelated
289
+ // unmounted one — but the lookup never reaches into another mounted route's plugins.
290
+ const localNameToEntry = new Map(globalNameToEntry);
291
+ const seenInGroup = new Set<string>();
292
+ for (const entry of group.entries) {
293
+ if (entry.name && !seenInGroup.has(entry.name)) {
294
+ seenInGroup.add(entry.name);
295
+ localNameToEntry.set(entry.name, entry);
296
+ }
297
+ }
298
+ return {
299
+ host: group.host,
300
+ urlPath: group.urlPath,
301
+ order: topoSort(resolveDeps(group.entries, localNameToEntry), onCycle),
302
+ };
303
+ });
257
304
 
258
305
  subRoutes.sort((a, b) => {
259
306
  const aSpec = (a.host ? 2 : 0) + (a.urlPath ? 1 : 0);
@@ -274,23 +321,35 @@ export function buildRoutedChain(
274
321
  const resolved = resolveRoutedChains(portEntries, onCycle);
275
322
  // resolveRoutedChains returns sub-routes (dispatch order) followed by the default route last.
276
323
  const defaultChain = buildLinearChain(resolved[resolved.length - 1].order, fallback);
324
+ // hostLower/urlPathWithSlash are computed once here rather than per request/per candidate:
325
+ // `route.urlPath` is already normalized by resolveRoutedChains, but `route.host` isn't
326
+ // (plugin-config hosts via routeFor aren't pre-lowercased), and matchesRoute's generic form
327
+ // would otherwise re-run toLowerCase()/a regex/a concat on every probe of every request —
328
+ // this dispatch is the mainline path once a port has any mounted route.
277
329
  const subRouteChains = resolved.slice(0, -1).map((route) => ({
278
- host: route.host,
330
+ hostLower: route.host?.toLowerCase(),
279
331
  urlPath: route.urlPath,
332
+ urlPathWithSlash: route.urlPath ? route.urlPath + '/' : undefined,
280
333
  chain: buildLinearChain(route.order, fallback),
281
334
  }));
335
+ const anyHostRoute = subRouteChains.some((route) => route.hostLower);
282
336
 
283
337
  return function dispatch(...args: any[]) {
284
338
  const request = args[requestArgIndex];
339
+ // The request's authority is the same for every candidate probed below, so it's
340
+ // extracted once per request rather than once per candidate — and skipped entirely
341
+ // when no mounted route in this chain matches on host at all.
342
+ const requestHost = anyHostRoute ? hostnameFromHeader(requestAuthority(request)) : undefined;
343
+ const pathname: string = request.pathname ?? '/';
285
344
  for (const route of subRouteChains) {
286
- if (matchesRoute(request, route)) {
287
- if (route.urlPath) {
288
- const newArgs = args.slice();
289
- newArgs[requestArgIndex] = stripPrefix(request, route.urlPath);
290
- return route.chain(...newArgs);
291
- }
292
- return route.chain(...args);
345
+ if (route.hostLower && requestHost !== route.hostLower) continue;
346
+ if (route.urlPath) {
347
+ if (pathname !== route.urlPath && !pathname.startsWith(route.urlPathWithSlash as string)) continue;
348
+ const newArgs = args.slice();
349
+ newArgs[requestArgIndex] = stripPrefix(request, route.urlPath);
350
+ return route.chain(...newArgs);
293
351
  }
352
+ return route.chain(...args);
294
353
  }
295
354
  return defaultChain(...args);
296
355
  };
@@ -137,10 +137,8 @@ export interface ImpersonatePayload {
137
137
 
138
138
  interface BaseOperationRequestBody {
139
139
  operation: OperationFunctionName;
140
- bypassAuth: boolean;
141
140
  hdb_user?: userSchema.User;
142
141
  hdbAuthHeader?: unknown;
143
- bypass_auth?: boolean;
144
142
  impersonate?: ImpersonatePayload;
145
143
  password?: string;
146
144
  payload?: string;
@@ -11,6 +11,7 @@ interface MultipartBody {
11
11
 
12
12
  const FIELD_SIZE_LIMIT = 1024 * 1024; // 1 MB per non-file field — generous for JSON params
13
13
  const MAX_FIELDS = 64;
14
+ const UNSAFE_FIELD_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
14
15
  // Operation handlers stream the file part directly into extraction (gunzip + tar-fs),
15
16
  // so there is no separate filesize cap to enforce here. Backpressure flows through busboy
16
17
  // → the file Readable → the consumer, bounded by disk space rather than memory.
@@ -73,6 +74,14 @@ export function parseMultipartRequest(
73
74
  logger.warn?.(`Multipart field "${name}" arrived after the file part; ignoring`);
74
75
  return;
75
76
  }
77
+ if (UNSAFE_FIELD_NAMES.has(name)) {
78
+ const error = new ClientError(`Multipart field "${name}" is not allowed`, 400);
79
+ callDone(error);
80
+ // Stop accepting bytes from a malicious request immediately. The raw-stream error
81
+ // handler below also tears down busboy, so a later file part cannot remain unread.
82
+ rawStream.destroy(error);
83
+ return;
84
+ }
76
85
  body[name] = decodeFieldValue(value);
77
86
  });
78
87
 
@@ -0,0 +1,11 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ const operationAuthorizationState = new AsyncLocalStorage<boolean>();
4
+
5
+ export function runWithOperationAuthorizationBypass<T>(bypassAuth: boolean, callback: () => T): T {
6
+ return operationAuthorizationState.run(bypassAuth === true, callback);
7
+ }
8
+
9
+ export function isOperationAuthorizationBypassed(): boolean {
10
+ return operationAuthorizationState.getStore() === true;
11
+ }
@@ -27,6 +27,7 @@ import harperLogger from '../../utility/logging/harper_logger.ts';
27
27
  import { ServerError } from '../../utility/errors/hdbError.ts';
28
28
  import { sendItcEvent } from '../threads/itc.js';
29
29
  import { onMessageByType, onThreadExit } from '../threads/manageThreads.js';
30
+ import { runWithOperationAuthorizationBypass } from './operationAuthorizationState.ts';
30
31
 
31
32
  const operationLog = harperLogger.loggerWithTag('operation');
32
33
 
@@ -49,7 +50,7 @@ const EXECUTE_TIMEOUT_MS = env.get(terms.CONFIG_PARAMS.OPERATIONSAPI_NETWORK_TIM
49
50
  // survive the dist build). A worker can only receive an execute request after announcing a
50
51
  // registration — which goes through serverUtilities — so these are always set on that path.
51
52
  let localDispatch: {
52
- chooseOperation: (body: any) => Function;
53
+ chooseOperation: (body: any, bypassAuth?: boolean) => Function;
53
54
  processLocalTransaction: (req: any, operationFunction: Function) => Promise<any>;
54
55
  };
55
56
  export function setLocalOperationDispatch(dispatch: typeof localDispatch) {
@@ -99,10 +100,13 @@ let rotation = 0;
99
100
  * function and metadata that `verifyPerms` needs only exist on the worker, which runs the full
100
101
  * `chooseOperation` check with the forwarded `hdb_user`.
101
102
  */
102
- export function getRemoteOperationFunction(name: string): ((body: any) => Promise<any>) | undefined {
103
+ export function getRemoteOperationFunction(
104
+ name: string,
105
+ bypassAuth = false
106
+ ): ((body: any) => Promise<any>) | undefined {
103
107
  if (!isMainThread) return undefined;
104
108
  if (!registeredByWorker.has(name)) return undefined;
105
- return (body: any) => executeRemoteOperation(name, body);
109
+ return (body: any) => executeRemoteOperation(name, body, bypassAuth);
106
110
  }
107
111
 
108
112
  function attachMainListeners() {
@@ -135,7 +139,7 @@ function attachMainListeners() {
135
139
  });
136
140
  }
137
141
 
138
- async function executeRemoteOperation(name: string, body: any): Promise<any> {
142
+ async function executeRemoteOperation(name: string, body: any, bypassAuth: boolean): Promise<any> {
139
143
  attachMainListeners();
140
144
  const workerIds = registeredByWorker.get(name);
141
145
  const forwardBody = { ...body };
@@ -151,7 +155,7 @@ async function executeRemoteOperation(name: string, body: any): Promise<any> {
151
155
  try {
152
156
  sent = threads.sendToThread(targetThreadId, {
153
157
  type: terms.ITC_EVENT_TYPES.OPERATION_EXECUTE_REQUEST,
154
- message: { requestId, body: forwardBody, originator: threadId },
158
+ message: { requestId, body: forwardBody, bypassAuth, originator: threadId },
155
159
  });
156
160
  } catch (error) {
157
161
  // Structured-clone failure (e.g. a function or native handle on the request body) is a
@@ -198,20 +202,20 @@ async function executeRemoteOperation(name: string, body: any): Promise<any> {
198
202
  * (permission check + processLocalTransaction) and send the result back to the main thread.
199
203
  */
200
204
  export async function operationExecuteRequestHandler(event: {
201
- message: { requestId: number; body: any; originator: number };
205
+ message: { requestId: number; body: any; bypassAuth?: boolean; originator: number };
202
206
  }) {
203
- const { requestId, body, originator } = event.message;
207
+ const { requestId, body, bypassAuth, originator } = event.message;
204
208
  let response;
205
209
  try {
206
210
  if (!localDispatch) throw new ServerError('This worker thread cannot execute operations', 503);
207
- // bypass_auth is trusted as forwarded: the main thread already strips it from external
208
- // HTTP requests (handlePostRequest, before any dispatch decision), and an internal caller
209
- // legitimately setting it (server.operation(op, context, false)) must see the same
210
- // authorize:false behavior whether the operation happens to run locally or via this bridge.
211
- // ITC is an internal, same-process trust boundary — a worker able to forge this message
212
- // already has direct DB access and gains nothing by spoofing bypass_auth.
213
- const operationFunction = localDispatch.chooseOperation(body);
214
- const result = await localDispatch.processLocalTransaction({ body }, operationFunction);
211
+ // Authorization state travels in the trusted same-process ITC envelope, never in the
212
+ // caller-controlled operation body. This preserves server.operation(..., false) across
213
+ // worker forwarding without creating a client-spoofable request property.
214
+ const trustedBypassAuth = bypassAuth === true;
215
+ const result = await runWithOperationAuthorizationBypass(trustedBypassAuth, () => {
216
+ const operationFunction = localDispatch.chooseOperation(body, trustedBypassAuth);
217
+ return localDispatch.processLocalTransaction({ body }, operationFunction);
218
+ });
215
219
  if (result instanceof Readable || typeof result?.pipe === 'function') {
216
220
  // Streaming results would need MessagePort transfer plumbing — explicitly unsupported
217
221
  // for worker-registered operations for now (#1736), rather than failing opaquely.