hippo-memory 1.32.0 → 1.33.0

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 (374) hide show
  1. package/dist/api.d.ts +24 -17
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +78 -142
  4. package/dist/api.js.map +1 -1
  5. package/dist/audit-prune.d.ts.map +1 -1
  6. package/dist/audit-prune.js +7 -1
  7. package/dist/audit-prune.js.map +1 -1
  8. package/dist/audit.d.ts +3 -2
  9. package/dist/audit.d.ts.map +1 -1
  10. package/dist/audit.js +13 -6
  11. package/dist/audit.js.map +1 -1
  12. package/dist/auth.d.ts.map +1 -1
  13. package/dist/auth.js +6 -0
  14. package/dist/auth.js.map +1 -1
  15. package/dist/autolearn.js +4 -4
  16. package/dist/autolearn.js.map +1 -1
  17. package/dist/benchmarks/e1.3/incident-recall-eval.js +4 -0
  18. package/dist/benchmarks/e1.3/incident-recall-eval.js.map +1 -1
  19. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +2 -2
  20. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +1 -1
  21. package/dist/capture.d.ts.map +1 -1
  22. package/dist/capture.js +93 -66
  23. package/dist/capture.js.map +1 -1
  24. package/dist/client.d.ts +0 -5
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +32 -14
  27. package/dist/client.js.map +1 -1
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +7 -4
  30. package/dist/config.js.map +1 -1
  31. package/dist/connectors/github/backfill.d.ts.map +1 -1
  32. package/dist/connectors/github/backfill.js +25 -16
  33. package/dist/connectors/github/backfill.js.map +1 -1
  34. package/dist/connectors/github/cli-impl.d.ts +2 -1
  35. package/dist/connectors/github/cli-impl.d.ts.map +1 -1
  36. package/dist/connectors/github/cli-impl.js +20 -4
  37. package/dist/connectors/github/cli-impl.js.map +1 -1
  38. package/dist/connectors/github/deletion.d.ts.map +1 -1
  39. package/dist/connectors/github/deletion.js +2 -0
  40. package/dist/connectors/github/deletion.js.map +1 -1
  41. package/dist/connectors/github/dlq.d.ts.map +1 -1
  42. package/dist/connectors/github/dlq.js +9 -1
  43. package/dist/connectors/github/dlq.js.map +1 -1
  44. package/dist/connectors/github/idempotency.d.ts.map +1 -1
  45. package/dist/connectors/github/idempotency.js +2 -0
  46. package/dist/connectors/github/idempotency.js.map +1 -1
  47. package/dist/connectors/github/octokit-client.d.ts +2 -1
  48. package/dist/connectors/github/octokit-client.d.ts.map +1 -1
  49. package/dist/connectors/github/octokit-client.js +8 -3
  50. package/dist/connectors/github/octokit-client.js.map +1 -1
  51. package/dist/connectors/github/tenant-routing.d.ts.map +1 -1
  52. package/dist/connectors/github/tenant-routing.js +8 -0
  53. package/dist/connectors/github/tenant-routing.js.map +1 -1
  54. package/dist/connectors/github/types.d.ts +14 -5
  55. package/dist/connectors/github/types.d.ts.map +1 -1
  56. package/dist/connectors/github/types.js +35 -29
  57. package/dist/connectors/github/types.js.map +1 -1
  58. package/dist/connectors/slack/backfill.d.ts.map +1 -1
  59. package/dist/connectors/slack/backfill.js +2 -0
  60. package/dist/connectors/slack/backfill.js.map +1 -1
  61. package/dist/connectors/slack/deletion.d.ts.map +1 -1
  62. package/dist/connectors/slack/deletion.js +2 -0
  63. package/dist/connectors/slack/deletion.js.map +1 -1
  64. package/dist/connectors/slack/dlq.d.ts.map +1 -1
  65. package/dist/connectors/slack/dlq.js +3 -1
  66. package/dist/connectors/slack/dlq.js.map +1 -1
  67. package/dist/connectors/slack/idempotency.d.ts.map +1 -1
  68. package/dist/connectors/slack/idempotency.js +2 -0
  69. package/dist/connectors/slack/idempotency.js.map +1 -1
  70. package/dist/connectors/slack/tenant-routing.d.ts.map +1 -1
  71. package/dist/connectors/slack/tenant-routing.js +4 -0
  72. package/dist/connectors/slack/tenant-routing.js.map +1 -1
  73. package/dist/connectors/slack/types.d.ts +8 -3
  74. package/dist/connectors/slack/types.d.ts.map +1 -1
  75. package/dist/connectors/slack/types.js +22 -10
  76. package/dist/connectors/slack/types.js.map +1 -1
  77. package/dist/connectors/slack/web-client.d.ts +0 -9
  78. package/dist/connectors/slack/web-client.d.ts.map +1 -1
  79. package/dist/connectors/slack/web-client.js +11 -11
  80. package/dist/connectors/slack/web-client.js.map +1 -1
  81. package/dist/connectors/slack/workspaces.d.ts.map +1 -1
  82. package/dist/connectors/slack/workspaces.js +2 -0
  83. package/dist/connectors/slack/workspaces.js.map +1 -1
  84. package/dist/consolidate.d.ts.map +1 -1
  85. package/dist/consolidate.js +8 -3
  86. package/dist/consolidate.js.map +1 -1
  87. package/dist/customer-notes.d.ts.map +1 -1
  88. package/dist/customer-notes.js +17 -0
  89. package/dist/customer-notes.js.map +1 -1
  90. package/dist/dag.js +1 -1
  91. package/dist/dag.js.map +1 -1
  92. package/dist/dashboard.d.ts.map +1 -1
  93. package/dist/dashboard.js +5 -2
  94. package/dist/dashboard.js.map +1 -1
  95. package/dist/db.d.ts +1 -1
  96. package/dist/db.d.ts.map +1 -1
  97. package/dist/db.js +24 -0
  98. package/dist/db.js.map +1 -1
  99. package/dist/decisions.d.ts.map +1 -1
  100. package/dist/decisions.js +10 -0
  101. package/dist/decisions.js.map +1 -1
  102. package/dist/dedupe.d.ts +14 -6
  103. package/dist/dedupe.d.ts.map +1 -1
  104. package/dist/dedupe.js +61 -39
  105. package/dist/dedupe.js.map +1 -1
  106. package/dist/embedding-provider.d.ts.map +1 -1
  107. package/dist/embedding-provider.js +24 -15
  108. package/dist/embedding-provider.js.map +1 -1
  109. package/dist/embeddings.d.ts.map +1 -1
  110. package/dist/embeddings.js +17 -1
  111. package/dist/embeddings.js.map +1 -1
  112. package/dist/eval-suite.d.ts.map +1 -1
  113. package/dist/eval-suite.js +5 -0
  114. package/dist/eval-suite.js.map +1 -1
  115. package/dist/extract.d.ts.map +1 -1
  116. package/dist/extract.js +10 -3
  117. package/dist/extract.js.map +1 -1
  118. package/dist/goals.d.ts.map +1 -1
  119. package/dist/goals.js +26 -12
  120. package/dist/goals.js.map +1 -1
  121. package/dist/graph-extract.d.ts.map +1 -1
  122. package/dist/graph-extract.js +9 -1
  123. package/dist/graph-extract.js.map +1 -1
  124. package/dist/graph-view.d.ts.map +1 -1
  125. package/dist/graph-view.js.map +1 -1
  126. package/dist/graph.d.ts.map +1 -1
  127. package/dist/graph.js +38 -13
  128. package/dist/graph.js.map +1 -1
  129. package/dist/hooks.d.ts.map +1 -1
  130. package/dist/hooks.js +42 -21
  131. package/dist/hooks.js.map +1 -1
  132. package/dist/importers.d.ts.map +1 -1
  133. package/dist/importers.js +23 -42
  134. package/dist/importers.js.map +1 -1
  135. package/dist/incidents.d.ts.map +1 -1
  136. package/dist/incidents.js +14 -0
  137. package/dist/incidents.js.map +1 -1
  138. package/dist/mcp/server.d.ts +4 -1
  139. package/dist/mcp/server.d.ts.map +1 -1
  140. package/dist/mcp/server.js +58 -28
  141. package/dist/mcp/server.js.map +1 -1
  142. package/dist/memory-value.d.ts +0 -10
  143. package/dist/memory-value.d.ts.map +1 -1
  144. package/dist/memory-value.js +8 -2
  145. package/dist/memory-value.js.map +1 -1
  146. package/dist/memory.js.map +1 -1
  147. package/dist/physics-state.d.ts.map +1 -1
  148. package/dist/physics-state.js +5 -0
  149. package/dist/physics-state.js.map +1 -1
  150. package/dist/physics.js.map +1 -1
  151. package/dist/policies.d.ts.map +1 -1
  152. package/dist/policies.js +22 -0
  153. package/dist/policies.js.map +1 -1
  154. package/dist/predictions.d.ts.map +1 -1
  155. package/dist/predictions.js +14 -0
  156. package/dist/predictions.js.map +1 -1
  157. package/dist/processes.d.ts +7 -1
  158. package/dist/processes.d.ts.map +1 -1
  159. package/dist/processes.js +21 -2
  160. package/dist/processes.js.map +1 -1
  161. package/dist/project-briefs.d.ts.map +1 -1
  162. package/dist/project-briefs.js +28 -2
  163. package/dist/project-briefs.js.map +1 -1
  164. package/dist/raw-archive-mirror-cleanup.d.ts.map +1 -1
  165. package/dist/raw-archive-mirror-cleanup.js +2 -0
  166. package/dist/raw-archive-mirror-cleanup.js.map +1 -1
  167. package/dist/raw-archive.d.ts +0 -9
  168. package/dist/raw-archive.d.ts.map +1 -1
  169. package/dist/raw-archive.js +3 -9
  170. package/dist/raw-archive.js.map +1 -1
  171. package/dist/recall-scope.d.ts.map +1 -1
  172. package/dist/recall-scope.js +11 -6
  173. package/dist/recall-scope.js.map +1 -1
  174. package/dist/recall-trace.d.ts.map +1 -1
  175. package/dist/recall-trace.js +7 -3
  176. package/dist/recall-trace.js.map +1 -1
  177. package/dist/refine-llm.d.ts.map +1 -1
  178. package/dist/refine-llm.js +3 -0
  179. package/dist/refine-llm.js.map +1 -1
  180. package/dist/reject-flow.d.ts.map +1 -1
  181. package/dist/reject-flow.js +2 -0
  182. package/dist/reject-flow.js.map +1 -1
  183. package/dist/rejection.d.ts.map +1 -1
  184. package/dist/rejection.js +3 -0
  185. package/dist/rejection.js.map +1 -1
  186. package/dist/replay.js.map +1 -1
  187. package/dist/rerankers/cross-encoder.d.ts.map +1 -1
  188. package/dist/rerankers/cross-encoder.js +11 -4
  189. package/dist/rerankers/cross-encoder.js.map +1 -1
  190. package/dist/rerankers/index.d.ts.map +1 -1
  191. package/dist/rerankers/index.js +5 -3
  192. package/dist/rerankers/index.js.map +1 -1
  193. package/dist/rerankers/llm.d.ts.map +1 -1
  194. package/dist/rerankers/llm.js +8 -5
  195. package/dist/rerankers/llm.js.map +1 -1
  196. package/dist/rerankers/types.d.ts +6 -1
  197. package/dist/rerankers/types.d.ts.map +1 -1
  198. package/dist/scheduler.d.ts +16 -0
  199. package/dist/scheduler.d.ts.map +1 -1
  200. package/dist/scheduler.js +15 -5
  201. package/dist/scheduler.js.map +1 -1
  202. package/dist/search.d.ts +4 -6
  203. package/dist/search.d.ts.map +1 -1
  204. package/dist/search.js.map +1 -1
  205. package/dist/server-detect.d.ts.map +1 -1
  206. package/dist/server-detect.js +2 -0
  207. package/dist/server-detect.js.map +1 -1
  208. package/dist/server.d.ts.map +1 -1
  209. package/dist/server.js +194 -135
  210. package/dist/server.js.map +1 -1
  211. package/dist/skills.d.ts.map +1 -1
  212. package/dist/skills.js +10 -3
  213. package/dist/skills.js.map +1 -1
  214. package/dist/src/api.js +78 -142
  215. package/dist/src/api.js.map +1 -1
  216. package/dist/src/audit-prune.js +7 -1
  217. package/dist/src/audit-prune.js.map +1 -1
  218. package/dist/src/audit.js +13 -6
  219. package/dist/src/audit.js.map +1 -1
  220. package/dist/src/auth.js +6 -0
  221. package/dist/src/auth.js.map +1 -1
  222. package/dist/src/autolearn.js +4 -4
  223. package/dist/src/autolearn.js.map +1 -1
  224. package/dist/src/capture.js +93 -66
  225. package/dist/src/capture.js.map +1 -1
  226. package/dist/src/client.js +32 -14
  227. package/dist/src/client.js.map +1 -1
  228. package/dist/src/config.js +7 -4
  229. package/dist/src/config.js.map +1 -1
  230. package/dist/src/connectors/github/backfill.js +25 -16
  231. package/dist/src/connectors/github/backfill.js.map +1 -1
  232. package/dist/src/connectors/github/cli-impl.js +20 -4
  233. package/dist/src/connectors/github/cli-impl.js.map +1 -1
  234. package/dist/src/connectors/github/deletion.js +2 -0
  235. package/dist/src/connectors/github/deletion.js.map +1 -1
  236. package/dist/src/connectors/github/dlq.js +9 -1
  237. package/dist/src/connectors/github/dlq.js.map +1 -1
  238. package/dist/src/connectors/github/idempotency.js +2 -0
  239. package/dist/src/connectors/github/idempotency.js.map +1 -1
  240. package/dist/src/connectors/github/octokit-client.js +8 -3
  241. package/dist/src/connectors/github/octokit-client.js.map +1 -1
  242. package/dist/src/connectors/github/tenant-routing.js +8 -0
  243. package/dist/src/connectors/github/tenant-routing.js.map +1 -1
  244. package/dist/src/connectors/github/types.js +35 -29
  245. package/dist/src/connectors/github/types.js.map +1 -1
  246. package/dist/src/connectors/slack/backfill.js +2 -0
  247. package/dist/src/connectors/slack/backfill.js.map +1 -1
  248. package/dist/src/connectors/slack/deletion.js +2 -0
  249. package/dist/src/connectors/slack/deletion.js.map +1 -1
  250. package/dist/src/connectors/slack/dlq.js +3 -1
  251. package/dist/src/connectors/slack/dlq.js.map +1 -1
  252. package/dist/src/connectors/slack/idempotency.js +2 -0
  253. package/dist/src/connectors/slack/idempotency.js.map +1 -1
  254. package/dist/src/connectors/slack/tenant-routing.js +4 -0
  255. package/dist/src/connectors/slack/tenant-routing.js.map +1 -1
  256. package/dist/src/connectors/slack/types.js +22 -10
  257. package/dist/src/connectors/slack/types.js.map +1 -1
  258. package/dist/src/connectors/slack/web-client.js +11 -11
  259. package/dist/src/connectors/slack/web-client.js.map +1 -1
  260. package/dist/src/connectors/slack/workspaces.js +2 -0
  261. package/dist/src/connectors/slack/workspaces.js.map +1 -1
  262. package/dist/src/consolidate.js +8 -3
  263. package/dist/src/consolidate.js.map +1 -1
  264. package/dist/src/customer-notes.js +17 -0
  265. package/dist/src/customer-notes.js.map +1 -1
  266. package/dist/src/dag.js +1 -1
  267. package/dist/src/dag.js.map +1 -1
  268. package/dist/src/dashboard.js +5 -2
  269. package/dist/src/dashboard.js.map +1 -1
  270. package/dist/src/db.js +24 -0
  271. package/dist/src/db.js.map +1 -1
  272. package/dist/src/decisions.js +10 -0
  273. package/dist/src/decisions.js.map +1 -1
  274. package/dist/src/dedupe.js +61 -39
  275. package/dist/src/dedupe.js.map +1 -1
  276. package/dist/src/embedding-provider.js +24 -15
  277. package/dist/src/embedding-provider.js.map +1 -1
  278. package/dist/src/embeddings.js +17 -1
  279. package/dist/src/embeddings.js.map +1 -1
  280. package/dist/src/eval-suite.js +5 -0
  281. package/dist/src/eval-suite.js.map +1 -1
  282. package/dist/src/extract.js +10 -3
  283. package/dist/src/extract.js.map +1 -1
  284. package/dist/src/goals.js +26 -12
  285. package/dist/src/goals.js.map +1 -1
  286. package/dist/src/graph-extract.js +9 -1
  287. package/dist/src/graph-extract.js.map +1 -1
  288. package/dist/src/graph-view.js.map +1 -1
  289. package/dist/src/graph.js +38 -13
  290. package/dist/src/graph.js.map +1 -1
  291. package/dist/src/hooks.js +42 -21
  292. package/dist/src/hooks.js.map +1 -1
  293. package/dist/src/importers.js +23 -42
  294. package/dist/src/importers.js.map +1 -1
  295. package/dist/src/incidents.js +14 -0
  296. package/dist/src/incidents.js.map +1 -1
  297. package/dist/src/mcp/server.js +58 -28
  298. package/dist/src/mcp/server.js.map +1 -1
  299. package/dist/src/memory-value.js +8 -2
  300. package/dist/src/memory-value.js.map +1 -1
  301. package/dist/src/memory.js.map +1 -1
  302. package/dist/src/physics-state.js +5 -0
  303. package/dist/src/physics-state.js.map +1 -1
  304. package/dist/src/physics.js.map +1 -1
  305. package/dist/src/policies.js +22 -0
  306. package/dist/src/policies.js.map +1 -1
  307. package/dist/src/predictions.js +14 -0
  308. package/dist/src/predictions.js.map +1 -1
  309. package/dist/src/processes.js +21 -2
  310. package/dist/src/processes.js.map +1 -1
  311. package/dist/src/project-briefs.js +28 -2
  312. package/dist/src/project-briefs.js.map +1 -1
  313. package/dist/src/raw-archive-mirror-cleanup.js +2 -0
  314. package/dist/src/raw-archive-mirror-cleanup.js.map +1 -1
  315. package/dist/src/raw-archive.js +3 -9
  316. package/dist/src/raw-archive.js.map +1 -1
  317. package/dist/src/recall-scope.js +11 -6
  318. package/dist/src/recall-scope.js.map +1 -1
  319. package/dist/src/recall-trace.js +7 -3
  320. package/dist/src/recall-trace.js.map +1 -1
  321. package/dist/src/refine-llm.js +3 -0
  322. package/dist/src/refine-llm.js.map +1 -1
  323. package/dist/src/reject-flow.js +2 -0
  324. package/dist/src/reject-flow.js.map +1 -1
  325. package/dist/src/rejection.js +3 -0
  326. package/dist/src/rejection.js.map +1 -1
  327. package/dist/src/replay.js.map +1 -1
  328. package/dist/src/rerankers/cross-encoder.js +11 -4
  329. package/dist/src/rerankers/cross-encoder.js.map +1 -1
  330. package/dist/src/rerankers/index.js +5 -3
  331. package/dist/src/rerankers/index.js.map +1 -1
  332. package/dist/src/rerankers/llm.js +8 -5
  333. package/dist/src/rerankers/llm.js.map +1 -1
  334. package/dist/src/scheduler.js +15 -5
  335. package/dist/src/scheduler.js.map +1 -1
  336. package/dist/src/search.js.map +1 -1
  337. package/dist/src/server-detect.js +2 -0
  338. package/dist/src/server-detect.js.map +1 -1
  339. package/dist/src/server.js +194 -135
  340. package/dist/src/server.js.map +1 -1
  341. package/dist/src/skills.js +10 -3
  342. package/dist/src/skills.js.map +1 -1
  343. package/dist/src/store.js +140 -14
  344. package/dist/src/store.js.map +1 -1
  345. package/dist/src/trace.js +15 -7
  346. package/dist/src/trace.js.map +1 -1
  347. package/dist/src/version.js +1 -1
  348. package/dist/src/working-memory.js +8 -0
  349. package/dist/src/working-memory.js.map +1 -1
  350. package/dist/src/yaml.js +12 -3
  351. package/dist/src/yaml.js.map +1 -1
  352. package/dist/store.d.ts +16 -3
  353. package/dist/store.d.ts.map +1 -1
  354. package/dist/store.js +140 -14
  355. package/dist/store.js.map +1 -1
  356. package/dist/trace.d.ts.map +1 -1
  357. package/dist/trace.js +15 -7
  358. package/dist/trace.js.map +1 -1
  359. package/dist/version.d.ts +1 -1
  360. package/dist/version.js +1 -1
  361. package/dist/working-memory.d.ts +6 -2
  362. package/dist/working-memory.d.ts.map +1 -1
  363. package/dist/working-memory.js +8 -0
  364. package/dist/working-memory.js.map +1 -1
  365. package/dist/yaml.d.ts.map +1 -1
  366. package/dist/yaml.js +12 -3
  367. package/dist/yaml.js.map +1 -1
  368. package/dist-ui/assets/{index-Cxa4RcAh.js → index-DOqenbV2.js} +11 -11
  369. package/dist-ui/index.html +1 -1
  370. package/extensions/openclaw-plugin/index.ts +50 -10
  371. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  372. package/extensions/openclaw-plugin/package.json +1 -1
  373. package/openclaw.plugin.json +1 -1
  374. package/package.json +4 -1
@@ -118,6 +118,41 @@ const VALID_AUDIT_OPS = new Set([
118
118
  // enough to dump a small deployment's full audit log without paginating, but
119
119
  // small enough that a malicious client can't ask for the world.
120
120
  const MAX_AUDIT_LIMIT = 10000;
121
+ function isJsonString(value) {
122
+ return typeof value === 'string';
123
+ }
124
+ function isJsonNumber(value) {
125
+ return typeof value === 'number';
126
+ }
127
+ function isJsonBoolean(value) {
128
+ return typeof value === 'boolean';
129
+ }
130
+ function isJsonObjectRecord(value) {
131
+ return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
132
+ }
133
+ // node:http header values are `string | string[] | undefined` (never a bare
134
+ // unknown), so this gets its own predicate rather than reusing isJsonString.
135
+ function isHeaderString(value) {
136
+ return typeof value === 'string';
137
+ }
138
+ // server.address() returns AddressInfo once a TCP socket is bound; null before
139
+ // listening, a string only for pipe/unix-socket listeners (never used here).
140
+ function isAddressInfo(a) {
141
+ return a !== null && typeof a !== 'string';
142
+ }
143
+ // Runtime membership check for a `ReadonlySet<T>` of string-literal union
144
+ // members, used at every `body` field validated against a VALID_* set below.
145
+ // Set<T>.has(value: T) itself gives no narrowing (its parameter type is T,
146
+ // not a type predicate) so callers previously needed a separate `as T` cast
147
+ // at both the check and the later usage; this helper is the one place that
148
+ // assertion lives, so downstream call sites narrow via the `value is T`
149
+ // return instead of re-asserting.
150
+ function isSetMember(set, value) {
151
+ // SAFETY: `value as T` is discarded unless `set.has` (the real runtime
152
+ // check) confirms membership; the `value is T` return type is what
153
+ // performs the actual narrowing for callers.
154
+ return set.has(value);
155
+ }
121
156
  // HTTP-boundary validation for a process `steps` body (untrusted). Returns the
122
157
  // step strings (saveProcess re-validates + trims, this is the fail-fast 400
123
158
  // gate). Caps mirror src/processes.ts MAX_PROCESS_STEPS / MAX_PROCESS_STEP_LEN.
@@ -131,7 +166,7 @@ function validateProcessStepsBody(raw) {
131
166
  throw new HttpError(400, 'steps exceeds 200-step cap');
132
167
  }
133
168
  for (const item of raw) {
134
- if (typeof item !== 'string') {
169
+ if (!isJsonString(item)) {
135
170
  throw new HttpError(400, 'each step must be a string');
136
171
  }
137
172
  if (item.trim().length === 0) {
@@ -141,6 +176,7 @@ function validateProcessStepsBody(raw) {
141
176
  throw new HttpError(400, 'a step exceeds the 2000-character cap');
142
177
  }
143
178
  }
179
+ // SAFETY: every item in raw was confirmed to be a string in the loop above.
144
180
  return raw;
145
181
  }
146
182
  // HTTP-boundary check for an optional policy date field (validFrom/validTo).
@@ -150,7 +186,7 @@ function validateProcessStepsBody(raw) {
150
186
  function optionalDateField(raw, label) {
151
187
  if (raw === undefined || raw === null)
152
188
  return undefined;
153
- if (typeof raw !== 'string') {
189
+ if (!isJsonString(raw)) {
154
190
  throw new HttpError(400, `${label} must be a string`);
155
191
  }
156
192
  if (raw.length > 64) {
@@ -216,6 +252,8 @@ async function readBody(req) {
216
252
  const chunks = [];
217
253
  let total = 0;
218
254
  for await (const chunk of req) {
255
+ // SAFETY: IncomingMessage never runs setEncoding() here, so every
256
+ // streamed chunk is a Buffer, not a decoded string.
219
257
  const buf = chunk;
220
258
  total += buf.length;
221
259
  if (total > MAX_BODY_BYTES) {
@@ -231,7 +269,7 @@ async function parseJsonBody(req) {
231
269
  return {};
232
270
  try {
233
271
  const parsed = JSON.parse(raw);
234
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
272
+ if (!isJsonObjectRecord(parsed)) {
235
273
  throw new HttpError(400, 'request body must be a JSON object');
236
274
  }
237
275
  return parsed;
@@ -320,8 +358,9 @@ function readAuthHeader(req) {
320
358
  if (raw === undefined)
321
359
  return { kind: 'absent' };
322
360
  const value = Array.isArray(raw) ? raw[0] : raw;
323
- if (typeof value !== 'string' || value.length === 0)
361
+ if (!isHeaderString(value) || value.length === 0) {
324
362
  return { kind: 'malformed' };
363
+ }
325
364
  const space = value.indexOf(' ');
326
365
  if (space < 0)
327
366
  return { kind: 'malformed' };
@@ -432,13 +471,13 @@ function requireAuth(req, hippoRoot) {
432
471
  }
433
472
  function getString(obj, key) {
434
473
  const v = obj[key];
435
- return typeof v === 'string' ? v : undefined;
474
+ return isJsonString(v) ? v : undefined;
436
475
  }
437
476
  function getStringArray(obj, key) {
438
477
  const v = obj[key];
439
478
  if (!Array.isArray(v))
440
479
  return undefined;
441
- if (!v.every((x) => typeof x === 'string'))
480
+ if (!v.every(isJsonString))
442
481
  return undefined;
443
482
  return v;
444
483
  }
@@ -518,7 +557,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
518
557
  throw new HttpError(400, 'content is required');
519
558
  }
520
559
  const kindRaw = getString(body, 'kind');
521
- if (kindRaw !== undefined && !VALID_KINDS.has(kindRaw)) {
560
+ if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
522
561
  throw new HttpError(400, `invalid kind: ${kindRaw}`);
523
562
  }
524
563
  const ctx = buildContextWithAuth(req, opts.hippoRoot);
@@ -667,19 +706,28 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
667
706
  }
668
707
  }
669
708
  }
709
+ const recallExtra = {};
710
+ if (freshTailCount !== undefined)
711
+ recallExtra.freshTailCount = freshTailCount;
712
+ if (freshTailSessionId !== undefined)
713
+ recallExtra.freshTailSessionId = freshTailSessionId;
714
+ if (summarizeOverflow !== undefined)
715
+ recallExtra.summarizeOverflow = summarizeOverflow;
716
+ if (scorerWindow !== undefined)
717
+ recallExtra.scorerWindow = scorerWindow;
718
+ if (sessionId !== undefined)
719
+ recallExtra.sessionId = sessionId;
720
+ if (httpRecallHistory !== undefined)
721
+ recallExtra.recallHistory = httpRecallHistory;
722
+ if (explain)
723
+ recallExtra.explain = explain;
670
724
  const result = recall(ctx, {
671
725
  query: q,
672
726
  limit,
673
- mode: (mode ?? undefined),
727
+ mode: mode ?? undefined,
674
728
  scope: scope ?? undefined,
675
729
  includeContinuity,
676
- ...(freshTailCount !== undefined ? { freshTailCount } : {}),
677
- ...(freshTailSessionId !== undefined ? { freshTailSessionId } : {}),
678
- ...(summarizeOverflow !== undefined ? { summarizeOverflow } : {}),
679
- ...(scorerWindow !== undefined ? { scorerWindow } : {}),
680
- ...(sessionId !== undefined ? { sessionId } : {}),
681
- ...(httpRecallHistory !== undefined ? { recallHistory: httpRecallHistory } : {}),
682
- ...(explain ? { explain } : {}),
730
+ ...recallExtra,
683
731
  });
684
732
  // v0.33 / J1 — append AFTER recall completes (snapshot was taken before
685
733
  // recall() ran). anchoredOn carries the memoryId of any hint that fired
@@ -729,12 +777,16 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
729
777
  const scopeQ = query.get('scope');
730
778
  const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
731
779
  const ctx = buildContextWithAuth(req, opts.hippoRoot);
732
- const result = assemble(ctx, assembleMatch.id, {
733
- ...(budget !== undefined ? { budget } : {}),
734
- ...(freshTailCount !== undefined ? { freshTailCount } : {}),
735
- ...(summarizeOlder !== undefined ? { summarizeOlder } : {}),
736
- ...(scope !== undefined ? { scope } : {}),
737
- });
780
+ const assembleExtra = {};
781
+ if (budget !== undefined)
782
+ assembleExtra.budget = budget;
783
+ if (freshTailCount !== undefined)
784
+ assembleExtra.freshTailCount = freshTailCount;
785
+ if (summarizeOlder !== undefined)
786
+ assembleExtra.summarizeOlder = summarizeOlder;
787
+ if (scope !== undefined)
788
+ assembleExtra.scope = scope;
789
+ const result = assemble(ctx, assembleMatch.id, assembleExtra);
738
790
  sendJson(res, 200, result);
739
791
  return;
740
792
  }
@@ -769,11 +821,14 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
769
821
  depth = parsed;
770
822
  }
771
823
  const ctx = buildContextWithAuth(req, opts.hippoRoot);
772
- const result = drillDown(ctx, drillMatch.id, {
773
- ...(limit !== undefined ? { limit } : {}),
774
- ...(budget !== undefined ? { budget } : {}),
775
- ...(depth !== undefined ? { depth } : {}),
776
- });
824
+ const drillExtra = {};
825
+ if (limit !== undefined)
826
+ drillExtra.limit = limit;
827
+ if (budget !== undefined)
828
+ drillExtra.budget = budget;
829
+ if (depth !== undefined)
830
+ drillExtra.depth = depth;
831
+ const result = drillDown(ctx, drillMatch.id, drillExtra);
777
832
  if ('failure' in result) {
778
833
  // v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
779
834
  // as 404 to avoid leaking cross-tenant existence or scope grants.
@@ -837,7 +892,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
837
892
  if (method === 'POST' && path === '/v1/outcome') {
838
893
  const body = await parseJsonBody(req);
839
894
  const good = body['good'];
840
- if (typeof good !== 'boolean') {
895
+ if (!isJsonBoolean(good)) {
841
896
  throw new HttpError(400, 'good is required (boolean)');
842
897
  }
843
898
  const idsRaw = body['ids'];
@@ -846,10 +901,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
846
901
  if (!Array.isArray(idsRaw)) {
847
902
  throw new HttpError(400, 'ids must be an array of non-empty strings');
848
903
  }
849
- for (const id of idsRaw) {
850
- if (typeof id !== 'string' || id.length === 0) {
851
- throw new HttpError(400, 'ids must be an array of non-empty strings');
852
- }
904
+ const isNonEmptyId = (item) => isJsonString(item) && item.length > 0;
905
+ if (!idsRaw.every(isNonEmptyId)) {
906
+ throw new HttpError(400, 'ids must be an array of non-empty strings');
853
907
  }
854
908
  // v1.11.5: DoS cap on ids.length. Each id triggers ~3 DB ops (readEntry +
855
909
  // writeEntry + appendAuditEvent). N=1000 keeps per-request work bounded
@@ -965,11 +1019,11 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
965
1019
  }
966
1020
  const body = await parseJsonBody(req);
967
1021
  const dryRunRaw = body['dry_run'];
968
- if (dryRunRaw !== undefined && typeof dryRunRaw !== 'boolean') {
1022
+ if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
969
1023
  throw new HttpError(400, 'dry_run must be a boolean');
970
1024
  }
971
1025
  const noShareRaw = body['no_share'];
972
- if (noShareRaw !== undefined && typeof noShareRaw !== 'boolean') {
1026
+ if (noShareRaw !== undefined && !isJsonBoolean(noShareRaw)) {
973
1027
  throw new HttpError(400, 'no_share must be a boolean');
974
1028
  }
975
1029
  // v1.12.0: sleepCtx already built above for the admin-role gate; reuse.
@@ -986,7 +1040,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
986
1040
  if (method === 'POST' && path === '/v1/auth/keys') {
987
1041
  const body = await parseJsonBody(req);
988
1042
  const labelRaw = body['label'];
989
- if (labelRaw !== undefined && typeof labelRaw !== 'string') {
1043
+ if (labelRaw !== undefined && !isJsonString(labelRaw)) {
990
1044
  throw new HttpError(400, 'label must be a string');
991
1045
  }
992
1046
  // v1.12.3: optional body.role mirrors the --role CLI flag. Validated
@@ -1054,7 +1108,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1054
1108
  const opRaw = query.get('op');
1055
1109
  let op;
1056
1110
  if (opRaw !== null) {
1057
- if (!VALID_AUDIT_OPS.has(opRaw)) {
1111
+ if (!isSetMember(VALID_AUDIT_OPS, opRaw)) {
1058
1112
  throw new HttpError(400, `invalid op: ${opRaw}`);
1059
1113
  }
1060
1114
  op = opRaw;
@@ -1103,20 +1157,20 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1103
1157
  if (method === 'POST' && path === '/v1/predictions') {
1104
1158
  const body = await parseJsonBody(req);
1105
1159
  const claim = body['claim'];
1106
- if (typeof claim !== 'string' || claim.length === 0) {
1160
+ if (!isJsonString(claim) || claim.length === 0) {
1107
1161
  throw new HttpError(400, 'claim is required (non-empty string)');
1108
1162
  }
1109
1163
  if (claim.length > 4096) {
1110
1164
  throw new HttpError(400, 'claim exceeds 4096-character cap');
1111
1165
  }
1112
1166
  const classTag = body['classTag'];
1113
- if (typeof classTag !== 'string' || classTag.length === 0) {
1167
+ if (!isJsonString(classTag) || classTag.length === 0) {
1114
1168
  throw new HttpError(400, 'classTag is required (non-empty string)');
1115
1169
  }
1116
1170
  const estimate = body['estimate'];
1117
1171
  let estimateValue;
1118
1172
  if (estimate !== undefined && estimate !== null) {
1119
- if (typeof estimate !== 'number' || !Number.isFinite(estimate)) {
1173
+ if (!isJsonNumber(estimate) || !Number.isFinite(estimate)) {
1120
1174
  throw new HttpError(400, 'estimate must be a finite number');
1121
1175
  }
1122
1176
  estimateValue = estimate;
@@ -1124,7 +1178,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1124
1178
  const unit = body['unit'];
1125
1179
  let estimateUnit;
1126
1180
  if (unit !== undefined && unit !== null) {
1127
- if (typeof unit !== 'string') {
1181
+ if (!isJsonString(unit)) {
1128
1182
  throw new HttpError(400, 'unit must be a string');
1129
1183
  }
1130
1184
  estimateUnit = unit;
@@ -1132,7 +1186,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1132
1186
  const targetDate = body['targetDate'];
1133
1187
  let targetDateValue;
1134
1188
  if (targetDate !== undefined && targetDate !== null) {
1135
- if (typeof targetDate !== 'string') {
1189
+ if (!isJsonString(targetDate)) {
1136
1190
  throw new HttpError(400, 'targetDate must be an ISO date string');
1137
1191
  }
1138
1192
  targetDateValue = targetDate;
@@ -1169,7 +1223,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1169
1223
  });
1170
1224
  }
1171
1225
  else {
1172
- if (!VALID_CLOSURE_STATES.has(status)) {
1226
+ if (!isSetMember(VALID_CLOSURE_STATES, status)) {
1173
1227
  throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
1174
1228
  }
1175
1229
  if (!classTag) {
@@ -1216,13 +1270,13 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1216
1270
  const id = parseInt(predictionCloseMatch[1], 10);
1217
1271
  const body = await parseJsonBody(req);
1218
1272
  const state = body['state'];
1219
- if (typeof state !== 'string' || !VALID_CLOSURE_STATES.has(state) || state === 'open') {
1273
+ if (!isJsonString(state) || !isSetMember(VALID_CLOSURE_STATES, state) || state === 'open') {
1220
1274
  throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
1221
1275
  }
1222
1276
  const actual = body['actual'];
1223
1277
  let actualValue;
1224
1278
  if (actual !== undefined && actual !== null) {
1225
- if (typeof actual !== 'number' || !Number.isFinite(actual)) {
1279
+ if (!isJsonNumber(actual) || !Number.isFinite(actual)) {
1226
1280
  throw new HttpError(400, 'actual must be a finite number');
1227
1281
  }
1228
1282
  actualValue = actual;
@@ -1230,7 +1284,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1230
1284
  const note = body['note'];
1231
1285
  let closureNote;
1232
1286
  if (note !== undefined && note !== null) {
1233
- if (typeof note !== 'string') {
1287
+ if (!isJsonString(note)) {
1234
1288
  throw new HttpError(400, 'note must be a string');
1235
1289
  }
1236
1290
  if (note.length > 2048) {
@@ -1248,7 +1302,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1248
1302
  sendJson(res, 200, { prediction });
1249
1303
  }
1250
1304
  catch (e) {
1251
- const msg = e.message;
1305
+ const msg = e instanceof Error ? e.message : String(e);
1252
1306
  if (msg.includes('not found')) {
1253
1307
  throw new HttpError(404, msg);
1254
1308
  }
@@ -1269,7 +1323,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1269
1323
  if (method === 'POST' && path === '/v1/decisions') {
1270
1324
  const body = await parseJsonBody(req);
1271
1325
  const text = body['text'];
1272
- if (typeof text !== 'string' || text.length === 0) {
1326
+ if (!isJsonString(text) || text.length === 0) {
1273
1327
  throw new HttpError(400, 'text is required (non-empty string)');
1274
1328
  }
1275
1329
  if (text.length > 4096) {
@@ -1278,7 +1332,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1278
1332
  const contextRaw = body['context'];
1279
1333
  let context;
1280
1334
  if (contextRaw !== undefined && contextRaw !== null) {
1281
- if (typeof contextRaw !== 'string') {
1335
+ if (!isJsonString(contextRaw)) {
1282
1336
  throw new HttpError(400, 'context must be a string');
1283
1337
  }
1284
1338
  if (contextRaw.length > 4096) {
@@ -1289,7 +1343,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1289
1343
  const supRaw = body['supersedesDecisionId'];
1290
1344
  let supersedesDecisionId;
1291
1345
  if (supRaw !== undefined && supRaw !== null) {
1292
- if (typeof supRaw !== 'number' || !Number.isInteger(supRaw) || supRaw <= 0) {
1346
+ if (!isJsonNumber(supRaw) || !Number.isInteger(supRaw) || supRaw <= 0) {
1293
1347
  throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
1294
1348
  }
1295
1349
  supersedesDecisionId = supRaw;
@@ -1304,7 +1358,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1304
1358
  sendJson(res, 201, { decision });
1305
1359
  }
1306
1360
  catch (e) {
1307
- const msg = e.message;
1361
+ const msg = e instanceof Error ? e.message : String(e);
1308
1362
  if (msg.includes('not found') || msg.includes('not active')) {
1309
1363
  throw new HttpError(409, msg);
1310
1364
  }
@@ -1321,11 +1375,11 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1321
1375
  decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
1322
1376
  }
1323
1377
  else {
1324
- if (!VALID_DECISION_STATES.has(status)) {
1378
+ if (!isSetMember(VALID_DECISION_STATES, status)) {
1325
1379
  throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1326
1380
  }
1327
1381
  decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
1328
- status: status,
1382
+ status,
1329
1383
  limit,
1330
1384
  });
1331
1385
  }
@@ -1337,7 +1391,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1337
1391
  const oldId = parseInt(decisionSupersedeMatch[1], 10);
1338
1392
  const body = await parseJsonBody(req);
1339
1393
  const text = body['text'];
1340
- if (typeof text !== 'string' || text.length === 0) {
1394
+ if (!isJsonString(text) || text.length === 0) {
1341
1395
  throw new HttpError(400, 'text is required (non-empty string)');
1342
1396
  }
1343
1397
  if (text.length > 4096) {
@@ -1346,7 +1400,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1346
1400
  const contextRaw = body['context'];
1347
1401
  let context;
1348
1402
  if (contextRaw !== undefined && contextRaw !== null) {
1349
- if (typeof contextRaw !== 'string') {
1403
+ if (!isJsonString(contextRaw)) {
1350
1404
  throw new HttpError(400, 'context must be a string');
1351
1405
  }
1352
1406
  if (contextRaw.length > 4096) {
@@ -1364,7 +1418,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1364
1418
  sendJson(res, 201, { decision });
1365
1419
  }
1366
1420
  catch (e) {
1367
- const msg = e.message;
1421
+ const msg = e instanceof Error ? e.message : String(e);
1368
1422
  if (msg.includes('not found')) {
1369
1423
  throw new HttpError(404, msg);
1370
1424
  }
@@ -1384,7 +1438,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1384
1438
  sendJson(res, 200, { decision });
1385
1439
  }
1386
1440
  catch (e) {
1387
- const msg = e.message;
1441
+ const msg = e instanceof Error ? e.message : String(e);
1388
1442
  if (msg.includes('not found')) {
1389
1443
  throw new HttpError(404, msg);
1390
1444
  }
@@ -1420,7 +1474,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1420
1474
  if (method === 'POST' && path === '/v1/incidents') {
1421
1475
  const body = await parseJsonBody(req);
1422
1476
  const text = body['text'];
1423
- if (typeof text !== 'string' || text.length === 0) {
1477
+ if (!isJsonString(text) || text.length === 0) {
1424
1478
  throw new HttpError(400, 'text is required (non-empty string)');
1425
1479
  }
1426
1480
  if (text.length > 4096) {
@@ -1429,7 +1483,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1429
1483
  const contextRaw = body['context'];
1430
1484
  let context;
1431
1485
  if (contextRaw !== undefined && contextRaw !== null) {
1432
- if (typeof contextRaw !== 'string') {
1486
+ if (!isJsonString(contextRaw)) {
1433
1487
  throw new HttpError(400, 'context must be a string');
1434
1488
  }
1435
1489
  if (contextRaw.length > 4096) {
@@ -1446,10 +1500,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1446
1500
  if (linkedRaw.length > 256) {
1447
1501
  throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
1448
1502
  }
1449
- for (const item of linkedRaw) {
1450
- if (typeof item !== 'string' || item.length === 0 || item.length > 4096) {
1451
- throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
1452
- }
1503
+ const isValidMemoryId = (item) => isJsonString(item) && item.length > 0 && item.length <= 4096;
1504
+ if (!linkedRaw.every(isValidMemoryId)) {
1505
+ throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
1453
1506
  }
1454
1507
  linkedMemoryIds = linkedRaw;
1455
1508
  }
@@ -1463,7 +1516,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1463
1516
  sendJson(res, 201, { incident });
1464
1517
  }
1465
1518
  catch (e) {
1466
- const msg = e.message;
1519
+ const msg = e instanceof Error ? e.message : String(e);
1467
1520
  if (msg.includes('not found')) {
1468
1521
  throw new HttpError(409, msg);
1469
1522
  }
@@ -1480,11 +1533,11 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1480
1533
  incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
1481
1534
  }
1482
1535
  else {
1483
- if (!VALID_INCIDENT_STATES.has(status)) {
1536
+ if (!isSetMember(VALID_INCIDENT_STATES, status)) {
1484
1537
  throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
1485
1538
  }
1486
1539
  incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
1487
- status: status,
1540
+ status,
1488
1541
  limit,
1489
1542
  });
1490
1543
  }
@@ -1496,7 +1549,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1496
1549
  const id = parseInt(incidentResolveMatch[1], 10);
1497
1550
  const body = await parseJsonBody(req);
1498
1551
  const resolutionText = body['resolutionText'];
1499
- if (typeof resolutionText !== 'string' || resolutionText.trim().length === 0) {
1552
+ if (!isJsonString(resolutionText) || resolutionText.trim().length === 0) {
1500
1553
  throw new HttpError(400, 'resolutionText is required (non-empty string)');
1501
1554
  }
1502
1555
  if (resolutionText.length > 4096) {
@@ -1508,7 +1561,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1508
1561
  sendJson(res, 200, { incident });
1509
1562
  }
1510
1563
  catch (e) {
1511
- const msg = e.message;
1564
+ const msg = e instanceof Error ? e.message : String(e);
1512
1565
  if (msg.includes('not found')) {
1513
1566
  throw new HttpError(404, msg);
1514
1567
  }
@@ -1528,7 +1581,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1528
1581
  sendJson(res, 200, { incident });
1529
1582
  }
1530
1583
  catch (e) {
1531
- const msg = e.message;
1584
+ const msg = e instanceof Error ? e.message : String(e);
1532
1585
  if (msg.includes('not found')) {
1533
1586
  throw new HttpError(404, msg);
1534
1587
  }
@@ -1564,7 +1617,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1564
1617
  if (method === 'POST' && path === '/v1/processes') {
1565
1618
  const body = await parseJsonBody(req);
1566
1619
  const processName = body['processName'];
1567
- if (typeof processName !== 'string' || processName.trim().length === 0) {
1620
+ if (!isJsonString(processName) || processName.trim().length === 0) {
1568
1621
  throw new HttpError(400, 'processName is required (non-empty string)');
1569
1622
  }
1570
1623
  if (processName.length > 4096) {
@@ -1574,7 +1627,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1574
1627
  const descriptionRaw = body['description'];
1575
1628
  let description;
1576
1629
  if (descriptionRaw !== undefined && descriptionRaw !== null) {
1577
- if (typeof descriptionRaw !== 'string') {
1630
+ if (!isJsonString(descriptionRaw)) {
1578
1631
  throw new HttpError(400, 'description must be a string');
1579
1632
  }
1580
1633
  if (descriptionRaw.length > 4096) {
@@ -1600,11 +1653,11 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1600
1653
  processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
1601
1654
  }
1602
1655
  else {
1603
- if (!VALID_PROCESS_STATES.has(status)) {
1656
+ if (!isSetMember(VALID_PROCESS_STATES, status)) {
1604
1657
  throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1605
1658
  }
1606
1659
  processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
1607
- status: status,
1660
+ status,
1608
1661
  limit,
1609
1662
  });
1610
1663
  }
@@ -1622,7 +1675,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1622
1675
  const changeRaw = body['changeSummary'];
1623
1676
  let changeSummary;
1624
1677
  if (changeRaw !== undefined && changeRaw !== null) {
1625
- if (typeof changeRaw !== 'string') {
1678
+ if (!isJsonString(changeRaw)) {
1626
1679
  throw new HttpError(400, 'changeSummary must be a string');
1627
1680
  }
1628
1681
  if (changeRaw.length > 4096) {
@@ -1633,7 +1686,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1633
1686
  const descRaw = body['description'];
1634
1687
  let description;
1635
1688
  if (descRaw !== undefined && descRaw !== null) {
1636
- if (typeof descRaw !== 'string') {
1689
+ if (!isJsonString(descRaw)) {
1637
1690
  throw new HttpError(400, 'description must be a string');
1638
1691
  }
1639
1692
  if (descRaw.length > 4096) {
@@ -1660,7 +1713,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1660
1713
  sendJson(res, 200, { process });
1661
1714
  }
1662
1715
  catch (e) {
1663
- const msg = e.message;
1716
+ const msg = e instanceof Error ? e.message : String(e);
1664
1717
  if (msg.includes('not found')) {
1665
1718
  throw new HttpError(404, msg);
1666
1719
  }
@@ -1680,7 +1733,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1680
1733
  sendJson(res, 200, { process });
1681
1734
  }
1682
1735
  catch (e) {
1683
- const msg = e.message;
1736
+ const msg = e instanceof Error ? e.message : String(e);
1684
1737
  if (msg.includes('not found')) {
1685
1738
  throw new HttpError(404, msg);
1686
1739
  }
@@ -1714,14 +1767,14 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1714
1767
  if (method === 'POST' && path === '/v1/policies') {
1715
1768
  const body = await parseJsonBody(req);
1716
1769
  const policyName = body['policyName'];
1717
- if (typeof policyName !== 'string' || policyName.trim().length === 0) {
1770
+ if (!isJsonString(policyName) || policyName.trim().length === 0) {
1718
1771
  throw new HttpError(400, 'policyName is required (non-empty string)');
1719
1772
  }
1720
1773
  if (policyName.length > 4096) {
1721
1774
  throw new HttpError(400, 'policyName exceeds 4096-character cap');
1722
1775
  }
1723
1776
  const policyText = body['policyText'];
1724
- if (typeof policyText !== 'string' || policyText.trim().length === 0) {
1777
+ if (!isJsonString(policyText) || policyText.trim().length === 0) {
1725
1778
  throw new HttpError(400, 'policyText is required (non-empty string)');
1726
1779
  }
1727
1780
  if (policyText.length > 4096) {
@@ -1741,7 +1794,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1741
1794
  }
1742
1795
  catch (e) {
1743
1796
  // savePolicy throws on invalid/inverted dates (validation) -> 400.
1744
- throw new HttpError(400, e.message);
1797
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1745
1798
  }
1746
1799
  return;
1747
1800
  }
@@ -1754,11 +1807,11 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1754
1807
  policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
1755
1808
  }
1756
1809
  else {
1757
- if (!VALID_POLICY_STATES.has(status)) {
1810
+ if (!isSetMember(VALID_POLICY_STATES, status)) {
1758
1811
  throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1759
1812
  }
1760
1813
  policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
1761
- status: status,
1814
+ status,
1762
1815
  limit,
1763
1816
  });
1764
1817
  }
@@ -1779,7 +1832,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1779
1832
  sendJson(res, 200, { policies });
1780
1833
  }
1781
1834
  catch (e) {
1782
- throw new HttpError(400, e.message);
1835
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1783
1836
  }
1784
1837
  return;
1785
1838
  }
@@ -1788,7 +1841,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1788
1841
  const id = parseInt(policySupersedeMatch[1], 10);
1789
1842
  const body = await parseJsonBody(req);
1790
1843
  const policyText = body['policyText'];
1791
- if (typeof policyText !== 'string' || policyText.trim().length === 0) {
1844
+ if (!isJsonString(policyText) || policyText.trim().length === 0) {
1792
1845
  throw new HttpError(400, 'policyText is required (non-empty string)');
1793
1846
  }
1794
1847
  if (policyText.length > 4096) {
@@ -1799,7 +1852,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1799
1852
  const changeRaw = body['changeSummary'];
1800
1853
  let changeSummary;
1801
1854
  if (changeRaw !== undefined && changeRaw !== null) {
1802
- if (typeof changeRaw !== 'string') {
1855
+ if (!isJsonString(changeRaw)) {
1803
1856
  throw new HttpError(400, 'changeSummary must be a string');
1804
1857
  }
1805
1858
  if (changeRaw.length > 4096) {
@@ -1824,7 +1877,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1824
1877
  sendJson(res, 200, { policy });
1825
1878
  }
1826
1879
  catch (e) {
1827
- const msg = e.message;
1880
+ const msg = e instanceof Error ? e.message : String(e);
1828
1881
  if (msg.includes('not found')) {
1829
1882
  throw new HttpError(404, msg);
1830
1883
  }
@@ -1845,7 +1898,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1845
1898
  sendJson(res, 200, { policy });
1846
1899
  }
1847
1900
  catch (e) {
1848
- const msg = e.message;
1901
+ const msg = e instanceof Error ? e.message : String(e);
1849
1902
  if (msg.includes('not found')) {
1850
1903
  throw new HttpError(404, msg);
1851
1904
  }
@@ -1882,14 +1935,14 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1882
1935
  if (method === 'POST' && path === '/v1/skills') {
1883
1936
  const body = await parseJsonBody(req);
1884
1937
  const skillName = body['skillName'];
1885
- if (typeof skillName !== 'string' || skillName.trim().length === 0) {
1938
+ if (!isJsonString(skillName) || skillName.trim().length === 0) {
1886
1939
  throw new HttpError(400, 'skillName is required (non-empty string)');
1887
1940
  }
1888
1941
  if (skillName.length > 256) {
1889
1942
  throw new HttpError(400, 'skillName exceeds 256-character cap');
1890
1943
  }
1891
1944
  const instructions = body['instructions'];
1892
- if (typeof instructions !== 'string' || instructions.trim().length === 0) {
1945
+ if (!isJsonString(instructions) || instructions.trim().length === 0) {
1893
1946
  throw new HttpError(400, 'instructions are required (non-empty string)');
1894
1947
  }
1895
1948
  if (instructions.length > 8192) {
@@ -1898,7 +1951,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1898
1951
  const triggerRaw = body['trigger'];
1899
1952
  let trigger;
1900
1953
  if (triggerRaw !== undefined && triggerRaw !== null) {
1901
- if (typeof triggerRaw !== 'string') {
1954
+ if (!isJsonString(triggerRaw)) {
1902
1955
  throw new HttpError(400, 'trigger must be a string');
1903
1956
  }
1904
1957
  if (triggerRaw.length > 1024) {
@@ -1917,7 +1970,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1917
1970
  }
1918
1971
  catch (e) {
1919
1972
  // saveSkill throws on validation (single-line name etc.) -> 400.
1920
- throw new HttpError(400, e.message);
1973
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1921
1974
  }
1922
1975
  return;
1923
1976
  }
@@ -1930,11 +1983,11 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1930
1983
  skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
1931
1984
  }
1932
1985
  else {
1933
- if (!VALID_SKILL_STATES.has(status)) {
1986
+ if (!isSetMember(VALID_SKILL_STATES, status)) {
1934
1987
  throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1935
1988
  }
1936
1989
  skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
1937
- status: status,
1990
+ status,
1938
1991
  limit,
1939
1992
  });
1940
1993
  }
@@ -1954,7 +2007,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1954
2007
  const id = parseInt(skillSupersedeMatch[1], 10);
1955
2008
  const body = await parseJsonBody(req);
1956
2009
  const instructions = body['instructions'];
1957
- if (typeof instructions !== 'string' || instructions.trim().length === 0) {
2010
+ if (!isJsonString(instructions) || instructions.trim().length === 0) {
1958
2011
  throw new HttpError(400, 'instructions are required (non-empty string)');
1959
2012
  }
1960
2013
  if (instructions.length > 8192) {
@@ -1963,7 +2016,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1963
2016
  const triggerRaw = body['trigger'];
1964
2017
  let trigger;
1965
2018
  if (triggerRaw !== undefined && triggerRaw !== null) {
1966
- if (typeof triggerRaw !== 'string') {
2019
+ if (!isJsonString(triggerRaw)) {
1967
2020
  throw new HttpError(400, 'trigger must be a string');
1968
2021
  }
1969
2022
  if (triggerRaw.length > 1024) {
@@ -1974,7 +2027,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1974
2027
  const changeRaw = body['changeSummary'];
1975
2028
  let changeSummary;
1976
2029
  if (changeRaw !== undefined && changeRaw !== null) {
1977
- if (typeof changeRaw !== 'string') {
2030
+ if (!isJsonString(changeRaw)) {
1978
2031
  throw new HttpError(400, 'changeSummary must be a string');
1979
2032
  }
1980
2033
  if (changeRaw.length > 4096) {
@@ -1998,7 +2051,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1998
2051
  sendJson(res, 200, { skill });
1999
2052
  }
2000
2053
  catch (e) {
2001
- const msg = e.message;
2054
+ const msg = e instanceof Error ? e.message : String(e);
2002
2055
  if (msg.includes('not found')) {
2003
2056
  throw new HttpError(404, msg);
2004
2057
  }
@@ -2018,7 +2071,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2018
2071
  sendJson(res, 200, { skill });
2019
2072
  }
2020
2073
  catch (e) {
2021
- const msg = e.message;
2074
+ const msg = e instanceof Error ? e.message : String(e);
2022
2075
  if (msg.includes('not found')) {
2023
2076
  throw new HttpError(404, msg);
2024
2077
  }
@@ -2053,14 +2106,14 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2053
2106
  if (method === 'POST' && path === '/v1/project-briefs') {
2054
2107
  const body = await parseJsonBody(req);
2055
2108
  const repo = body['repo'];
2056
- if (typeof repo !== 'string' || repo.trim().length === 0) {
2109
+ if (!isJsonString(repo) || repo.trim().length === 0) {
2057
2110
  throw new HttpError(400, 'repo is required (non-empty string)');
2058
2111
  }
2059
2112
  if (repo.length > 256) {
2060
2113
  throw new HttpError(400, 'repo exceeds 256-character cap');
2061
2114
  }
2062
2115
  const summary = body['summary'];
2063
- if (typeof summary !== 'string' || summary.trim().length === 0) {
2116
+ if (!isJsonString(summary) || summary.trim().length === 0) {
2064
2117
  throw new HttpError(400, 'summary is required (non-empty string)');
2065
2118
  }
2066
2119
  if (summary.length > 8192) {
@@ -2076,7 +2129,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2076
2129
  }
2077
2130
  catch (e) {
2078
2131
  // saveProjectBrief throws on validation (single-line repo etc.) -> 400.
2079
- throw new HttpError(400, e.message);
2132
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
2080
2133
  }
2081
2134
  return;
2082
2135
  }
@@ -2090,7 +2143,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2090
2143
  listOpts.repo = repoFilter.trim();
2091
2144
  }
2092
2145
  if (status !== 'all') {
2093
- if (!VALID_BRIEF_STATES.has(status)) {
2146
+ if (!isSetMember(VALID_BRIEF_STATES, status)) {
2094
2147
  throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2095
2148
  }
2096
2149
  listOpts.status = status;
@@ -2104,7 +2157,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2104
2157
  if (method === 'POST' && path === '/v1/project-briefs/refresh') {
2105
2158
  const body = await parseJsonBody(req);
2106
2159
  const repo = body['repo'];
2107
- if (typeof repo !== 'string' || repo.trim().length === 0) {
2160
+ if (!isJsonString(repo) || repo.trim().length === 0) {
2108
2161
  throw new HttpError(400, 'repo is required (non-empty string)');
2109
2162
  }
2110
2163
  if (repo.length > 256) {
@@ -2126,7 +2179,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2126
2179
  // loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
2127
2180
  // validation error — map it to 409 like the explicit supersede route
2128
2181
  // (codex-review 2026-05-30, P3).
2129
- const msg = e.message;
2182
+ const msg = e instanceof Error ? e.message : String(e);
2130
2183
  if (msg.includes('not found')) {
2131
2184
  throw new HttpError(404, msg);
2132
2185
  }
@@ -2142,7 +2195,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2142
2195
  const id = parseInt(briefSupersedeMatch[1], 10);
2143
2196
  const body = await parseJsonBody(req);
2144
2197
  const summary = body['summary'];
2145
- if (typeof summary !== 'string' || summary.trim().length === 0) {
2198
+ if (!isJsonString(summary) || summary.trim().length === 0) {
2146
2199
  throw new HttpError(400, 'summary is required (non-empty string)');
2147
2200
  }
2148
2201
  if (summary.length > 8192) {
@@ -2151,7 +2204,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2151
2204
  const changeRaw = body['changeSummary'];
2152
2205
  let changeSummary;
2153
2206
  if (changeRaw !== undefined && changeRaw !== null) {
2154
- if (typeof changeRaw !== 'string') {
2207
+ if (!isJsonString(changeRaw)) {
2155
2208
  throw new HttpError(400, 'changeSummary must be a string');
2156
2209
  }
2157
2210
  if (changeRaw.length > 4096) {
@@ -2174,7 +2227,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2174
2227
  sendJson(res, 200, { brief });
2175
2228
  }
2176
2229
  catch (e) {
2177
- const msg = e.message;
2230
+ const msg = e instanceof Error ? e.message : String(e);
2178
2231
  if (msg.includes('not found')) {
2179
2232
  throw new HttpError(404, msg);
2180
2233
  }
@@ -2194,7 +2247,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2194
2247
  sendJson(res, 200, { brief });
2195
2248
  }
2196
2249
  catch (e) {
2197
- const msg = e.message;
2250
+ const msg = e instanceof Error ? e.message : String(e);
2198
2251
  if (msg.includes('not found')) {
2199
2252
  throw new HttpError(404, msg);
2200
2253
  }
@@ -2227,14 +2280,14 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2227
2280
  if (method === 'POST' && path === '/v1/customer-notes') {
2228
2281
  const body = await parseJsonBody(req);
2229
2282
  const customer = body['customer'];
2230
- if (typeof customer !== 'string' || customer.trim().length === 0) {
2283
+ if (!isJsonString(customer) || customer.trim().length === 0) {
2231
2284
  throw new HttpError(400, 'customer is required (non-empty string)');
2232
2285
  }
2233
2286
  if (customer.length > 256) {
2234
2287
  throw new HttpError(400, 'customer exceeds 256-character cap');
2235
2288
  }
2236
2289
  const note = body['note'];
2237
- if (typeof note !== 'string' || note.trim().length === 0) {
2290
+ if (!isJsonString(note) || note.trim().length === 0) {
2238
2291
  throw new HttpError(400, 'note is required (non-empty string)');
2239
2292
  }
2240
2293
  if (note.length > 8192) {
@@ -2250,7 +2303,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2250
2303
  }
2251
2304
  catch (e) {
2252
2305
  // saveCustomerNote throws on validation (single-line customer etc.) -> 400.
2253
- throw new HttpError(400, e.message);
2306
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
2254
2307
  }
2255
2308
  return;
2256
2309
  }
@@ -2264,7 +2317,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2264
2317
  listOpts.customer = customerFilter.trim();
2265
2318
  }
2266
2319
  if (status !== 'all') {
2267
- if (!VALID_NOTE_STATES.has(status)) {
2320
+ if (!isSetMember(VALID_NOTE_STATES, status)) {
2268
2321
  throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2269
2322
  }
2270
2323
  listOpts.status = status;
@@ -2278,7 +2331,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2278
2331
  const id = parseInt(noteSupersedeMatch[1], 10);
2279
2332
  const body = await parseJsonBody(req);
2280
2333
  const note = body['note'];
2281
- if (typeof note !== 'string' || note.trim().length === 0) {
2334
+ if (!isJsonString(note) || note.trim().length === 0) {
2282
2335
  throw new HttpError(400, 'note is required (non-empty string)');
2283
2336
  }
2284
2337
  if (note.length > 8192) {
@@ -2287,7 +2340,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2287
2340
  const changeRaw = body['changeSummary'];
2288
2341
  let changeSummary;
2289
2342
  if (changeRaw !== undefined && changeRaw !== null) {
2290
- if (typeof changeRaw !== 'string') {
2343
+ if (!isJsonString(changeRaw)) {
2291
2344
  throw new HttpError(400, 'changeSummary must be a string');
2292
2345
  }
2293
2346
  if (changeRaw.length > 4096) {
@@ -2310,7 +2363,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2310
2363
  sendJson(res, 200, { note: customerNote });
2311
2364
  }
2312
2365
  catch (e) {
2313
- const msg = e.message;
2366
+ const msg = e instanceof Error ? e.message : String(e);
2314
2367
  if (msg.includes('not found')) {
2315
2368
  throw new HttpError(404, msg);
2316
2369
  }
@@ -2330,7 +2383,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2330
2383
  sendJson(res, 200, { note: customerNote });
2331
2384
  }
2332
2385
  catch (e) {
2333
- const msg = e.message;
2386
+ const msg = e instanceof Error ? e.message : String(e);
2334
2387
  if (msg.includes('not found')) {
2335
2388
  throw new HttpError(404, msg);
2336
2389
  }
@@ -2386,8 +2439,8 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2386
2439
  const previousSecret = process.env.SLACK_SIGNING_SECRET_PREVIOUS;
2387
2440
  const sig = req.headers['x-slack-signature'];
2388
2441
  const tsHdr = req.headers['x-slack-request-timestamp'];
2389
- const sigStr = typeof sig === 'string' ? sig : null;
2390
- const tsStr = typeof tsHdr === 'string' ? tsHdr : null;
2442
+ const sigStr = isHeaderString(sig) ? sig : null;
2443
+ const tsStr = isHeaderString(tsHdr) ? tsHdr : null;
2391
2444
  if (sigStr === null ||
2392
2445
  tsStr === null ||
2393
2446
  !verifySlackSignature({
@@ -2438,20 +2491,21 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2438
2491
  sendJson(res, 200, { ok: true, status: 'dlq' });
2439
2492
  return;
2440
2493
  }
2441
- if (body &&
2442
- typeof body === 'object' &&
2443
- body.type === 'url_verification') {
2444
- sendJson(res, 200, {
2445
- challenge: String(body.challenge ?? ''),
2446
- });
2447
- return;
2494
+ if (isJsonObjectRecord(body)) {
2495
+ const bodyRecord = body;
2496
+ if (bodyRecord.type === 'url_verification') {
2497
+ sendJson(res, 200, {
2498
+ challenge: String(bodyRecord.challenge ?? ''),
2499
+ });
2500
+ return;
2501
+ }
2448
2502
  }
2449
2503
  // Resolve tenant. v0.39 fail-closed: when slack_workspaces is non-empty
2450
2504
  // and the team_id is unknown, resolveTenantForTeam returns null and we
2451
2505
  // park the envelope in slack_dlq with bucket='unroutable'. Mandatory ACK
2452
2506
  // 200 so Slack stops retrying; do NOT call ingest.
2453
2507
  let resolvedTenant = null;
2454
- if (isSlackEventEnvelope(body)) {
2508
+ if (body !== undefined && isSlackEventEnvelope(body)) {
2455
2509
  const db = openHippoDb(opts.hippoRoot);
2456
2510
  try {
2457
2511
  resolvedTenant = resolveTenantForTeam(db, body.team_id);
@@ -2488,7 +2542,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2488
2542
  tenantId: resolvedTenant,
2489
2543
  actor: adminActor('connector:slack'),
2490
2544
  };
2491
- if (!isSlackEventEnvelope(body)) {
2545
+ if (body === undefined || !isSlackEventEnvelope(body)) {
2492
2546
  const db = openHippoDb(ctx.hippoRoot);
2493
2547
  try {
2494
2548
  writeToDlq(db, {
@@ -2542,7 +2596,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2542
2596
  tenantId: ctx.tenantId,
2543
2597
  teamId: body.team_id,
2544
2598
  rawPayload: rawBody,
2545
- error: `unhandled event type: ${inner?.type ?? 'unknown'}`,
2599
+ error: `unhandled event type: ${inner.type ?? 'unknown'}`,
2546
2600
  bucket: 'parse_error',
2547
2601
  signature: sigStr,
2548
2602
  slackTimestamp: tsStr,
@@ -2583,9 +2637,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2583
2637
  const sigHdr = req.headers['x-hub-signature-256'];
2584
2638
  const eventHdr = req.headers['x-github-event'];
2585
2639
  const deliveryHdr = req.headers['x-github-delivery'];
2586
- const sigStr = typeof sigHdr === 'string' ? sigHdr : null;
2587
- const eventName = typeof eventHdr === 'string' ? eventHdr : null;
2588
- const deliveryId = typeof deliveryHdr === 'string' ? deliveryHdr : null;
2640
+ const sigStr = isHeaderString(sigHdr) ? sigHdr : null;
2641
+ const eventName = isHeaderString(eventHdr) ? eventHdr : null;
2642
+ const deliveryId = isHeaderString(deliveryHdr) ? deliveryHdr : null;
2589
2643
  if (sigStr === null ||
2590
2644
  !verifyGitHubSignature({
2591
2645
  rawBody,
@@ -2686,7 +2740,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2686
2740
  sendJson(res, 200, { ok: true, status: 'dlq' });
2687
2741
  return;
2688
2742
  }
2689
- if (!isGitHubWebhookEnvelope(body)) {
2743
+ if (body === undefined || !isGitHubWebhookEnvelope(body)) {
2690
2744
  const db = openHippoDb(opts.hippoRoot);
2691
2745
  try {
2692
2746
  writeToGitHubDlq(db, {
@@ -2892,12 +2946,16 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2892
2946
  catch {
2893
2947
  throw new HttpError(400, 'invalid JSON-RPC body');
2894
2948
  }
2895
- if (!mcpReq || typeof mcpReq !== 'object' || typeof mcpReq.method !== 'string') {
2949
+ if (!isJsonObjectRecord(mcpReq) || !isJsonString(mcpReq.method)) {
2896
2950
  throw new HttpError(400, 'JSON-RPC body must include a method string');
2897
2951
  }
2952
+ // SAFETY: validated above as a plain JSON object carrying a string method;
2953
+ // the remaining McpRequest wire fields (jsonrpc, id, params) are checked or
2954
+ // safely defaulted inside handleMcpRequest's JSON-RPC dispatch.
2955
+ const rpcReq = mcpReq;
2898
2956
  let mcpRes;
2899
2957
  try {
2900
- mcpRes = await handleMcpRequest(mcpReq, {
2958
+ mcpRes = await handleMcpRequest(rpcReq, {
2901
2959
  hippoRoot: ctx.hippoRoot,
2902
2960
  tenantId: ctx.tenantId,
2903
2961
  // v1.12.0: McpContext.actor stays string; extract subject at the boundary.
@@ -2982,7 +3040,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2982
3040
  }, heartbeatMs);
2983
3041
  // Don't keep the event loop alive just for this timer — the server's
2984
3042
  // listener already does that, and tests want the process to exit cleanly.
2985
- if (typeof ping.unref === 'function')
3043
+ if (ping.unref instanceof Function)
2986
3044
  ping.unref();
2987
3045
  req.on('close', () => clearInterval(ping));
2988
3046
  return;
@@ -3098,10 +3156,11 @@ export async function serve(opts) {
3098
3156
  server.listen(requestedPort, host);
3099
3157
  });
3100
3158
  const address = server.address();
3101
- if (!address || typeof address === 'string') {
3159
+ if (!isAddressInfo(address)) {
3102
3160
  throw new Error('server.address() returned unexpected shape');
3103
3161
  }
3104
- const actualPort = address.port;
3162
+ const addressInfo = address;
3163
+ const actualPort = addressInfo.port;
3105
3164
  const url = `http://${host}:${actualPort}`;
3106
3165
  writePidfile(opts.hippoRoot, { port: actualPort, url, startedAt });
3107
3166
  let stopping = false;