@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.2

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 (347) hide show
  1. package/bin/cliOperations.ts +12 -0
  2. package/bin/restart.ts +66 -6
  3. package/components/Application.ts +1134 -112
  4. package/components/DESIGN.md +421 -0
  5. package/components/OptionsWatcher.ts +368 -102
  6. package/components/Scope.ts +20 -1
  7. package/components/componentLoader.ts +43 -4
  8. package/components/deploymentOperations.ts +4 -1
  9. package/components/deploymentRecorder.ts +9 -2
  10. package/components/mcp/DESIGN.md +109 -0
  11. package/components/mcp/audit.ts +21 -17
  12. package/components/operations.js +284 -52
  13. package/components/operationsValidation.js +49 -2
  14. package/components/packageComponent.ts +25 -1
  15. package/components/requestRestart.ts +11 -0
  16. package/config/DESIGN.md +306 -0
  17. package/config/RootConfigWatcher.ts +191 -37
  18. package/config/configReadRetry.ts +62 -0
  19. package/config/configUtils.ts +78 -26
  20. package/config/parseConfigFile.ts +34 -0
  21. package/config/readConfigFileSync.ts +44 -0
  22. package/config/watcherArming.ts +59 -0
  23. package/config-root.schema.json +4 -0
  24. package/dataLayer/DESIGN.md +179 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
  26. package/dataLayer/restoreMarker.ts +92 -25
  27. package/dist/bin/cliOperations.js +13 -0
  28. package/dist/bin/cliOperations.js.map +1 -1
  29. package/dist/bin/restart.js +42 -6
  30. package/dist/bin/restart.js.map +1 -1
  31. package/dist/components/Application.d.ts +104 -9
  32. package/dist/components/Application.js +954 -102
  33. package/dist/components/Application.js.map +1 -1
  34. package/dist/components/OptionsWatcher.d.ts +4 -1
  35. package/dist/components/OptionsWatcher.js +378 -104
  36. package/dist/components/OptionsWatcher.js.map +1 -1
  37. package/dist/components/Scope.js +15 -1
  38. package/dist/components/Scope.js.map +1 -1
  39. package/dist/components/componentLoader.js +35 -3
  40. package/dist/components/componentLoader.js.map +1 -1
  41. package/dist/components/deploymentOperations.js +4 -1
  42. package/dist/components/deploymentOperations.js.map +1 -1
  43. package/dist/components/deploymentRecorder.d.ts +4 -2
  44. package/dist/components/deploymentRecorder.js +1 -0
  45. package/dist/components/deploymentRecorder.js.map +1 -1
  46. package/dist/components/mcp/audit.d.ts +2 -1
  47. package/dist/components/mcp/audit.js +21 -17
  48. package/dist/components/mcp/audit.js.map +1 -1
  49. package/dist/components/operations.d.ts +28 -0
  50. package/dist/components/operations.js +263 -54
  51. package/dist/components/operations.js.map +1 -1
  52. package/dist/components/operationsValidation.js +48 -2
  53. package/dist/components/operationsValidation.js.map +1 -1
  54. package/dist/components/packageComponent.js +24 -0
  55. package/dist/components/packageComponent.js.map +1 -1
  56. package/dist/components/requestRestart.d.ts +1 -0
  57. package/dist/components/requestRestart.js +7 -0
  58. package/dist/components/requestRestart.js.map +1 -1
  59. package/dist/config/RootConfigWatcher.d.ts +2 -0
  60. package/dist/config/RootConfigWatcher.js +189 -35
  61. package/dist/config/RootConfigWatcher.js.map +1 -1
  62. package/dist/config/configReadRetry.d.ts +8 -0
  63. package/dist/config/configReadRetry.js +62 -0
  64. package/dist/config/configReadRetry.js.map +1 -0
  65. package/dist/config/configUtils.d.ts +10 -9
  66. package/dist/config/configUtils.js +63 -27
  67. package/dist/config/configUtils.js.map +1 -1
  68. package/dist/config/parseConfigFile.d.ts +4 -0
  69. package/dist/config/parseConfigFile.js +35 -0
  70. package/dist/config/parseConfigFile.js.map +1 -0
  71. package/dist/config/readConfigFileSync.d.ts +1 -0
  72. package/dist/config/readConfigFileSync.js +47 -0
  73. package/dist/config/readConfigFileSync.js.map +1 -0
  74. package/dist/config/watcherArming.d.ts +15 -0
  75. package/dist/config/watcherArming.js +59 -0
  76. package/dist/config/watcherArming.js.map +1 -0
  77. package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
  78. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  79. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  80. package/dist/dataLayer/restoreMarker.js +94 -27
  81. package/dist/dataLayer/restoreMarker.js.map +1 -1
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.js +9 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/json/systemSchema.json +3 -0
  86. package/dist/resources/DatabaseTransaction.d.ts +25 -0
  87. package/dist/resources/DatabaseTransaction.js +224 -18
  88. package/dist/resources/DatabaseTransaction.js.map +1 -1
  89. package/dist/resources/LMDBTransaction.d.ts +2 -1
  90. package/dist/resources/LMDBTransaction.js +22 -3
  91. package/dist/resources/LMDBTransaction.js.map +1 -1
  92. package/dist/resources/PrimaryRocksDatabase.js +22 -6
  93. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  94. package/dist/resources/RecordEncoder.d.ts +1 -1
  95. package/dist/resources/RecordEncoder.js +46 -10
  96. package/dist/resources/RecordEncoder.js.map +1 -1
  97. package/dist/resources/Resource.js +97 -13
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/ResourceInterface.d.ts +8 -0
  100. package/dist/resources/RocksIndexStore.js +2 -1
  101. package/dist/resources/RocksIndexStore.js.map +1 -1
  102. package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
  103. package/dist/resources/RocksTransactionLogStore.js +104 -33
  104. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  105. package/dist/resources/Table.d.ts +58 -7
  106. package/dist/resources/Table.js +1143 -348
  107. package/dist/resources/Table.js.map +1 -1
  108. package/dist/resources/analytics/write.d.ts +3 -0
  109. package/dist/resources/analytics/write.js +49 -16
  110. package/dist/resources/analytics/write.js.map +1 -1
  111. package/dist/resources/auditStore.d.ts +170 -0
  112. package/dist/resources/auditStore.js +457 -11
  113. package/dist/resources/auditStore.js.map +1 -1
  114. package/dist/resources/crdt.d.ts +10 -0
  115. package/dist/resources/crdt.js +22 -0
  116. package/dist/resources/crdt.js.map +1 -1
  117. package/dist/resources/dataLoader.js +3 -4
  118. package/dist/resources/dataLoader.js.map +1 -1
  119. package/dist/resources/databases.d.ts +16 -13
  120. package/dist/resources/databases.js +625 -177
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  123. package/dist/resources/derivedIndexRegistry.js +68 -0
  124. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  125. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  126. package/dist/resources/derivedIndexRuntime.js +2027 -0
  127. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  128. package/dist/resources/graphql.js +3 -2
  129. package/dist/resources/graphql.js.map +1 -1
  130. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
  131. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +867 -39
  132. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  133. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  134. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  135. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  136. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  137. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  138. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  139. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  140. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  141. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  142. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  143. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  144. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  145. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  146. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  147. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  148. package/dist/resources/nodeIdMapping.d.ts +5 -0
  149. package/dist/resources/nodeIdMapping.js +49 -0
  150. package/dist/resources/nodeIdMapping.js.map +1 -1
  151. package/dist/resources/recordLock.d.ts +47 -4
  152. package/dist/resources/recordLock.js +138 -7
  153. package/dist/resources/recordLock.js.map +1 -1
  154. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  155. package/dist/resources/recordLockCoordinator.js +2597 -0
  156. package/dist/resources/recordLockCoordinator.js.map +1 -0
  157. package/dist/resources/replayLogs.js +5 -0
  158. package/dist/resources/replayLogs.js.map +1 -1
  159. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  160. package/dist/resources/replicatedApplyFailure.js +63 -0
  161. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  162. package/dist/resources/scheduler/scheduler.js +3 -3
  163. package/dist/resources/scheduler/scheduler.js.map +1 -1
  164. package/dist/resources/search.d.ts +10 -4
  165. package/dist/resources/search.js +160 -40
  166. package/dist/resources/search.js.map +1 -1
  167. package/dist/resources/tracked.d.ts +5 -1
  168. package/dist/resources/tracked.js +74 -23
  169. package/dist/resources/tracked.js.map +1 -1
  170. package/dist/resources/transactionBroadcast.js +4 -6
  171. package/dist/resources/transactionBroadcast.js.map +1 -1
  172. package/dist/security/auth.js +59 -23
  173. package/dist/security/auth.js.map +1 -1
  174. package/dist/security/deferredAuthentication.d.ts +11 -0
  175. package/dist/security/deferredAuthentication.js +25 -3
  176. package/dist/security/deferredAuthentication.js.map +1 -1
  177. package/dist/security/jsLoader.js +9 -4
  178. package/dist/security/jsLoader.js.map +1 -1
  179. package/dist/server/REST.js +39 -5
  180. package/dist/server/REST.js.map +1 -1
  181. package/dist/server/http.d.ts +5 -1
  182. package/dist/server/http.js +34 -2
  183. package/dist/server/http.js.map +1 -1
  184. package/dist/server/mqtt.js +5 -1
  185. package/dist/server/mqtt.js.map +1 -1
  186. package/dist/server/serverHelpers/Headers.d.ts +2 -0
  187. package/dist/server/serverHelpers/Headers.js +6 -0
  188. package/dist/server/serverHelpers/Headers.js.map +1 -1
  189. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  190. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  191. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  192. package/dist/server/serverHelpers/Request.d.ts +5 -10
  193. package/dist/server/serverHelpers/Request.js +38 -136
  194. package/dist/server/serverHelpers/Request.js.map +1 -1
  195. package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
  196. package/dist/server/serverHelpers/contentTypes.js +189 -15
  197. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  198. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  199. package/dist/server/serverHelpers/serverUtilities.js +110 -20
  200. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  201. package/dist/server/serverHelpers/uwsServer.js +4 -1
  202. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  203. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  204. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  205. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  206. package/dist/server/storageReclamation.js +1 -1
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  209. package/dist/server/threads/isolatedApplications.js +171 -0
  210. package/dist/server/threads/isolatedApplications.js.map +1 -0
  211. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  212. package/dist/server/threads/logRotationTransport.js +33 -0
  213. package/dist/server/threads/logRotationTransport.js.map +1 -0
  214. package/dist/server/threads/manageThreads.d.ts +64 -6
  215. package/dist/server/threads/manageThreads.js +261 -12
  216. package/dist/server/threads/manageThreads.js.map +1 -1
  217. package/dist/server/threads/socketRouter.d.ts +1 -0
  218. package/dist/server/threads/socketRouter.js +196 -13
  219. package/dist/server/threads/socketRouter.js.map +1 -1
  220. package/dist/server/threads/threadServer.js +30 -7
  221. package/dist/server/threads/threadServer.js.map +1 -1
  222. package/dist/utility/errors/hdbError.d.ts +24 -0
  223. package/dist/utility/errors/hdbError.js +58 -1
  224. package/dist/utility/errors/hdbError.js.map +1 -1
  225. package/dist/utility/hdbTerms.d.ts +2 -0
  226. package/dist/utility/hdbTerms.js +2 -0
  227. package/dist/utility/hdbTerms.js.map +1 -1
  228. package/dist/utility/logging/harper_logger.js +218 -38
  229. package/dist/utility/logging/harper_logger.js.map +1 -1
  230. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  231. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  232. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  233. package/dist/utility/logging/logRotation.d.ts +46 -0
  234. package/dist/utility/logging/logRotation.js +365 -0
  235. package/dist/utility/logging/logRotation.js.map +1 -0
  236. package/dist/utility/logging/logRotator.d.ts +1 -1
  237. package/dist/utility/logging/logRotator.js +172 -92
  238. package/dist/utility/logging/logRotator.js.map +1 -1
  239. package/dist/utility/npmUtilities.js +6 -4
  240. package/dist/utility/npmUtilities.js.map +1 -1
  241. package/dist/utility/watcherFallback.d.ts +0 -45
  242. package/dist/utility/watcherFallback.js +1 -125
  243. package/dist/utility/watcherFallback.js.map +1 -1
  244. package/dist/validation/configValidator.js +6 -3
  245. package/dist/validation/configValidator.js.map +1 -1
  246. package/index.ts +9 -0
  247. package/json/systemSchema.json +3 -0
  248. package/npm-shrinkwrap.json +177 -87
  249. package/package.json +13 -5
  250. package/resources/DESIGN.md +689 -19
  251. package/resources/DatabaseTransaction.ts +230 -17
  252. package/resources/LMDBTransaction.ts +21 -3
  253. package/resources/PrimaryRocksDatabase.ts +20 -7
  254. package/resources/RecordEncoder.ts +52 -10
  255. package/resources/Resource.ts +97 -13
  256. package/resources/ResourceInterface.ts +8 -0
  257. package/resources/RocksIndexStore.ts +2 -1
  258. package/resources/RocksTransactionLogStore.ts +111 -31
  259. package/resources/Table.ts +1273 -396
  260. package/resources/analytics/DESIGN.md +38 -0
  261. package/resources/analytics/write.ts +50 -17
  262. package/resources/auditStore.ts +460 -11
  263. package/resources/crdt.ts +22 -0
  264. package/resources/dataLoader.ts +3 -4
  265. package/resources/databases.ts +611 -147
  266. package/resources/derivedIndexRegistry.ts +56 -0
  267. package/resources/derivedIndexRuntime.ts +2292 -0
  268. package/resources/graphql.ts +3 -2
  269. package/resources/indexes/DESIGN.md +833 -0
  270. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +924 -46
  271. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  272. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  273. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  274. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  275. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  276. package/resources/nodeIdMapping.ts +50 -0
  277. package/resources/record-locks.md +1407 -0
  278. package/resources/recordLock.ts +173 -7
  279. package/resources/recordLockCoordinator.ts +3082 -0
  280. package/resources/replayLogs.ts +5 -0
  281. package/resources/replicatedApplyFailure.ts +77 -0
  282. package/resources/scheduler/DESIGN.md +40 -0
  283. package/resources/scheduler/scheduler.ts +4 -4
  284. package/resources/search.ts +169 -49
  285. package/resources/tracked.ts +73 -22
  286. package/resources/transactionBroadcast.ts +4 -4
  287. package/security/DESIGN.md +175 -0
  288. package/security/auth.ts +53 -24
  289. package/security/deferredAuthentication.ts +24 -2
  290. package/security/jsLoader.ts +9 -4
  291. package/server/DESIGN.md +275 -0
  292. package/server/REST.ts +42 -6
  293. package/server/http.ts +34 -2
  294. package/server/mqtt.ts +6 -4
  295. package/server/serverHelpers/Headers.ts +5 -1
  296. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  297. package/server/serverHelpers/Request.ts +33 -131
  298. package/server/serverHelpers/contentTypes.ts +188 -15
  299. package/server/serverHelpers/serverUtilities.ts +157 -27
  300. package/server/serverHelpers/uwsServer.ts +4 -1
  301. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  302. package/server/storageReclamation.ts +2 -2
  303. package/server/threads/isolatedApplications.ts +157 -0
  304. package/server/threads/logRotationTransport.ts +40 -0
  305. package/server/threads/manageThreads.js +254 -12
  306. package/server/threads/socketRouter.ts +217 -11
  307. package/server/threads/threadServer.js +30 -7
  308. package/studio/web/assets/{Chat-BnCBegQz.js → Chat-DADFFGe_.js} +1 -1
  309. package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-D_mI-rZ7.js} +3 -3
  310. package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-c2NiSDHa.js} +1 -1
  311. package/studio/web/assets/{applications-DHxGi7JH.js → applications-DktUqh7G.js} +1 -1
  312. package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-Cs_75Xhw.js} +1 -1
  313. package/studio/web/assets/{editor-DNcRHK54.js → editor-19b-Y1IN.js} +1 -1
  314. package/studio/web/assets/{html-Bdssedlg.js → html-DiYEQMpB.js} +1 -1
  315. package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CmR0y7P_.js} +1 -1
  316. package/studio/web/assets/{index-D6sxmFLR.js → index-Dm0rfkJ7.js} +5 -5
  317. package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-7vqt2CC3.js} +1 -1
  318. package/studio/web/assets/{javascript-B8meVSTH.js → javascript-BWtCFuOt.js} +1 -1
  319. package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-Buzzbv9y.js} +1 -1
  320. package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-SqsFWfTM.js} +1 -1
  321. package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  322. package/studio/web/assets/{notifications-CvZivSbh.js → notifications-CAB-LZWT.js} +1 -1
  323. package/studio/web/assets/{notifications-CQf18QKb.js → notifications-DRzmSRxM.js} +1 -1
  324. package/studio/web/assets/{profile-DdOwtntb.js → profile-BNKAl79n.js} +1 -1
  325. package/studio/web/assets/{regions-n69fwagr.js → regions-CUow_Zw2.js} +1 -1
  326. package/studio/web/assets/{register-PfWTCXWB.js → register-Dkt3WUMp.js} +2 -2
  327. package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BZRfMD0N.js} +1 -1
  328. package/studio/web/assets/{setup-CUx_aUDl.js → setup-D_yiEPO2.js} +2 -2
  329. package/studio/web/assets/{status-D7BVKqX9.js → status-DhHh1Ge-.js} +1 -1
  330. package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  331. package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-CCwLk1YS.js} +1 -1
  332. package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-BP1j1mjn.js} +1 -1
  333. package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  334. package/studio/web/assets/{workers-pR3jRY9D.js → workers-tOuCNT17.js} +1 -1
  335. package/studio/web/assets/{xml-2iRnMhQO.js → xml-BSG_3mQT.js} +1 -1
  336. package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-DxiLprBB.js} +1 -1
  337. package/studio/web/index.html +1 -1
  338. package/utility/DESIGN.md +55 -0
  339. package/utility/errors/hdbError.ts +54 -0
  340. package/utility/hdbTerms.ts +2 -0
  341. package/utility/logging/harper_logger.ts +210 -30
  342. package/utility/logging/logGenerationCoordinator.ts +196 -0
  343. package/utility/logging/logRotation.ts +367 -0
  344. package/utility/logging/logRotator.ts +196 -91
  345. package/utility/npmUtilities.ts +6 -4
  346. package/utility/watcherFallback.ts +0 -122
  347. package/validation/configValidator.ts +6 -3
package/server/DESIGN.md CHANGED
@@ -208,6 +208,17 @@ mirror because it only widens what an allowlist may _name_; enforcement stays on
208
208
 
209
209
  `REST.ts → http(request, nextHandler)` is the chief integration point: it takes a `Request`, asks the `Resources` registry for a match, builds a `RequestTarget`, and dispatches into the Resource class's static method. Cache headers are translated to `request.expiresAt` / `onlyIfCached` / `noCache` flags within the same function.
210
210
 
211
+ ### Streaming startup errors
212
+
213
+ SSE and NDJSON serializers eagerly take and hold their first iterator step for GET requests. `REST.ts` waits through the next event-loop turn for that step: an immediate rejection remains an HTTP error rendered as Problem Details, while a first item or the cutoff commits the stream. Mutating requests do not use the startup-status gate because their transaction has already committed by this point; all of their stream failures use the in-band form. Later GET failures are terminal, format-valid records (`event: harper-error` for SSE and a reserved control record for NDJSON). Keep the decision in the serializer/REST boundary so Node, uWS, Bun, compression, and injection share one contract; transports must not independently prefetch the iterator.
214
+
215
+ SSE terminal event data uses `{ error: <code-or-class>, message: <message>, status?: <status> }`; NDJSON wraps the same object as `{ "$harperStreamError": { ... } }` so it cannot be mistaken for an ordinary row with an `error` field.
216
+ Sources with `mapError` remain unmapped for these two formats so their failures reach this single contract instead of becoming legacy data records.
217
+ generic JSON-array streaming retains its older `{ error: "<name>: <message>" }` element shape.
218
+ The `error` value is the stable programmatic discriminator; `message` is diagnostic and follows the same thrown-message exposure policy as pre-commit Problem Details.
219
+
220
+ Clean stream completion does not prove completeness; clients must inspect SSE for the `harper-error` event and NDJSON for the `$harperStreamError` control record.
221
+
211
222
  ### Deferred credential rejection (#2418)
212
223
 
213
224
  `authentication` runs before route matching, so when it meets an `Authorization` header it cannot
@@ -361,3 +372,267 @@ The `@table(cacheControl:)` value is persisted on the primary-key attribute (lik
361
372
  - New protocol plugins implement the `Server` interface (in `Server.ts`) and register via `onRequest`/`onUpgrade`/`onWebSocket`.
362
373
  - Always pass `name` when registering a listener with `before`/`after` — anonymous entries can't be ordered against.
363
374
  - Tests live in `../unitTests/server/`.
375
+
376
+ ---
377
+
378
+ ## The dispatched API operation is carried on async context, never on the request (`server/serverHelpers/operationAuthorizationState.ts`)
379
+
380
+ `verifyPermsAST`'s token-scope check has to be told which top-level API operation the caller
381
+ invoked, because the scope is written in that namespace (`sql`, `export_local`, ...). Two things
382
+ make that awkward:
383
+
384
+ 1. On the **direct-SQL** path, the object handed to `checkASTPermissions` _is_ the client's request
385
+ body, and this check is the only gate there (`chooseOperation`'s `sql` branch is mutually
386
+ exclusive with its `verifyPerms` call). Any field read off that object is therefore a way to
387
+ name whichever operation the caller's scope happens to allow and run arbitrary SQL under it.
388
+ `jsonMessage.operation` is safe only because dispatch already routed on that same field, so it
389
+ cannot disagree with the operation running. Never add another.
390
+ 2. A **job** re-parses its SQL from the nested `search_operation` in a _different_ async context —
391
+ `executeJob` persists the request and hands off to the job runner, and `jobProcess.ts` re-enters
392
+ from the `hdb_job` record. So a store established around the originating request cannot reach
393
+ it, and the re-parse would be judged as `sql` rather than as the job's own operation.
394
+
395
+ The carrier is therefore established **in the job worker**, by `runWithDispatchedOperation`, from
396
+ the same `request.operation` that `getOperationFunction` just resolved the handler from. That
397
+ identity is the whole basis for trusting it: the value naming the operation and the value selecting
398
+ the code cannot diverge. A new carrier must preserve that property — an added request property, a
399
+ `search_operation` field, or a persisted `parsed_sql_object` would not.
400
+
401
+ This lives in the same `AsyncLocalStorage` as the auth bypass rather than a second store, so
402
+ `processAST` reads the state once. `runWithOperationAuthorizationBypass` **preserves** an existing
403
+ carrier on both branches. That is deliberate and was initially got wrong: its enforced branch is not
404
+ a bypass, so a job handler dispatching a nested _authorized_ operation lands there, and dropping the
405
+ carrier would judge that job's re-parsed SQL as the inner `sql` and refuse it partway through its own
406
+ work. The consequence to know is the other direction — a nested dispatch inside a job is judged
407
+ against the **outer** job's operation for any `evaluateSQL` that does not pass through
408
+ `chooseOperation`. It allocates only when a carrier is present; with none, two shared frozen objects
409
+ serve the common path. All four stores are frozen, so `getOperationAuthorizationState()` cannot hand
410
+ a mutable one to a caller.
411
+
412
+ It has four call sites, and they are not all dispatch wrappers: `server.operation()`
413
+ (`serverUtilities.ts`), the ITC path (`registeredOperations.ts`), the legacy SQL engine
414
+ (`sqlEngine/diff/differential.ts`), and Harper's own `hdb_job` query (`server/jobs/jobs.ts`) — that
415
+ last one **is** reached from the ops-API dispatch, via `search_jobs_by_start_date` →
416
+ `handleGetJobsByStartDate` → `getJobsInDateRange`.
417
+
418
+ Harper's own internal SQL takes the bypass, not the carrier. `getJobsInDateRange` runs a fixed
419
+ `system.hdb_job` query through `evaluateSQL` beneath a handler the caller was already authorized for,
420
+ and `SqlSearchObject` hardcodes `operation: 'sql'` — so the same mismatch applies, but the answer
421
+ differs, and the reason is easy to get backwards. `verifyPermsAST`'s super_user early return is
422
+ `isSuperUser && !isSuSystemOperation`, so a `system` schema is **exempt** from it and the table check
423
+ genuinely runs. A carrier would therefore put Harper's own query through `hasPermissions` on
424
+ `system.hdb_job`, which passes only because `appendSystemTablesToRole` grants `system.*.read` to a
425
+ hydrated super_user — a super_user principal without an appended `permission.system` (an
426
+ impersonation payload, or any path that skips user-cache hydration) would start getting 403s on an
427
+ operation it is entitled to. The bypass also states the actual intent: the statement is Harper's, not
428
+ the caller's. Wrap the individual statement, not the function — a later caller-dependent statement
429
+ must not inherit it.
430
+
431
+ A second body field has to be neutralized for any of this to hold: `evaluateSQL` trusts a supplied
432
+ `parsed_sql_object` verbatim and skips parsing, `chooseOperation` overwrites only the **top-level**
433
+ one, and `dataLayer/export.ts` hands the nested `search_operation` straight to `evaluateSQL`. So a
434
+ body-supplied `search_operation.parsed_sql_object` carrying `permissions_checked: true` would run an
435
+ arbitrary AST with the check skipped. `chooseOperation` deletes it, forcing the worker to re-parse
436
+ from the `sql` string that dispatch authorized — the nested object is never overwritten the way the
437
+ top-level one is, because nothing downstream should read one at all.
438
+
439
+ What is untestable is not the carrier's contract — unit tests cover that by calling
440
+ `runWithDispatchedOperation` directly — but that `jobProcess` is what establishes it. Delete that call
441
+ and those tests stay green. The carrier only changes an outcome through `tokenScopeDenial`, which is
442
+ inert unless the principal carries `tokenOperations`, and that property has exactly one origin: an
443
+ OIDC trust-policy exchange, for which there is no integration harness.
444
+
445
+ Three different mechanisms are easy to conflate here. `tokenOperations` above is the **OIDC token
446
+ operation scope** (#2174). An **inline-role scoped token** (`create_authentication_tokens` with a
447
+ `role` object) is not the same thing and cannot substitute, because `createScopedToken` mints it
448
+ `super_user: false`, so it cannot invoke a `requires_su` operation such as `export_local` at all.
449
+ **Table permissions** are a third, and also cannot substitute — see the system-schema exemption
450
+ above. See #2298.
451
+
452
+ ## `universalHeaders` (`http.securityHeaders`): ownership, precedence, and per-thread scope
453
+
454
+ `server/http.ts` exports `universalHeaders: [string, string][]`, applied to responses in the
455
+ Node, Bun, and uWS (`#914`, `HARPER_UWS_HTTP`) request handlers alike. `http.securityHeaders`
456
+ config populates it via `applySecurityHeaders()`, called from `handleApplication()` on load and
457
+ on `scope.options.on('change', ...)`. Three invariants to preserve:
458
+
459
+ - **Ownership tracking.** Other components may push entries onto the same shared array, so a
460
+ hot-reload can't clear-and-rebuild it. `applySecurityHeaders` tracks the exact `[name, value]`
461
+ tuples it previously pushed in a module-level `ownedSecurityHeaders` array and splices only
462
+ those out (by reference, via `indexOf`) before re-adding the new set. Any future feature that
463
+ pushes into `universalHeaders` from a hot-reloadable source should follow the same "track what
464
+ I added, only remove what I added" pattern.
465
+ - **Root scope owns the config.** `'http'` is a `TRUSTED_RESOURCE_PLUGINS` key, so an application
466
+ `config.yaml` with an `http:` block re-invokes `handleApplication`. A module-level guard makes
467
+ only the _first_ invocation (the root config, which loads before applications) own
468
+ `applySecurityHeaders` and its change listener; later invocations still refresh `httpOptions`
469
+ but cannot wipe root-configured headers.
470
+ - **App wins on conflicts.** Universal headers are _defaults_: `applyUniversalHeaders()` (a shared
471
+ helper used by all three transports) only sets a header when `has(name)` is false, and the
472
+ direct-to-`nodeResponse` paths (handlesHeaders, error) check `hasHeader` first. A route that sets
473
+ `X-Frame-Options: DENY` is never loosened by a configured `SAMEORIGIN`. Response paths covered:
474
+ normal writeHead, `handlesHeaders` streams (e.g. the static component's `send()`, which writes
475
+ its own headers directly — universal headers are pre-set on `nodeResponse` / the Bun
476
+ `responseHeaders` shim so the stream can still override its own names), the thrown-error path,
477
+ and the `status === -1` cascade — on Node via the Fastify `'unhandled'` event bridge, on Bun/uWS
478
+ via `injectToFastify` (or the bare-404 fallback when no Fastify instance is registered for the
479
+ port). Each `status === -1` branch builds a **fresh** `Headers` object from the fallback
480
+ response rather than reusing the request's original `headers`, so `applyUniversalHeaders()` must
481
+ be called again on whichever object actually gets returned — applying it only once, before the
482
+ `status === -1` branch, is a trap that silently drops universal headers on every unhandled/404
483
+ response. CI first caught this on the uWS shard (the integration suite's only unauthenticated
484
+ 404 case landed there); the same bug existed unnoticed on Bun's parallel `status === -1`
485
+ branches (`getBunHTTPServer`'s bare-404 return and `bunDelegateToNodeServer`'s two `Response`s)
486
+ and is fixed alongside it in `harper-1568-fix2`.
487
+
488
+ **Why the operations API doesn't get these headers in normal mode**: ops requests _do_ flow
489
+ through the Harper-native `requestHandler` (`httpServer()` calls `getServer()` for every
490
+ registration, including Fastify's non-function listener) and cascade to Fastify via the
491
+ `status === -1` branch, which copies `response.headers` onto `nodeResponse`. But the ops API runs
492
+ on the **main thread**, and the main thread loads components with `resources.isWorker = false`
493
+ (`server/loadRootComponents.js`), so the componentLoader's `resources.isWorker &&
494
+ extensionModule.handleApplication` gate (`components/componentLoader.ts`) means http's
495
+ `handleApplication` never runs there — the main thread's `universalHeaders` array stays empty.
496
+ `universalHeaders` is per-thread module state, populated only where the http component loads.
497
+ Corollary: with `threads: 0` the ops API shares the worker where `handleApplication` _did_ run,
498
+ so ops responses **will** carry the headers there (benign).
499
+
500
+ ## Under Bun, the main HTTP port is served by `node:http`, not `Bun.serve`
501
+
502
+ Worth knowing before debugging anything Bun-specific on the HTTP path: `getBunHTTPServer()` builds
503
+ the `Bun.serve()` fetch config, but `onWebSocket()` calls `getHTTPServer()` unconditionally — it has
504
+ a uWS branch and no Bun branch, because Bun native WebSockets are unimplemented (nothing ever sets
505
+ `config.websocket`, so WS relies on the Node `ws` server attached to an `http.Server`). MQTT's
506
+ `handleApplication` registers WS on the default port before REST's `httpServer()` call for that same
507
+ port, so `httpServers[port]` is already a Node server by then and `getBunHTTPServer` early-returns
508
+ without registering a serve config. The port is bound by `registerServer()`'s Node server via
509
+ `listenOnPortsBun`'s trailing "non-HTTP servers" loop, and the fetch handler is never invoked for it
510
+ (only the exclusive operations port reaches `Bun.serve`). Consequence: on Bun the `Request`/`Response`
511
+ fetch path is dead code for the main port, and its divergences show up as `node:http`-emulation
512
+ divergences instead.
513
+
514
+ One such divergence, `#2210`: Bun's `node:http` never derives keep-alive from the request. For a
515
+ `Connection: close` request `shouldKeepAlive` stays `true`, and neither a `Connection: close` response
516
+ header nor `response.socket.end()` closes the connection — a **stream-ended** response (an async
517
+ source ended through `pipeline()`; a direct `response.end()` is fine) delivers its full body and
518
+ terminal chunk, then holds the connection until Bun's own idle timeout — a chunked-aware client
519
+ completes the message and can walk away, but the un-honored close still violates RFC 9112 §9.6 and
520
+ strands the socket; a raw client waiting on the FIN (and the HTTP/1.0 case below, which has no
521
+ terminal chunk to stop at) hangs outright. An HTTP/1.0 client hangs the same way
522
+ without asking to close at all, since 1.0 persistence needs both an explicit `keep-alive` and a length
523
+ to read to — so a 1.0 response that got no `Content-Length` is close-delimited, the same line Node
524
+ draws (Node closes it at ~7ms; Bun never does). An explicit `close` token wins over `keep-alive` on
525
+ both versions. A 1.0 `keep-alive` request whose response _did_ get a
526
+ `Content-Length` (`body.size` on a blob, `server/http.ts:698-709`) is left open, which is again what
527
+ Node does and what Bun then handles correctly.
528
+
529
+ `pipeBodyToResponse()` therefore ends `request.socket` itself for those shapes
530
+ (`endConnectionIfClientExpectsClose`, `isBun`-gated, HTTP/1 only, clean path only — the error path
531
+ already closes because `pipeline()` destroys the response with the stream error). Ending the
532
+ _request's_ socket is the only remedy that works after a clean stream end on Bun: a `Connection:
533
+ close` response header, `response.socket.end()` and `response.destroy()` were all measured as no-ops
534
+ there. `socket.end()` is graceful, so it does not truncate — 8 MB over plain TCP and 6 MB over TLS to
535
+ a deliberately slow reader each arrive whole. The
536
+ `Content-Length` check reads `response.hasHeader()`, which Bun populates from the `writeHead(status,
537
+ headers)` fast path this file uses (Node does not, but the branch is Bun-only). A
538
+ keep-alive arm pins the other direction (such a client keeps its connection and reuses it); the two
539
+ HTTP/1.0 arms are Node/Bun-only, because uWS does not route an HTTP/1.0 request to the resource at
540
+ all.
541
+
542
+ ## Per-worker UDS mirrors are separate server instances — port-keyed wiring does not reach them (`server/http.ts`)
543
+
544
+ With `tls.unixDomainSockets: true`, every secure port gets a per-worker cleartext mirror
545
+ (`<worker>-<port>.sock`) so a fronting proxy (symphony) can terminate TLS and route to a specific
546
+ worker. The mirror is a **separate** `http.Server` instance registered in `SERVERS[udsPath]` — it is
547
+ _not_ `httpServers[port]` — so anything wired by port key (upgrade listeners, uWS `wsHandler`,
548
+ mTLS flags, socket options) must be explicitly propagated to it. `getHTTPServer()` exposes the
549
+ mirror as `server.udsMirror` (Node) / `server.udsMirrorUwsConfig` (HARPER_UWS_UDS) for exactly this;
550
+ `onWebSocket()` uses those to attach the `'upgrade'` dispatch and uWS `wsHandler`. Two lessons paid
551
+ for in production (WS handshakes died with a zero-byte close on the mirrors while SSE worked):
552
+
553
+ - A Node HTTP server with **no** `'upgrade'` listener destroys upgrade sockets with no response and
554
+ no log — a silent per-server default that makes a missing listener look like a network problem.
555
+ - `enableProxyProtocol()`'s data interception must hand the socket **back to the original
556
+ listeners** once the PROXY header decision is made (it re-attaches them and removes its wrapper).
557
+ A permanent wrapper breaks protocol handoffs: Node's upgrade path removes its parser's `'data'`
558
+ listener _by reference_ before ws takes over, so a lingering wrapper keeps feeding the freed HTTP
559
+ parser — which the parser pool can re-issue to another connection, injecting one connection's
560
+ WS frames into another's request stream (`Parse Error: Data after 'Connection: close'`).
561
+
562
+ The h2c mirror (`HARPER_H2C_UDS`) is exempt: HTTP/1.1 `Upgrade` doesn't exist in h2, and the
563
+ fronting proxy routes WS to the h1 mirror by ALPN.
564
+
565
+ Known limitation on uWS-served transports (`HARPER_UWS_HTTP` ports, `HARPER_UWS_UDS` mirrors):
566
+ uWS accepts WebSocket handshakes natively in `app.ws()`, so `server.upgrade()` middleware never
567
+ runs pre-handshake there (auth is unaffected — it runs in the WS connection chain on both paths,
568
+ matching Node's upgrade-then-authorize order). No core component registers custom upgrade
569
+ middleware; `onUpgrade()`/`installUwsWsHandler()` warn when one is registered for a uWS-served
570
+ port so the gap is visible instead of silent.
571
+
572
+ ## A worker that misses an ITC ack gets its OS thread state logged (`server/threads/manageThreads.js`)
573
+
574
+ `broadcastWithAcknowledgement` already times out (30 s) on a worker whose port stays open but never acks, and that shape is almost always a blocked event loop — a native lock, a runaway synchronous call — which nothing inside the worker can report (harper-pro#788: a restarted node's single http worker went byte-silent while main kept serving `cluster_status`, and the app log only said "not acknowledged by worker thread(s) 2"). So each worker posts its Linux thread id (`readlink /proc/thread-self`) to main once at startup, before anything else runs on it, and the timeout branch reads that thread's kernel state from `/proc/self/task/<tid>`: state, `wchan`, the syscall number (the first token only — the rest of that file is argument registers and stack/instruction pointers), CPU ticks, and context-switch counts, plus two cross-platform signals main already has, `worker.performance.eventLoopUtilization()` and the age of the last 1 s resource report. It samples again a second later and logs the deltas: no CPU ticks, no context switches and `event loop active +1000ms` is "parked on a lock"; ticks climbing with state `R` is "spinning". It is deliberately main-thread-only and best-effort: `workers` and the tid live on the main thread's `Worker` objects, every `/proc` field is reported individually (a hardened container may deny `wchan`/`syscall` while `stat` stays readable), a follow-up sample whose `starttime` differs from the first is discarded (the tid may have been recycled), one diagnostic runs per worker with a 30 s cooldown so concurrent timeouts on the same worker don't multiply reads, and nothing here runs when acks arrive on time. It does not name the lock owner; that still needs a native stack from the next occurrence.
575
+
576
+ ## `chooseOperation` authorizes the invoked operation against the authenticated principal (`server/serverHelpers/serverUtilities.ts`)
577
+
578
+ `verifyPerms` takes a request-shaped object and reads _both_ halves of the permission question off it: the principal from `hdb_user`, and the tables from `schema`/`database`/`table`/`records`. `chooseOperation` used to hand it `json.search_operation` — a caller-supplied field — which made both halves body-controlled. Fixing one half and not the other is not a fix: with an empty `search_operation` the table map is empty, and `hasPermissions` iterating nothing authorizes everything. Regression cover: `integrationTests/security/choose-operation-authz.test.ts`.
579
+
580
+ Four rules hold this together, and all four are load-bearing:
581
+
582
+ **The principal comes from authentication.** Authentication sets only the _top-level_ `hdb_user`, and `validateRequestBodyProperties` inspects only top-level keys, so a nested `hdb_user` must be overwritten, never backfilled `if (!...)`. All four callers of `chooseOperation` (`serverHandlers`, `serverUtilities.operation`, `registeredOperations` worker forwarding, MCP) set the top-level principal before dispatch, which is why this belongs here rather than only at the HTTP boundary.
583
+
584
+ **`search_operation` is the permission subject only for the operations that consume it.** `dataLayer/export.ts` is its sole consumer (`export_local`, `export_to_s3`); for any other operation the substitution checks the nested tables while the handler runs against the top-level ones, so it is gated on the operation name. It must also be an object naming one of export's supported operations (`search_by_value`/`search_by_hash`/`search_by_conditions`/`sql`) — a primitive, `{}`, or an unsupported operation is a request-time 400, not a wrapped 500 or an asynchronously-failed job.
585
+
586
+ **One check cannot authorize both the outer export and its nested query.** The outer op's own `verifyPerms` returns before any table check — `export_local`/`export_to_s3` are `requires_su`, and a role that lists the operation in `operations` is granted at gate 2 (an explicit listing of an SU-only operation is a deliberate grant). The job worker then runs `search_operation` through `searchByValue`/`searchByHash`/`searchByConditions`, none of which check permissions. So the outer invocation is authorized first, and then the nested search is authorized additively against its _real_ search handler (`getOperationFunction(search_operation)`) and the authenticated principal — otherwise a role granted `export_local` could export a table it holds no grant on. A nested `sql` search takes the SQL branch instead, but the same two-part shape holds: the outer export op runs through `verifyPerms` (so its `requires_su` gate, the `operations` allowlist, and the export token scope all apply, exactly as on the non-SQL path — SQL must not be a way around the requires_su gate), the statement must be a `SELECT` because export is read-only, and `checkASTPermissions` then covers the statement's tables. A direct `sql` call has no outer job op, so there the `operations` allowlist alone is the operation-invocation check.
587
+
588
+ **`parsed_sql_object` is dispatch state, never client input.** The export worker re-reads it off the same caller-supplied nested object (`evaluateSQL`), and it carries `permissions_checked`, so a body-supplied one runs an AST no check ever saw. It is deleted from the nested object at dispatch, and stripped from the top-level object before this dispatch's own parse is assigned. Only the direct-SQL path consumes the top-level `parsed_sql_object`; a job re-parses off `search_operation`, so setting it for a job would be inert. The bypass/`apiOperation` decision is carried on async-context state (`getOperationAuthorizationState`), not on the request body, and `processAST` honors the denial `checkASTPermissions` computes — a `PermissionResponseObject` has no `length`, so the guard tests the object itself rather than `.length` (which always refused nothing).
589
+
590
+ The SQL and job paths are additive rather than exclusive: `verifyPermsAST` validates only the statement's tables and attributes, never the `operations` allowlist or `requires_su`, and a table-free statement gives it nothing to validate — so the allowlist check and the AST check both run for a SQL-carrying request, and the nested-search check runs alongside the outer export check for a job.
591
+
592
+ ## `withNodeAdapter()`'s response is the body `PassThrough` it resolves with (`server/serverHelpers/NodeAdapterResponse.ts`)
593
+
594
+ `Request.withNodeAdapter(handler)` gives third-party Node middleware an `IncomingMessage`/`ServerResponse` pair and resolves `{ status, headers, body }` once headers are committed. The response is `NodeAdapterResponse extends PassThrough`, and that same stream is the resolved `body`: `write()`'s return value, `'drain'`, `'finish'`, `'close'`, `writableEnded`/`writableFinished` and destroy propagation are Node's own rather than events forwarded from a second stream, which is what `Readable.pipe`, `compression`'s buffered `res.on('drain')` and Next.js's response writer depend on past the high-water mark (#2527). Invariants that middleware exercises and the unit test `unitTests/server/serverHelpers/nodeAdapterMiddleware.test.js` pins against the real `compression` (1.8 and the 1.7.4 Next.js vendors), `send`, `on-finished` and `on-headers`:
595
+
596
+ - **Headers commit exactly once, through `this.writeHead`.** `write()`, `end()`, `flushHeaders()` and `_implicitHeader()` all reach `this.writeHead(this.statusCode)` by property lookup, so a `writeHead` that `on-headers` replaced on the instance runs its listeners (the ones that set `Content-Encoding` and remove `Content-Length`) before the promise resolves. After commit, `setHeader`/`appendHeader`/`removeHeader` and a second `writeHead` throw `ERR_HTTP_HEADERS_SENT` as Node's do; `_header` (which `compression` ≤ 1.7 tests instead of `headersSent`) and `finished` (which `on-finished` tests) derive from that state.
597
+ - **The adapter owns the `'error'` listener.** A `destroy(err)` right after `writeHead()` emits before the awaiting caller can attach one; the error stays in the stream's `errored` state for `pipeline()`, `finished()` or async iteration. Client disconnect (`Request.signal`) destroys the response without an error after headers (a plain premature close, which `pipeBodyToResponse` treats as routine) and rejects the promise with the abort reason before them; a handler that throws or rejects before ending the response destroys it, and one that fails after `end()` is logged at warn.
598
+ - **Header names are case-insensitive on removal too.** `Headers.delete` lowercases like `set`/`get`/`has`; the inherited `Map.delete` silently left `send`'s `Content-Length` on a gzip body (truncated transfers).
599
+ - **Express is not a target.** `express`'s `app.handle()` replaces the response's prototype with one rooted at `http.ServerResponse.prototype`, which no Writable-derived response survives, and would do the same to the request `Proxy`'s target, Harper's real `IncomingMessage`. Middleware that duck-types the response (Next.js, `compression`, `send`, `serve-static`, `finalhandler`, h3, fastify) is the supported surface.
600
+
601
+ ## `manageThreads` has two different `workerCount`s (`server/threads/manageThreads.js`)
602
+
603
+ The module-global `let workerCount` and the per-worker `workerData.workerCount` share a name and
604
+ nothing else. `getWorkerCount()` (and therefore the `server.workerCount` a component reads) resolves
605
+ only `workerData.workerCount`, frozen at spawn — it never reads the global; on the main thread it
606
+ answers `isMainWorker ? 1 : undefined`. The global's one and only reader is `restartWorkers`' default
607
+ `maxWorkersDown = Math.max(Math.floor(workerCount / 8), 1)`, so it is the _serving topology_ the
608
+ rolling-restart throttle is sized from, nothing more.
609
+
610
+ That makes the global writable only by a start that declares the topology. It used to be assigned
611
+ unconditionally from `options.threadCount` inside the `workerData` literal, so every job worker — which
612
+ passes no `threadCount` — set it to `undefined`, and the next rolling restart computed `NaN` (harper#2491).
613
+ `NaN` defeats the guard below it (`NaN < 1` is false) _and_ every throttle comparison, so the restart
614
+ took the whole pool down at once. A string does the same thing for the same reason. So the fix is the conditional write plus a consumer guard that clamps anything not a usable number. `Infinity` is exempt: it is the deliberate "all at once" sentinel `shutdownWorkers` passes, and `shutdownWorkersNow` depends on it to mark every worker synchronously before the first await. A literal `0` is also left alone, because it reads as a ratio rather than as garbage — it still reaches the ratio branch and stops the restart after one worker (harper#2601).
615
+
616
+ `workerData.workerCount` must stay exactly what it is for each start, `undefined` for job workers
617
+ included: an earlier attempt to give job workers the serving count instead broke the Windows
618
+ integration shard with ECONNREFUSED across the job tests. A job worker that believes it is part of the
619
+ pool behaves differently.
620
+
621
+ ## A WebSocket close reason must be bounded to 123 bytes (`server/serverHelpers/webSocketCloseReason.ts`)
622
+
623
+ `ws` throws a `RangeError` when a close reason exceeds 123 bytes — the control-frame payload minus the
624
+ status code — and every close site Harper has reaches it from a rejection handler, where that throw
625
+ surfaces as an unhandled rejection rather than a failed close. Two of the reasons are outside Harper's
626
+ control: a `server.getUser` override's rejection text, and `request.pathname` in REST's no-resource
627
+ close. So every `ws.close()` carrying a dynamic reason goes through `toCloseReason()`, which truncates
628
+ on a code-point boundary (harper#2703).
629
+
630
+ The `ClassName: message` in a close reason is deliberate and not something to sanitize: the class name is
631
+ Harper's error code, and `errorToString` is the correct renderer for client-visible error text — see
632
+ AGENTS.md, "An error's class name is its error code". Only an internal fault's _message_ is replaced, by
633
+ `AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL`. The three terminal HTTP handlers (Node and Bun in
634
+ `server/http.ts`, uWS in `server/serverHelpers/uwsServer.ts`) must agree on that rendering; uWS rendered
635
+ the bare message until harper#2703, so the same error carried an error code on two runtimes and not the third.
636
+
637
+ REST settles a credential rejection _before_ its route lookup, so a rejected client gets the unauthorized
638
+ close rather than `1011 No resource was found` — which would otherwise disclose whether the resource exists.
package/server/REST.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { serialize, serializeMessage, getDeserializer } from '../server/serverHelpers/contentTypes.ts';
3
+ import {
4
+ serialize,
5
+ serializeMessage,
6
+ getDeserializer,
7
+ waitForStreamStartup,
8
+ discardSerializedStream,
9
+ } from '../server/serverHelpers/contentTypes.ts';
4
10
  import { addAnalyticsListener, recordAction, recordActionBinary } from '../resources/analytics/write.ts';
5
11
  import * as harperLogger from '../utility/logging/harper_logger.ts';
6
12
  import { ServerError, ClientError } from '../utility/errors/hdbError.ts';
@@ -19,6 +25,7 @@ import {
19
25
  assertNoDeferredCredentialRejection,
20
26
  settleDeferredCredentialRejection,
21
27
  } from '../security/deferredAuthentication.ts';
28
+ import { toCloseReason } from './serverHelpers/webSocketCloseReason.ts';
22
29
 
23
30
  import { Request } from '../server/serverHelpers/Request.ts';
24
31
  import { RequestTarget } from '../resources/RequestTarget';
@@ -348,7 +355,15 @@ async function http(request: Request, nextHandler, resources: Resources, httpOpt
348
355
  headers.setIfNone('Last-Modified', new Date(lastModification).toUTCString());
349
356
  } else if (responseData.headers) {
350
357
  // if response is a Response object (or response-like envelope with headers), use it as the response
351
- return finalizeResponse(responseData, headers, status, request);
358
+ const response = finalizeResponse(responseData, headers, status, request);
359
+ if (request.method === 'HEAD') {
360
+ discardSerializedStream(response.body);
361
+ if (!(response instanceof Response)) response.body = undefined;
362
+ } else if (request.method === 'GET') {
363
+ const startup = waitForStreamStartup(response.body);
364
+ if (startup) await startup;
365
+ }
366
+ return response;
352
367
  } else if (isFinite(lastModification)) {
353
368
  etagFloat[0] = lastModification;
354
369
  // base64 encoding of the 64-bit float encoding of the date in ms (with quotes)
@@ -422,7 +437,13 @@ async function http(request: Request, nextHandler, resources: Resources, httpOpt
422
437
  setCountHeaders(headers, (target as any).offset || 0, (target as any).count, responseData);
423
438
  }
424
439
  responseObject.body = serialize(responseData, request, responseObject);
425
- if (method === 'HEAD') responseObject.body = undefined; // we want everything else to be the same as GET, but then omit the body
440
+ if (request.method === 'HEAD') {
441
+ discardSerializedStream(responseObject.body);
442
+ responseObject.body = undefined; // we want everything else to be the same as GET, but then omit the body
443
+ } else if (request.method === 'GET') {
444
+ const startup = waitForStreamStartup(responseObject.body);
445
+ if (startup) await startup;
446
+ }
426
447
  }
427
448
  // A collection read's count headers vary by the request's `Prefer` value; serialize() just reset
428
449
  // `Vary`, so declare it here (after serialization) — otherwise a shared cache could serve count
@@ -466,6 +487,19 @@ async function http(request: Request, nextHandler, resources: Resources, httpOpt
466
487
  headers,
467
488
  body: undefined,
468
489
  };
490
+ // The error body is a different representation, so success representation and caching headers
491
+ // accumulated before the startup failure must not survive its serialization.
492
+ for (const header of [
493
+ 'Content-Encoding',
494
+ 'Cache-Control',
495
+ 'ETag',
496
+ 'Last-Modified',
497
+ 'Content-Range',
498
+ 'Range-Unit',
499
+ 'Preference-Applied',
500
+ 'Age',
501
+ ])
502
+ headers.delete(header);
469
503
  responseObject.body = serialize(problemDetail, request, responseObject);
470
504
  return responseObject;
471
505
  }
@@ -552,14 +586,16 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
552
586
  });
553
587
  try {
554
588
  await chainCompletion;
589
+ // before the route lookup: the same credential is a 401 over HTTP, so a rejected client
590
+ // must not learn from the close code whether the resource exists
591
+ assertNoDeferredCredentialRejection(request);
555
592
  const url = request.url.slice(1);
556
593
  const entry = resources.getMatch(url, 'ws');
557
594
  recordActionBinary(Boolean(entry), 'connection', 'ws', 'connect');
558
595
  if (!entry) {
559
596
  // TODO: Ideally we would like to have a 404 response before upgrading to WebSocket protocol, probably
560
- return ws.close(1011, `No resource was found to handle ${request.pathname}`);
597
+ return ws.close(1011, toCloseReason(`No resource was found to handle ${request.pathname}`));
561
598
  } else {
562
- assertNoDeferredCredentialRejection(request);
563
599
  request.handlerPath = entry.path;
564
600
  recordAction(
565
601
  (action) => ({
@@ -599,7 +635,7 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
599
635
  ws.close(
600
636
  HTTP_TO_WEBSOCKET_CLOSE_CODES[error.statusCode] || // try to return a helpful code
601
637
  1011, // otherwise generic internal error
602
- errorToString(error)
638
+ toCloseReason(errorToString(error))
603
639
  );
604
640
  }
605
641
  ws.close();
package/server/http.ts CHANGED
@@ -12,6 +12,11 @@ import * as env from '../utility/environment/environmentManager.ts';
12
12
  import * as terms from '../utility/hdbTerms.ts';
13
13
  import { getConfigPath } from '../config/configUtils.ts';
14
14
  import { getTicketKeys, getWorkerIndex } from './threads/manageThreads.js';
15
+ import {
16
+ applicationSocketName,
17
+ isolatedApplicationRoute,
18
+ thisThreadsIsolatedApplication,
19
+ } from './threads/isolatedApplications.ts';
15
20
  import { createTLSSelector, getEffectiveTlsCiphers } from '../security/keys.ts';
16
21
  import { createSecureServer, createServer as createH2CServer } from 'node:http2';
17
22
  import { createServer as createSecureServerHttp1 } from 'node:https';
@@ -196,10 +201,21 @@ export function writeUdsMetadata(
196
201
  port: number | string,
197
202
  secureServer: any,
198
203
  protocol?: string,
199
- mtlsForwarding = true
204
+ mtlsForwarding = true,
205
+ route: { application: string; hosts: string[] } | undefined = isolatedApplicationRoute()
200
206
  ) {
201
207
  const contexts = secureServer.secureContexts;
202
208
  let yaml = `pid: ${process.pid}\ntid: ${currentThreadId()}\nport: ${port}\n`;
209
+ // Routing identity, separate from certificate coverage: a proxy sends this application's hosts to
210
+ // this socket alone, whatever hostnames the (shared, possibly wildcard) certificates carry.
211
+ if (route) {
212
+ yaml += `application: ${JSON.stringify(route.application)}\n`;
213
+ if (route.hosts.length === 0) yaml += `applicationHosts: []\n`;
214
+ else {
215
+ yaml += `applicationHosts:\n`;
216
+ for (const host of route.hosts) yaml += ` - ${JSON.stringify(host)}\n`;
217
+ }
218
+ }
203
219
  // Which application protocol this socket speaks (absent = http/1.1, the historical
204
220
  // default) — lets a fronting proxy route by negotiated ALPN.
205
221
  if (protocol) yaml += `protocol: ${protocol}\n`;
@@ -267,6 +283,19 @@ export function cleanupSocketsDirectory() {
267
283
  } catch {}
268
284
  }
269
285
 
286
+ export function cleanupApplicationSockets(application: string) {
287
+ const socketsDir = join(env.getHdbBasePath(), 'sockets');
288
+ const prefix = applicationSocketName(application, '');
289
+ try {
290
+ for (const file of readdirSync(socketsDir)) {
291
+ if (!file.startsWith(prefix) || (!file.endsWith('.sock') && !file.endsWith('.yaml'))) continue;
292
+ try {
293
+ unlinkSync(join(socketsDir, file));
294
+ } catch {}
295
+ }
296
+ } catch {}
297
+ }
298
+
270
299
  // Entries in `universalHeaders` that were pushed by `applySecurityHeaders`, so a config
271
300
  // hot-reload can remove exactly the entries it owns without clobbering entries pushed by
272
301
  // other components.
@@ -875,7 +904,10 @@ function getHTTPServer(port: number, secure: boolean, options: ServerOptions) {
875
904
  if (secure && env.get(terms.CONFIG_PARAMS.TLS_UNIXDOMAINSOCKETS)) {
876
905
  const socketsDir = join(env.getHdbBasePath(), 'sockets');
877
906
  mkdirSync(socketsDir, { recursive: true });
878
- const socketName = `${getWorkerIndex()}-${port}`;
907
+ const isolatedApplication = thisThreadsIsolatedApplication();
908
+ const socketName = isolatedApplication
909
+ ? applicationSocketName(isolatedApplication, port)
910
+ : `${getWorkerIndex()}-${port}`;
879
911
  const udsPath = join(socketsDir, `${socketName}.sock`);
880
912
  const yamlPath = join(socketsDir, `${socketName}.yaml`);
881
913
 
package/server/mqtt.ts CHANGED
@@ -19,8 +19,10 @@ import { forComponent as loggerForComponent } from '../utility/logging/harper_lo
19
19
  import { EventEmitter } from 'events';
20
20
  import { verifyCertificate } from '../security/certificateVerification/index.ts';
21
21
  import { registerShutdownDrain } from '../components/shutdownDrain.ts';
22
+ import { toCloseReason } from './serverHelpers/webSocketCloseReason.ts';
22
23
  import {
23
24
  assertNoDeferredCredentialRejection,
25
+ getAuthenticationRejectedInPlace,
24
26
  getDeferredCredentialRejection,
25
27
  } from '../security/deferredAuthentication.ts';
26
28
 
@@ -86,10 +88,10 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
86
88
  });
87
89
  authenticated.catch((error) => {
88
90
  mqttLog.info?.('Closing MQTT WebSocket connection, authentication was rejected', error);
89
- ws.close(
90
- WEBSOCKET_UNAUTHORIZED_CLOSE_CODE,
91
- getDeferredCredentialRejection(request)?.message ?? 'Unauthorized'
92
- );
91
+ // read the records rather than the error: both hold a client-safe message, while an
92
+ // arbitrary chain rejection landing here would not
93
+ const rejection = getDeferredCredentialRejection(request) ?? getAuthenticationRejectedInPlace(request);
94
+ ws.close(WEBSOCKET_UNAUTHORIZED_CLOSE_CODE, toCloseReason(rejection?.message ?? 'Unauthorized'));
93
95
  });
94
96
  const { onMessage, onClose } = onSocket(
95
97
  ws,
@@ -31,6 +31,10 @@ export class Headers extends Map<string, [string, string | string[]]> {
31
31
  if (typeof name !== 'string') name = '' + name;
32
32
  return super.has(name.toLowerCase());
33
33
  }
34
+ delete(name) {
35
+ if (typeof name !== 'string') name = '' + name;
36
+ return super.delete(name.toLowerCase());
37
+ }
34
38
  setIfNone(name, value) {
35
39
  if (typeof name !== 'string') name = '' + name;
36
40
  if (typeof value !== 'string') value = '' + value;
@@ -239,7 +243,7 @@ function nodeResponseHeaders(nodeResponse: any) {
239
243
  };
240
244
  }
241
245
 
242
- function applyWriteHeadHeaders(nodeResponse: any, headers: any): void {
246
+ export function applyWriteHeadHeaders(nodeResponse: any, headers: any): void {
243
247
  const suppliedHeaders = new Map<string, { name: string; value: any }>();
244
248
  const addHeader = (name: string, value: any) => {
245
249
  const key = String(name).toLowerCase();