@harperfast/harper 5.3.0 → 5.3.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 (231) hide show
  1. package/bin/copyDb.ts +5 -1
  2. package/bin/deployRenderer.ts +3 -0
  3. package/components/Application.ts +66 -22
  4. package/components/DESIGN.md +89 -36
  5. package/components/componentLoader.ts +2 -0
  6. package/components/deploymentRecorder.ts +18 -1
  7. package/components/installFingerprint.ts +123 -0
  8. package/components/mcp/DESIGN.md +4 -4
  9. package/components/operations.js +29 -0
  10. package/config/DESIGN.md +5 -5
  11. package/dataLayer/DESIGN.md +37 -22
  12. package/dataLayer/backupRepository.ts +248 -0
  13. package/dataLayer/restoreMarker.ts +30 -75
  14. package/dataLayer/rocksdbBackup.ts +253 -110
  15. package/dist/bin/copyDb.js +3 -0
  16. package/dist/bin/copyDb.js.map +1 -1
  17. package/dist/bin/deployRenderer.js +3 -0
  18. package/dist/bin/deployRenderer.js.map +1 -1
  19. package/dist/components/Application.d.ts +5 -0
  20. package/dist/components/Application.js +51 -19
  21. package/dist/components/Application.js.map +1 -1
  22. package/dist/components/componentLoader.js +2 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/deploymentRecorder.js +18 -1
  25. package/dist/components/deploymentRecorder.js.map +1 -1
  26. package/dist/components/installFingerprint.d.ts +39 -0
  27. package/dist/components/installFingerprint.js +113 -0
  28. package/dist/components/installFingerprint.js.map +1 -0
  29. package/dist/components/operations.d.ts +2 -0
  30. package/dist/components/operations.js +31 -0
  31. package/dist/components/operations.js.map +1 -1
  32. package/dist/dataLayer/backupRepository.d.ts +33 -0
  33. package/dist/dataLayer/backupRepository.js +232 -0
  34. package/dist/dataLayer/backupRepository.js.map +1 -0
  35. package/dist/dataLayer/restoreMarker.d.ts +4 -1
  36. package/dist/dataLayer/restoreMarker.js +29 -68
  37. package/dist/dataLayer/restoreMarker.js.map +1 -1
  38. package/dist/dataLayer/rocksdbBackup.js +221 -112
  39. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/systemSchema.json +14 -0
  43. package/dist/resources/DatabaseTransaction.d.ts +140 -11
  44. package/dist/resources/DatabaseTransaction.js +1055 -464
  45. package/dist/resources/DatabaseTransaction.js.map +1 -1
  46. package/dist/resources/IterableEventQueue.d.ts +2 -1
  47. package/dist/resources/IterableEventQueue.js +16 -8
  48. package/dist/resources/IterableEventQueue.js.map +1 -1
  49. package/dist/resources/LMDBTransaction.d.ts +13 -2
  50. package/dist/resources/LMDBTransaction.js +219 -93
  51. package/dist/resources/LMDBTransaction.js.map +1 -1
  52. package/dist/resources/RecordEncoder.js +2 -3
  53. package/dist/resources/RecordEncoder.js.map +1 -1
  54. package/dist/resources/Resource.js +5 -6
  55. package/dist/resources/Resource.js.map +1 -1
  56. package/dist/resources/ResourceInterface.d.ts +3 -1
  57. package/dist/resources/ResourceInterface.js.map +1 -1
  58. package/dist/resources/RocksIndexStore.d.ts +2 -1
  59. package/dist/resources/RocksIndexStore.js +3 -0
  60. package/dist/resources/RocksIndexStore.js.map +1 -1
  61. package/dist/resources/Table.js +116 -12
  62. package/dist/resources/Table.js.map +1 -1
  63. package/dist/resources/analytics/write.js +6 -2
  64. package/dist/resources/analytics/write.js.map +1 -1
  65. package/dist/resources/auditStore.d.ts +71 -17
  66. package/dist/resources/auditStore.js +248 -64
  67. package/dist/resources/auditStore.js.map +1 -1
  68. package/dist/resources/branchDatabase.js +2 -0
  69. package/dist/resources/branchDatabase.js.map +1 -1
  70. package/dist/resources/databases.js +8 -2
  71. package/dist/resources/databases.js.map +1 -1
  72. package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
  73. package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
  74. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  75. package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
  76. package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
  77. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
  78. package/dist/resources/models/Models.d.ts +11 -0
  79. package/dist/resources/models/Models.js +95 -4
  80. package/dist/resources/models/Models.js.map +1 -1
  81. package/dist/resources/models/backendRegistry.d.ts +8 -0
  82. package/dist/resources/models/backendRegistry.js +15 -1
  83. package/dist/resources/models/backendRegistry.js.map +1 -1
  84. package/dist/resources/models/bootstrap.d.ts +2 -0
  85. package/dist/resources/models/bootstrap.js +6 -0
  86. package/dist/resources/models/bootstrap.js.map +1 -1
  87. package/dist/resources/models/calibration.d.ts +88 -0
  88. package/dist/resources/models/calibration.js +244 -0
  89. package/dist/resources/models/calibration.js.map +1 -0
  90. package/dist/resources/models/calibrationStore.d.ts +163 -0
  91. package/dist/resources/models/calibrationStore.js +986 -0
  92. package/dist/resources/models/calibrationStore.js.map +1 -0
  93. package/dist/resources/models/decisionStore.d.ts +24 -0
  94. package/dist/resources/models/decisionStore.js +45 -0
  95. package/dist/resources/models/decisionStore.js.map +1 -1
  96. package/dist/resources/models/generativeDecision.js +44 -15
  97. package/dist/resources/models/generativeDecision.js.map +1 -1
  98. package/dist/resources/models/types.d.ts +7 -0
  99. package/dist/resources/recordLock.d.ts +8 -5
  100. package/dist/resources/recordLock.js +6 -4
  101. package/dist/resources/recordLock.js.map +1 -1
  102. package/dist/resources/replayLogs.js +8 -1
  103. package/dist/resources/replayLogs.js.map +1 -1
  104. package/dist/resources/scheduler/engine.d.ts +4 -0
  105. package/dist/resources/scheduler/engine.js +30 -2
  106. package/dist/resources/scheduler/engine.js.map +1 -1
  107. package/dist/resources/search.d.ts +16 -4
  108. package/dist/resources/search.js +117 -13
  109. package/dist/resources/search.js.map +1 -1
  110. package/dist/resources/transaction.js +85 -5
  111. package/dist/resources/transaction.js.map +1 -1
  112. package/dist/resources/transactionBroadcast.d.ts +8 -0
  113. package/dist/resources/transactionBroadcast.js +65 -0
  114. package/dist/resources/transactionBroadcast.js.map +1 -1
  115. package/dist/security/keys.d.ts +1 -1
  116. package/dist/security/keys.js +170 -120
  117. package/dist/security/keys.js.map +1 -1
  118. package/dist/server/http.js +10 -0
  119. package/dist/server/http.js.map +1 -1
  120. package/dist/server/serverHelpers/Request.js +11 -2
  121. package/dist/server/serverHelpers/Request.js.map +1 -1
  122. package/dist/server/serverHelpers/contentTypes.js +8 -3
  123. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +38 -7
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/server/threads/manageThreads.d.ts +3 -3
  127. package/dist/server/threads/manageThreads.js +15 -13
  128. package/dist/server/threads/manageThreads.js.map +1 -1
  129. package/dist/server/threads/threadServer.d.ts +3 -0
  130. package/dist/server/threads/threadServer.js +24 -6
  131. package/dist/server/threads/threadServer.js.map +1 -1
  132. package/dist/upgrade/directives/5-3-0.js +9 -3
  133. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  134. package/dist/utility/durableFile.d.ts +36 -0
  135. package/dist/utility/durableFile.js +106 -0
  136. package/dist/utility/durableFile.js.map +1 -0
  137. package/dist/utility/errors/hdbError.d.ts +4 -0
  138. package/dist/utility/errors/hdbError.js +10 -1
  139. package/dist/utility/errors/hdbError.js.map +1 -1
  140. package/dist/utility/globalSchema.d.ts +9 -0
  141. package/dist/utility/hdbTerms.d.ts +1 -0
  142. package/dist/utility/hdbTerms.js +1 -0
  143. package/dist/utility/hdbTerms.js.map +1 -1
  144. package/dist/validation/configValidator.js +29 -0
  145. package/dist/validation/configValidator.js.map +1 -1
  146. package/index.ts +7 -0
  147. package/json/systemSchema.json +14 -0
  148. package/npm-shrinkwrap.json +22 -22
  149. package/package.json +2 -2
  150. package/resources/DESIGN.md +57 -272
  151. package/resources/DatabaseTransaction.ts +708 -87
  152. package/resources/FULL_TEXT.md +6 -2
  153. package/resources/IterableEventQueue.ts +14 -7
  154. package/resources/LMDBTransaction.ts +212 -84
  155. package/resources/RecordEncoder.ts +2 -3
  156. package/resources/Resource.ts +5 -6
  157. package/resources/ResourceInterface.ts +3 -1
  158. package/resources/RocksIndexStore.ts +5 -0
  159. package/resources/Table.ts +118 -15
  160. package/resources/analytics/write.ts +6 -2
  161. package/resources/audit-retention.md +203 -0
  162. package/resources/auditStore.ts +306 -84
  163. package/resources/branchDatabase.ts +2 -0
  164. package/resources/databases.ts +6 -2
  165. package/resources/indexes/DESIGN.md +120 -177
  166. package/resources/indexes/fullTextNativeBinding.ts +22 -3
  167. package/resources/indexes/fullTextQueryIndex.ts +274 -11
  168. package/resources/models/DESIGN.md +11 -3
  169. package/resources/models/Models.ts +127 -4
  170. package/resources/models/backendRegistry.ts +17 -0
  171. package/resources/models/bootstrap.ts +9 -1
  172. package/resources/models/calibration.ts +298 -0
  173. package/resources/models/calibrationStore.ts +1167 -0
  174. package/resources/models/decisionStore.ts +57 -0
  175. package/resources/models/generativeDecision.ts +54 -16
  176. package/resources/models/types.ts +6 -0
  177. package/resources/record-locks.md +155 -141
  178. package/resources/recordLock.ts +12 -6
  179. package/resources/replayLogs.ts +9 -2
  180. package/resources/scheduler/DESIGN.md +4 -3
  181. package/resources/scheduler/engine.ts +35 -2
  182. package/resources/search.ts +154 -16
  183. package/resources/transaction.ts +50 -5
  184. package/resources/transactionBroadcast.ts +69 -0
  185. package/security/DESIGN.md +9 -30
  186. package/security/keys.ts +175 -127
  187. package/server/DESIGN.md +70 -36
  188. package/server/http.ts +10 -0
  189. package/server/serverHelpers/Request.ts +10 -2
  190. package/server/serverHelpers/contentTypes.ts +5 -3
  191. package/server/serverHelpers/uwsServer.ts +34 -7
  192. package/server/threads/manageThreads.js +16 -14
  193. package/server/threads/threadServer.js +24 -6
  194. package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
  195. package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
  196. package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
  197. package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
  198. package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
  199. package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
  200. package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
  201. package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
  202. package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
  203. package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
  204. package/studio/web/assets/index-QLZw6xrU.css +1 -0
  205. package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
  206. package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
  207. package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
  208. package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
  209. package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
  210. package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
  211. package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
  212. package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
  213. package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
  214. package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
  215. package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
  216. package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
  217. package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
  218. package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
  219. package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
  220. package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
  221. package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
  222. package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
  223. package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
  224. package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
  225. package/studio/web/index.html +2 -2
  226. package/upgrade/directives/5-3-0.ts +10 -3
  227. package/utility/durableFile.ts +96 -0
  228. package/utility/errors/hdbError.ts +9 -0
  229. package/utility/hdbTerms.ts +1 -0
  230. package/validation/configValidator.ts +31 -0
  231. package/studio/web/assets/index-72cEH474.css +0 -1
package/security/keys.ts CHANGED
@@ -17,7 +17,7 @@ import * as hdbTerms from '../utility/hdbTerms.ts';
17
17
 
18
18
  import * as certificatesTerms from '../utility/terms/certificates.js';
19
19
  const tls = require('node:tls');
20
- import { relative, join } from 'node:path';
20
+ import { relative, join, dirname, resolve } from 'node:path';
21
21
 
22
22
  import assignCmdenvVars from '../utility/assignCmdEnvVariables.ts';
23
23
  import * as configUtils from '../config/configUtils.ts';
@@ -27,9 +27,18 @@ const { CONFIG_PARAMS } = hdbTerms;
27
27
  const { CERTIFICATE_VALUES } = certificatesTerms;
28
28
  import { getThisNodeName, getThisNodeUrl, urlToNodeName, clearThisNodeName } from '../server/nodeName.ts';
29
29
 
30
- export const getPrivateKeys = () => privateKeys;
30
+ export const getPrivateKeys = () => {
31
+ for (const name of configuredPrivateKeyPaths.keys()) {
32
+ try {
33
+ getPrivateKeyByName(name);
34
+ } catch (error) {
35
+ forComponent('tls').conditional.trace?.('Could not refresh configured private key:', name, error);
36
+ }
37
+ }
38
+ return privateKeys;
39
+ };
31
40
 
32
- import { readFileSync, statSync } from 'node:fs';
41
+ import { readFileSync, statSync, watchFile } from 'node:fs';
33
42
  import { getTicketKeys, onMessageFromWorkers } from '../server/threads/manageThreads.js';
34
43
  import { isMainThread } from 'worker_threads';
35
44
  import {
@@ -152,9 +161,9 @@ export async function getReplicationCertAuth() {
152
161
 
153
162
  let configuredCertsLoaded;
154
163
  const privateKeys = new Map();
164
+ const configuredPrivateKeyPaths = new Map<string, string>();
165
+ const filePrivateKeys = new Map<string, string>();
155
166
 
156
- // Debounce window (ms) for rebuilding TLS secure contexts. Shared by the hdb_certificate
157
- // subscription and the private-key hot-reload trigger so both coalesce on the same cadence.
158
167
  const TLS_REBUILD_DEBOUNCE_MS = 1500;
159
168
 
160
169
  // Self-retry backoff cap: a permanently bad record must not cost every selector on every
@@ -163,39 +172,6 @@ const TLS_FAILURE_RETRY_MAX_DELAY_MS = 300_000;
163
172
  // While a failure signature is unchanged, repeat occurrences log a summary at most this often.
164
173
  const TLS_FAILURE_SUMMARY_INTERVAL_MS = 3_600_000;
165
174
 
166
- // Debounced rebuild triggers, one per live server TLS selector (registered in createTLSSelector's
167
- // initialize). When a private key is hot-reloaded on this thread, every live selector re-runs
168
- // updateTLS so a secure context built with a stale key — or built before this key arrived — is
169
- // regenerated. Transient selectors (getReplicationCert) opt out so they don't accumulate here.
170
- const liveTLSRebuilders = new Set<() => void>();
171
-
172
- /**
173
- * Trigger a debounced rebuild of every live server's TLS secure contexts on this thread.
174
- *
175
- * Workers load their private key directly from disk into the privateKeys map (there is no table
176
- * propagation for keys), so a key rotation must rebuild the secure contexts locally. The cert side
177
- * already propagates via the hdb_certificate subscription; without this, a worker that rebuilt for
178
- * the new cert before reloading the matching key would serve the new cert paired with the old key
179
- * until the next cert-table change.
180
- */
181
- function rebuildLiveTLSContexts() {
182
- for (const scheduleRebuild of liveTLSRebuilders) scheduleRebuild();
183
- }
184
-
185
- /**
186
- * Handle a private-key (re)load: update the in-thread map and, on any content change, trigger a
187
- * local TLS context rebuild. The `previous !== private_key` guard skips identical-content reloads
188
- * (so neither chokidar nor the periodic poll thrashes) while still rebuilding when a key first
189
- * appears or is restored after boot — the recovery case we must not strand. During normal startup
190
- * this runs before any TLS selector is registered, so the rebuild is a harmless no-op on an empty
191
- * rebuilder set.
192
- */
193
- function handlePrivateKeyReload(private_key_name, private_key) {
194
- const previous = privateKeys.get(private_key_name);
195
- privateKeys.set(private_key_name, private_key);
196
- if (previous !== private_key) rebuildLiveTLSContexts();
197
- }
198
-
199
175
  /**
200
176
  * This is responsible for loading any certificates that are in the harperdb-config.yaml file and putting them into the hdbCertificate table.
201
177
  * @return {*}
@@ -222,18 +198,26 @@ export function loadCertificates() {
222
198
  // need to relativize the paths so they aren't exposed
223
199
  let private_key_name = privateKeyPath && relative(join(rootPath, 'keys'), privateKeyPath);
224
200
  if (private_key_name) {
225
- loadAndWatch(
226
- privateKeyPath,
227
- (private_key) => handlePrivateKeyReload(private_key_name, private_key),
228
- 'private key'
229
- );
201
+ configuredPrivateKeyPaths.set(private_key_name, privateKeyPath);
202
+ try {
203
+ cacheFilePrivateKey(private_key_name, readPEM(privateKeyPath));
204
+ } catch (error) {
205
+ forComponent('tls').conditional.error?.('Error loading private key:', privateKeyPath, error);
206
+ }
230
207
  }
231
208
  for (let ca of [false, true]) {
232
209
  let path = config[ca ? 'certificateAuthority' : 'certificate'];
233
210
  if (path && isMainThread) {
211
+ let pendingPairTimer;
212
+ const reportPendingPair = () => {
213
+ forComponent('tls').conditional.error?.(
214
+ `TLS certificate at ${path} still has no matching private key at ${privateKeyPath}`
215
+ );
216
+ pendingPairTimer = setTimeout(reportPendingPair, TLS_FAILURE_SUMMARY_INTERVAL_MS).unref();
217
+ };
234
218
  loadAndWatch(
235
219
  path,
236
- (certificate) => {
220
+ (certificate, fileStats) => {
237
221
  if (CERTIFICATE_VALUES.cert === certificate) {
238
222
  // this is the compromised Harper certificate authority, and we do not even want to bother to
239
223
  // load it or tempted to use it anywhere
@@ -241,7 +225,7 @@ export function loadCertificates() {
241
225
  }
242
226
  let hostnames = config.hostname ?? config.hostnames ?? config.host ?? config.hosts;
243
227
  if (hostnames && !Array.isArray(hostnames)) hostnames = [hostnames];
244
- const certificatePem = readPEM(path);
228
+ const certificatePem = certificate;
245
229
  const x509Cert = new X509Certificate(certificatePem);
246
230
  let certCn;
247
231
  try {
@@ -259,14 +243,25 @@ export function loadCertificates() {
259
243
  // Check if cert issued by compromised Harper certificate authority, if it is, do not load it
260
244
  if (x509Cert.checkIssued(new X509Certificate(CERTIFICATE_VALUES.cert))) return;
261
245
 
262
- // If a record already exists for cert check to see who is newer, cert record or cert file.
263
- // If cert file is newer, add it to table
264
- // getSync (not get): this runs in a synchronous loadAndWatch callback. On RocksDB get()
265
- // returns a Promise on a block-cache miss, which is truthy but has no timestamp fields,
266
- // so the staleness guard below is bypassed and a stale on-disk cert can overwrite a
267
- // newer replicated record (TLS churn / downgrade) once hdb_certificate evicts.
246
+ // The synchronous timestamp guard must not treat a cold RocksDB read Promise as a record.
268
247
  const certRecord = certificateTable.primaryStore.getSync(certCn);
269
- let fileTimestamp = statSync(path).mtimeMs;
248
+ if (!ca && privateKeyPath) {
249
+ const privateKey = readPEM(privateKeyPath);
250
+ if (!x509Cert.checkPrivateKey(createPrivateKey(privateKey))) {
251
+ if (!pendingPairTimer) {
252
+ forComponent('tls').conditional.warn?.(
253
+ `Waiting for matching TLS certificate and private key: ${path}, ${privateKeyPath}`
254
+ );
255
+ if (!certRecord || certRecord.is_self_signed) reportPendingPair();
256
+ else pendingPairTimer = setTimeout(reportPendingPair, 30_000).unref();
257
+ }
258
+ return false;
259
+ }
260
+ cacheFilePrivateKey(private_key_name, privateKey);
261
+ clearTimeout(pendingPairTimer);
262
+ pendingPairTimer = undefined;
263
+ }
264
+ let fileTimestamp = fileStats.mtimeMs;
270
265
  let recordTimestamp =
271
266
  !certRecord || certRecord.is_self_signed
272
267
  ? 1
@@ -283,7 +278,7 @@ export function loadCertificates() {
283
278
  return;
284
279
  }
285
280
 
286
- // Returned so loadAndWatch can roll back its mtime latch if the write fails —
281
+ // Returned so loadAndWatch can roll back its fingerprint latch if the write fails —
287
282
  // assigned as well to preserve loadCertificates()'s awaited-return contract.
288
283
  return (promise = certificateTable.put({
289
284
  name: certCn,
@@ -304,7 +299,8 @@ export function loadCertificates() {
304
299
  },
305
300
  }));
306
301
  },
307
- ca ? 'certificate authority' : 'certificate'
302
+ ca ? 'certificate authority' : 'certificate',
303
+ !ca && privateKeyPath ? [privateKeyPath] : []
308
304
  );
309
305
  }
310
306
  }
@@ -340,20 +336,12 @@ const certificateWatchTimers = new Map<string, NodeJS.Timeout>();
340
336
  const certificateWatchPollers = new Map<string, () => void>();
341
337
 
342
338
  /**
343
- * Load the certificate file and watch for changes and reload with any changes.
344
- *
345
- * Two detection mechanisms feed the same reload path (and share the `lastModified` fingerprint,
346
- * so they never double-reload for the same change):
347
- * 1. chokidar inotify watcher — the fast path, reacts immediately to fs events.
348
- * 2. A periodic re-read (main thread only) — a safety net for filesystems where inotify is
349
- * unreliable (overlayfs/containers/network mounts), where a real renewal can otherwise be
350
- * silently missed. Interval is tls.certificateWatchInterval (default 5m); 0 disables it.
351
- * @param path
352
- * @param loadCert
353
- * @param type
339
+ * Watch the certificate and related files through filtered parent directories and the main-thread safety poll.
340
+ * A false loader result remains unapplied so an unchanged fingerprint can be retried.
354
341
  */
355
- function loadAndWatch(path, loadCert, type) {
342
+ function loadAndWatch(path, loadCert, type, relatedPaths: string[] = []) {
356
343
  let lastModified;
344
+ let lastAttempted;
357
345
  const loadFile = (path, stats?) => {
358
346
  // The latch dedupes chokidar/poll but must mean "last successfully APPLIED", or a failed
359
347
  // apply is deduplicated forever (#2382). Rollbacks are equality-guarded so an old failure
@@ -361,69 +349,110 @@ function loadAndWatch(path, loadCert, type) {
361
349
  const previousModified = lastModified;
362
350
  let modified;
363
351
  try {
364
- // chokidar's 'change' event omits stats unless alwaysStat is set (default off in v4), so
365
- // stat the file here when it's missing — otherwise the inotify fast path would throw and
366
- // silently never reload, leaving only the periodic poll to catch the change.
367
- modified = (stats ?? statSync(path)).mtimeMs;
368
- if (modified && modified !== lastModified) {
369
- if (lastModified && isMainThread) logger.warn?.(`Reloading ${type}:`, path);
352
+ const fileStats = stats ?? statSync(path);
353
+ const fingerprints = [fileStats, ...relatedPaths.map((relatedPath) => statSync(relatedPath))];
354
+ modified = JSON.stringify(fingerprints.map(({ mtimeMs, ino, size }) => [mtimeMs, ino, size]));
355
+ // Restoring an applied fingerprint after a failed attempt must clear the loader's pending state.
356
+ if (modified !== lastModified || modified !== lastAttempted) {
357
+ if (lastModified && modified !== lastAttempted && isMainThread) logger.warn?.(`Reloading ${type}:`, path);
358
+ lastAttempted = modified;
370
359
  lastModified = modified;
371
- const applied = loadCert(readPEM(path));
372
- if (typeof (applied as any)?.then === 'function') {
373
- (applied as Promise<unknown>).catch((error) => {
374
- logger.error?.(`Error applying ${type}:`, path, error);
375
- if (lastModified === modified) lastModified = previousModified;
376
- });
360
+ const rollback = () => {
361
+ if (lastModified === modified) lastModified = previousModified === modified ? undefined : previousModified;
362
+ };
363
+ const applied = loadCert(readPEM(path), fileStats);
364
+ if (applied === false) rollback();
365
+ else if (typeof (applied as any)?.then === 'function') {
366
+ (applied as Promise<unknown>)
367
+ .then((result) => {
368
+ if (result === false) rollback();
369
+ })
370
+ .catch((error) => {
371
+ logger.error?.(`Error applying ${type}:`, path, error);
372
+ rollback();
373
+ });
377
374
  }
378
375
  }
379
376
  } catch (error) {
380
377
  logger.error?.(`Error loading ${type}:`, path, error);
381
- if (modified !== undefined && lastModified === modified) lastModified = previousModified;
378
+ if (modified !== undefined && lastModified === modified)
379
+ lastModified = previousModified === modified ? undefined : previousModified;
382
380
  }
383
381
  };
384
382
  if (fs.existsSync(path)) loadFile(path, statSync(path));
385
383
  else logger.error?.(`${type} file not found:`, path);
386
- const watchTarget = resolveWatchTarget(path);
387
- let usingPolling = watchTarget.mustPoll;
388
- let liveWatcher;
389
- const openWatcher = () => {
390
- const opened = (liveWatcher = guardedWatch(watchTarget.path, {
391
- persistent: false,
392
- ...(usingPolling ? POLLING_FALLBACK_OPTIONS : {}),
393
- }));
394
- opened
395
- // The event carries the watched spelling, which is not the configured one once canonicalized;
396
- // reload through the configured path so a retargeted link is followed.
397
- .on('change', () => loadFile(path))
398
- // chokidar emits 'error' unguarded for anything but ENOENT/ENOTDIR.
399
- .on('error', (error) => {
400
- if (claimLostNativeWatchError(error)) return;
401
- if (isWatcherExhaustionError(error)) {
402
- if (usingPolling || liveWatcher !== opened) return;
403
- warnWatcherFallback(path);
404
- usingPolling = true;
405
- Promise.resolve()
406
- .then(() => opened.close())
407
- .catch(() => {})
408
- .then(openWatcher)
409
- .catch((error) => logger.error?.(`Could not reopen the ${type} watch on polling:`, path, error));
410
- return;
411
- }
412
- logger.error?.(`Error watching ${type}:`, path, error);
413
- });
414
- };
415
- openWatcher();
416
-
417
- // Periodic re-read safety net. For certificates, this runs on the main thread only — workers
418
- // receive cert updates via the hdb_certificate table subscription, so polling the cert file on
419
- // every worker would be wasteful. Private keys are different: each worker loads its own key
420
- // directly from disk into its privateKeys map (no table propagation), so a renewal missed by
421
- // inotify on a worker would strand the stale key — therefore the key poll must run on all
422
- // threads. mtime is checked first against the last-loaded fingerprint, so an unchanged file does
423
- // no PEM read and no reload (and shares the fingerprint with the chokidar watcher, so the two
424
- // detection paths never double-reload the same change).
425
- const pollsOnThisThread = isMainThread || type === 'private key';
426
- if (pollsOnThisThread) {
384
+ const watchedDirectories = new Map<string, { mustPoll: boolean; files: Set<string> }>();
385
+ for (const filePath of [path, ...relatedPaths]) {
386
+ const target = resolveWatchTarget(filePath);
387
+ const directory = resolveWatchTarget(dirname(target.path));
388
+ let watched = watchedDirectories.get(directory.path);
389
+ if (!watched)
390
+ watchedDirectories.set(
391
+ directory.path,
392
+ (watched = { mustPoll: target.mustPoll || directory.mustPoll, files: new Set() })
393
+ );
394
+ watched.files.add(resolve(target.path));
395
+ }
396
+ for (const [directory, watched] of watchedDirectories) {
397
+ let usingPolling = watched.mustPoll;
398
+ let pollingFiles = false;
399
+ let liveWatcher;
400
+ const openWatcher = () => {
401
+ const opened = (liveWatcher = guardedWatch(directory, {
402
+ persistent: false,
403
+ depth: 0,
404
+ ignoreInitial: true,
405
+ ignored: (filePath) => {
406
+ const resolved = resolve(filePath);
407
+ return resolved !== resolve(directory) && !watched.files.has(resolved);
408
+ },
409
+ ...(usingPolling ? POLLING_FALLBACK_OPTIONS : {}),
410
+ }));
411
+ const reload = () => loadFile(path);
412
+ const reopen = () =>
413
+ Promise.resolve()
414
+ .then(() => opened.close())
415
+ .catch(() => {})
416
+ .then(openWatcher)
417
+ .catch((error) => logger.error?.(`Could not reopen the ${type} watch on polling:`, directory, error));
418
+ opened
419
+ .on('add', reload)
420
+ .on('change', reload)
421
+ .on('error', (error) => {
422
+ if (claimLostNativeWatchError(error)) return;
423
+ if (pollingFiles || liveWatcher !== opened) return;
424
+ const errorCode = (error as NodeJS.ErrnoException).code;
425
+ if (errorCode === 'EACCES' || errorCode === 'EPERM') {
426
+ forComponent('tls').conditional.warn?.(
427
+ `Cannot watch TLS directory ${directory}; polling its configured files instead`,
428
+ error
429
+ );
430
+ pollingFiles = true;
431
+ Promise.resolve()
432
+ .then(() => opened.close())
433
+ .catch(() => {});
434
+ // watchFile stays armed across ENOENT without needing to list the unreadable parent.
435
+ for (const file of watched.files)
436
+ watchFile(file, { persistent: false, interval: POLLING_FALLBACK_OPTIONS.interval }, (stats) => {
437
+ if (stats.nlink) reload();
438
+ });
439
+ reload();
440
+ return;
441
+ }
442
+ if (isWatcherExhaustionError(error)) {
443
+ if (usingPolling || liveWatcher !== opened) return;
444
+ warnWatcherFallback(directory);
445
+ usingPolling = true;
446
+ reopen();
447
+ return;
448
+ }
449
+ logger.error?.(`Error watching ${type}:`, path, error);
450
+ });
451
+ };
452
+ openWatcher();
453
+ }
454
+
455
+ if (isMainThread) {
427
456
  const poll = () => {
428
457
  let stats;
429
458
  try {
@@ -619,7 +648,13 @@ export async function getCertAuthority() {
619
648
  let match;
620
649
  for (let cert of allCerts) {
621
650
  if (!cert.is_authority) continue;
622
- const matchingPrivateKey = getPrivateKeyByName(cert.private_key_name);
651
+ let matchingPrivateKey;
652
+ try {
653
+ matchingPrivateKey = getPrivateKeyByName(cert.private_key_name);
654
+ } catch (error) {
655
+ if (error.code !== 'ENOENT') throw error;
656
+ continue;
657
+ }
623
658
  if (cert.private_key_name && matchingPrivateKey) {
624
659
  const keyCheck = new X509Certificate(cert.certificate).checkPrivateKey(createPrivateKey(matchingPrivateKey));
625
660
  if (keyCheck) {
@@ -1032,7 +1067,7 @@ export function getEffectiveTlsCiphers(type, mtlsOptions?): string | undefined {
1032
1067
  * Create a TLS selector that will choose the best TLS configuration/context for a given hostname
1033
1068
  * @param type
1034
1069
  * @param mtlsOptions
1035
- * @param liveReload when true (default) the selector registers for private-key hot-reload rebuilds.
1070
+ * @param liveReload when true (default) the selector subscribes to certificate-table updates.
1036
1071
  * Pass false for transient, single-use selectors (e.g. getReplicationCert) so they don't accumulate.
1037
1072
  * @return {(function(*, *): (*|undefined))|*}
1038
1073
  */
@@ -1447,7 +1482,7 @@ export function createTLSSelector(type, mtlsOptions?, liveReload = true): any {
1447
1482
  // derive it from more than the selector's mtlsOptions) so this compares like with like
1448
1483
  const effectiveCiphers =
1449
1484
  getEffectiveTlsCiphers(type, server.verifiesClientCerts ?? Boolean(mtlsOptions)) ?? null;
1450
- // latch per distinct value: rebuilds recur (cert-table changes, key reloads) and the
1485
+ // latch per distinct value: rebuilds recur (cert-table changes, failure retries) and the
1451
1486
  // pending change shouldn't re-warn on every cycle until the restart happens
1452
1487
  if (effectiveCiphers !== server.appliedCiphers && server.lastWarnedCiphers !== effectiveCiphers) {
1453
1488
  server.lastWarnedCiphers = effectiveCiphers;
@@ -1476,10 +1511,7 @@ export function createTLSSelector(type, mtlsOptions?, liveReload = true): any {
1476
1511
  updateTLS();
1477
1512
  }, TLS_REBUILD_DEBOUNCE_MS).unref();
1478
1513
  };
1479
- // Register the private-key hot-reload rebuild path unconditionally, before the first
1480
- // updateTLS() pass: that pass may find the system database not yet loaded and retry
1481
- // (see above) rather than subscribe, so this must not depend on it succeeding.
1482
- if (liveReload) liveTLSRebuilders.add(scheduleRebuild);
1514
+
1483
1515
  updateTLS();
1484
1516
  }));
1485
1517
  };
@@ -1513,8 +1545,24 @@ export function createTLSSelector(type, mtlsOptions?, liveReload = true): any {
1513
1545
  }
1514
1546
  }
1515
1547
 
1548
+ function cacheFilePrivateKey(name: string, key: string) {
1549
+ if (!privateKeys.has(name) || privateKeys.get(name) === filePrivateKeys.get(name)) privateKeys.set(name, key);
1550
+ filePrivateKeys.set(name, key);
1551
+ return privateKeys.get(name);
1552
+ }
1553
+
1516
1554
  function getPrivateKeyByName(private_key_name) {
1517
1555
  const private_key = privateKeys.get(private_key_name);
1556
+ const configuredPath = configuredPrivateKeyPaths.get(private_key_name);
1557
+ if (configuredPath && (!private_key || private_key === filePrivateKeys.get(private_key_name))) {
1558
+ try {
1559
+ return cacheFilePrivateKey(private_key_name, readPEM(configuredPath));
1560
+ } catch (error) {
1561
+ if (!private_key) throw error;
1562
+ forComponent('tls').conditional.trace?.('Could not refresh configured private key:', private_key_name, error);
1563
+ return private_key;
1564
+ }
1565
+ }
1518
1566
  if (!private_key && private_key_name) {
1519
1567
  return fs.readFileSync(
1520
1568
  path.join(envManager.get(CONFIG_PARAMS.ROOTPATH), hdbTerms.LICENSE_KEY_DIR_NAME, private_key_name),