hippo-memory 1.44.0 → 1.46.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 (554) hide show
  1. package/README.md +68 -24
  2. package/bin/hippo.js +3 -1
  3. package/dist/ablation.d.ts +10 -1
  4. package/dist/ablation.js +17 -1
  5. package/dist/api.d.ts +81 -22
  6. package/dist/api.js +258 -49
  7. package/dist/audit.d.ts +2 -2
  8. package/dist/audit.js +9 -0
  9. package/dist/autolearn.d.ts +1 -1
  10. package/dist/autolearn.js +2 -1
  11. package/dist/capture-error.d.ts +20 -0
  12. package/dist/capture-error.js +82 -0
  13. package/dist/capture.d.ts +25 -8
  14. package/dist/capture.js +100 -5
  15. package/dist/cli.d.ts +9 -1
  16. package/dist/cli.js +531 -119
  17. package/dist/client.d.ts +4 -21
  18. package/dist/client.js +3 -112
  19. package/dist/config.d.ts +20 -0
  20. package/dist/config.js +37 -1
  21. package/dist/consolidate.d.ts +9 -0
  22. package/dist/consolidate.js +133 -46
  23. package/dist/dag.d.ts +1 -0
  24. package/dist/dag.js +10 -5
  25. package/dist/dashboard.js +7 -3
  26. package/dist/db.js +69 -24
  27. package/dist/dedupe.d.ts +1 -0
  28. package/dist/dedupe.js +4 -7
  29. package/dist/doctor.d.ts +34 -0
  30. package/dist/doctor.js +174 -0
  31. package/dist/dormant.d.ts +91 -0
  32. package/dist/dormant.js +121 -0
  33. package/dist/embedding-provider.js +2 -1
  34. package/dist/embeddings.js +81 -8
  35. package/dist/eval-stats.d.ts +123 -0
  36. package/dist/eval-stats.js +187 -0
  37. package/dist/extract.d.ts +2 -0
  38. package/dist/extract.js +9 -4
  39. package/dist/half-life-migration.d.ts +55 -0
  40. package/dist/half-life-migration.js +111 -0
  41. package/dist/hooks.d.ts +4 -0
  42. package/dist/hooks.js +47 -0
  43. package/dist/mcp/server.d.ts +6 -0
  44. package/dist/mcp/server.js +90 -48
  45. package/dist/memory.d.ts +18 -1
  46. package/dist/memory.js +33 -5
  47. package/dist/physics-config.js +5 -1
  48. package/dist/recall-scope.d.ts +24 -0
  49. package/dist/recall-scope.js +41 -0
  50. package/dist/refine-llm.js +3 -2
  51. package/dist/reject-flow.d.ts +3 -3
  52. package/dist/reject-flow.js +10 -3
  53. package/dist/rerankers/jev.js +3 -2
  54. package/dist/rerankers/llm.js +3 -2
  55. package/dist/search.d.ts +4 -4
  56. package/dist/search.js +26 -21
  57. package/dist/server.d.ts +5 -0
  58. package/dist/server.js +49 -29
  59. package/dist/shared.js +1 -1
  60. package/dist/store.d.ts +29 -11
  61. package/dist/store.js +197 -70
  62. package/dist/token-ledger.d.ts +119 -0
  63. package/dist/token-ledger.js +181 -0
  64. package/dist/version.d.ts +4 -6
  65. package/dist/version.js +4 -7
  66. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  67. package/extensions/openclaw-plugin/package.json +1 -1
  68. package/openclaw.plugin.json +1 -1
  69. package/package.json +7 -3
  70. package/dist/ablation.d.ts.map +0 -1
  71. package/dist/ablation.js.map +0 -1
  72. package/dist/ambient.d.ts.map +0 -1
  73. package/dist/ambient.js.map +0 -1
  74. package/dist/api.d.ts.map +0 -1
  75. package/dist/api.js.map +0 -1
  76. package/dist/audit-prune.d.ts.map +0 -1
  77. package/dist/audit-prune.js.map +0 -1
  78. package/dist/audit.d.ts.map +0 -1
  79. package/dist/audit.js.map +0 -1
  80. package/dist/auth.d.ts.map +0 -1
  81. package/dist/auth.js.map +0 -1
  82. package/dist/autolearn.d.ts.map +0 -1
  83. package/dist/autolearn.js.map +0 -1
  84. package/dist/availability.d.ts.map +0 -1
  85. package/dist/availability.js.map +0 -1
  86. package/dist/benchmarks/e1.3/incident-recall-eval.js +0 -78
  87. package/dist/benchmarks/e1.3/incident-recall-eval.js.map +0 -1
  88. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +0 -103
  89. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +0 -1
  90. package/dist/capture.d.ts.map +0 -1
  91. package/dist/capture.js.map +0 -1
  92. package/dist/card-detail.d.ts.map +0 -1
  93. package/dist/card-detail.js.map +0 -1
  94. package/dist/card.d.ts.map +0 -1
  95. package/dist/card.js.map +0 -1
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js.map +0 -1
  98. package/dist/client.d.ts.map +0 -1
  99. package/dist/client.js.map +0 -1
  100. package/dist/compare.d.ts.map +0 -1
  101. package/dist/compare.js.map +0 -1
  102. package/dist/config.d.ts.map +0 -1
  103. package/dist/config.js.map +0 -1
  104. package/dist/connectors/github/backfill.d.ts.map +0 -1
  105. package/dist/connectors/github/backfill.js.map +0 -1
  106. package/dist/connectors/github/cli-impl.d.ts.map +0 -1
  107. package/dist/connectors/github/cli-impl.js.map +0 -1
  108. package/dist/connectors/github/deletion.d.ts.map +0 -1
  109. package/dist/connectors/github/deletion.js.map +0 -1
  110. package/dist/connectors/github/dlq.d.ts.map +0 -1
  111. package/dist/connectors/github/dlq.js.map +0 -1
  112. package/dist/connectors/github/idempotency.d.ts.map +0 -1
  113. package/dist/connectors/github/idempotency.js.map +0 -1
  114. package/dist/connectors/github/ingest.d.ts.map +0 -1
  115. package/dist/connectors/github/ingest.js.map +0 -1
  116. package/dist/connectors/github/octokit-client.d.ts.map +0 -1
  117. package/dist/connectors/github/octokit-client.js.map +0 -1
  118. package/dist/connectors/github/ratelimit.d.ts.map +0 -1
  119. package/dist/connectors/github/ratelimit.js.map +0 -1
  120. package/dist/connectors/github/scope.d.ts.map +0 -1
  121. package/dist/connectors/github/scope.js.map +0 -1
  122. package/dist/connectors/github/signature.d.ts.map +0 -1
  123. package/dist/connectors/github/signature.js.map +0 -1
  124. package/dist/connectors/github/tenant-routing.d.ts.map +0 -1
  125. package/dist/connectors/github/tenant-routing.js.map +0 -1
  126. package/dist/connectors/github/transform.d.ts.map +0 -1
  127. package/dist/connectors/github/transform.js.map +0 -1
  128. package/dist/connectors/github/types.d.ts.map +0 -1
  129. package/dist/connectors/github/types.js.map +0 -1
  130. package/dist/connectors/slack/backfill.d.ts.map +0 -1
  131. package/dist/connectors/slack/backfill.js.map +0 -1
  132. package/dist/connectors/slack/deletion.d.ts.map +0 -1
  133. package/dist/connectors/slack/deletion.js.map +0 -1
  134. package/dist/connectors/slack/dlq.d.ts.map +0 -1
  135. package/dist/connectors/slack/dlq.js.map +0 -1
  136. package/dist/connectors/slack/idempotency.d.ts.map +0 -1
  137. package/dist/connectors/slack/idempotency.js.map +0 -1
  138. package/dist/connectors/slack/ingest.d.ts.map +0 -1
  139. package/dist/connectors/slack/ingest.js.map +0 -1
  140. package/dist/connectors/slack/ratelimit.d.ts.map +0 -1
  141. package/dist/connectors/slack/ratelimit.js.map +0 -1
  142. package/dist/connectors/slack/scope.d.ts.map +0 -1
  143. package/dist/connectors/slack/scope.js.map +0 -1
  144. package/dist/connectors/slack/signature.d.ts.map +0 -1
  145. package/dist/connectors/slack/signature.js.map +0 -1
  146. package/dist/connectors/slack/tenant-routing.d.ts.map +0 -1
  147. package/dist/connectors/slack/tenant-routing.js.map +0 -1
  148. package/dist/connectors/slack/transform.d.ts.map +0 -1
  149. package/dist/connectors/slack/transform.js.map +0 -1
  150. package/dist/connectors/slack/types.d.ts.map +0 -1
  151. package/dist/connectors/slack/types.js.map +0 -1
  152. package/dist/connectors/slack/web-client.d.ts.map +0 -1
  153. package/dist/connectors/slack/web-client.js.map +0 -1
  154. package/dist/connectors/slack/workspaces.d.ts.map +0 -1
  155. package/dist/connectors/slack/workspaces.js.map +0 -1
  156. package/dist/consolidate.d.ts.map +0 -1
  157. package/dist/consolidate.js.map +0 -1
  158. package/dist/correction-latency.d.ts.map +0 -1
  159. package/dist/correction-latency.js.map +0 -1
  160. package/dist/customer-notes.d.ts.map +0 -1
  161. package/dist/customer-notes.js.map +0 -1
  162. package/dist/dag.d.ts.map +0 -1
  163. package/dist/dag.js.map +0 -1
  164. package/dist/dashboard.d.ts.map +0 -1
  165. package/dist/dashboard.js.map +0 -1
  166. package/dist/db.d.ts.map +0 -1
  167. package/dist/db.js.map +0 -1
  168. package/dist/decisions.d.ts.map +0 -1
  169. package/dist/decisions.js.map +0 -1
  170. package/dist/dedupe.d.ts.map +0 -1
  171. package/dist/dedupe.js.map +0 -1
  172. package/dist/embedding-provider.d.ts.map +0 -1
  173. package/dist/embedding-provider.js.map +0 -1
  174. package/dist/embeddings.d.ts.map +0 -1
  175. package/dist/embeddings.js.map +0 -1
  176. package/dist/eval-suite.d.ts.map +0 -1
  177. package/dist/eval-suite.js.map +0 -1
  178. package/dist/eval.d.ts.map +0 -1
  179. package/dist/eval.js.map +0 -1
  180. package/dist/extensions/openclaw-plugin/index.js.map +0 -1
  181. package/dist/extract.d.ts.map +0 -1
  182. package/dist/extract.js.map +0 -1
  183. package/dist/forward-claim-detector.d.ts.map +0 -1
  184. package/dist/forward-claim-detector.js.map +0 -1
  185. package/dist/goals.d.ts.map +0 -1
  186. package/dist/goals.js.map +0 -1
  187. package/dist/graph-extract.d.ts.map +0 -1
  188. package/dist/graph-extract.js.map +0 -1
  189. package/dist/graph-recall.d.ts.map +0 -1
  190. package/dist/graph-recall.js.map +0 -1
  191. package/dist/graph-stream.d.ts.map +0 -1
  192. package/dist/graph-stream.js.map +0 -1
  193. package/dist/graph-view.d.ts.map +0 -1
  194. package/dist/graph-view.js.map +0 -1
  195. package/dist/graph.d.ts.map +0 -1
  196. package/dist/graph.js.map +0 -1
  197. package/dist/handoff.d.ts.map +0 -1
  198. package/dist/handoff.js.map +0 -1
  199. package/dist/hooks.d.ts.map +0 -1
  200. package/dist/hooks.js.map +0 -1
  201. package/dist/importers.d.ts.map +0 -1
  202. package/dist/importers.js.map +0 -1
  203. package/dist/incidents.d.ts.map +0 -1
  204. package/dist/incidents.js.map +0 -1
  205. package/dist/index.d.ts.map +0 -1
  206. package/dist/index.js.map +0 -1
  207. package/dist/invalidation.d.ts.map +0 -1
  208. package/dist/invalidation.js.map +0 -1
  209. package/dist/mcp/framing.d.ts.map +0 -1
  210. package/dist/mcp/framing.js.map +0 -1
  211. package/dist/mcp/server.d.ts.map +0 -1
  212. package/dist/mcp/server.js.map +0 -1
  213. package/dist/memory-value-weights.d.ts.map +0 -1
  214. package/dist/memory-value-weights.js.map +0 -1
  215. package/dist/memory-value.d.ts.map +0 -1
  216. package/dist/memory-value.js.map +0 -1
  217. package/dist/memory.d.ts.map +0 -1
  218. package/dist/memory.js.map +0 -1
  219. package/dist/multihop.d.ts.map +0 -1
  220. package/dist/multihop.js.map +0 -1
  221. package/dist/owner-validation.d.ts.map +0 -1
  222. package/dist/owner-validation.js.map +0 -1
  223. package/dist/path-context.d.ts.map +0 -1
  224. package/dist/path-context.js.map +0 -1
  225. package/dist/physics-config.d.ts.map +0 -1
  226. package/dist/physics-config.js.map +0 -1
  227. package/dist/physics-state.d.ts.map +0 -1
  228. package/dist/physics-state.js.map +0 -1
  229. package/dist/physics.d.ts.map +0 -1
  230. package/dist/physics.js.map +0 -1
  231. package/dist/policies.d.ts.map +0 -1
  232. package/dist/policies.js.map +0 -1
  233. package/dist/postinstall.d.ts.map +0 -1
  234. package/dist/postinstall.js.map +0 -1
  235. package/dist/predictions.d.ts.map +0 -1
  236. package/dist/predictions.js.map +0 -1
  237. package/dist/processes.d.ts.map +0 -1
  238. package/dist/processes.js.map +0 -1
  239. package/dist/project-briefs.d.ts.map +0 -1
  240. package/dist/project-briefs.js.map +0 -1
  241. package/dist/project-identity.d.ts.map +0 -1
  242. package/dist/project-identity.js.map +0 -1
  243. package/dist/provenance-coverage.d.ts.map +0 -1
  244. package/dist/provenance-coverage.js.map +0 -1
  245. package/dist/rate-limit.d.ts.map +0 -1
  246. package/dist/rate-limit.js.map +0 -1
  247. package/dist/raw-archive-mirror-cleanup.d.ts.map +0 -1
  248. package/dist/raw-archive-mirror-cleanup.js.map +0 -1
  249. package/dist/raw-archive.d.ts.map +0 -1
  250. package/dist/raw-archive.js.map +0 -1
  251. package/dist/recall-history.d.ts.map +0 -1
  252. package/dist/recall-history.js.map +0 -1
  253. package/dist/recall-scope.d.ts.map +0 -1
  254. package/dist/recall-scope.js.map +0 -1
  255. package/dist/recall-trace.d.ts.map +0 -1
  256. package/dist/recall-trace.js.map +0 -1
  257. package/dist/refine-llm.d.ts.map +0 -1
  258. package/dist/refine-llm.js.map +0 -1
  259. package/dist/reject-flow.d.ts.map +0 -1
  260. package/dist/reject-flow.js.map +0 -1
  261. package/dist/rejection.d.ts.map +0 -1
  262. package/dist/rejection.js.map +0 -1
  263. package/dist/replay.d.ts.map +0 -1
  264. package/dist/replay.js.map +0 -1
  265. package/dist/rerankers/cross-encoder.d.ts.map +0 -1
  266. package/dist/rerankers/cross-encoder.js.map +0 -1
  267. package/dist/rerankers/index.d.ts.map +0 -1
  268. package/dist/rerankers/index.js.map +0 -1
  269. package/dist/rerankers/jev.d.ts.map +0 -1
  270. package/dist/rerankers/jev.js.map +0 -1
  271. package/dist/rerankers/llm.d.ts.map +0 -1
  272. package/dist/rerankers/llm.js.map +0 -1
  273. package/dist/rerankers/types.d.ts.map +0 -1
  274. package/dist/rerankers/types.js.map +0 -1
  275. package/dist/rrf.d.ts.map +0 -1
  276. package/dist/rrf.js.map +0 -1
  277. package/dist/salience.d.ts.map +0 -1
  278. package/dist/salience.js.map +0 -1
  279. package/dist/scheduler.d.ts.map +0 -1
  280. package/dist/scheduler.js.map +0 -1
  281. package/dist/scope.d.ts.map +0 -1
  282. package/dist/scope.js.map +0 -1
  283. package/dist/search.d.ts.map +0 -1
  284. package/dist/search.js.map +0 -1
  285. package/dist/secret-detect.d.ts.map +0 -1
  286. package/dist/secret-detect.js.map +0 -1
  287. package/dist/server-detect.d.ts.map +0 -1
  288. package/dist/server-detect.js.map +0 -1
  289. package/dist/server.d.ts.map +0 -1
  290. package/dist/server.js.map +0 -1
  291. package/dist/shared.d.ts.map +0 -1
  292. package/dist/shared.js.map +0 -1
  293. package/dist/skills.d.ts.map +0 -1
  294. package/dist/skills.js.map +0 -1
  295. package/dist/sleep-redact.d.ts +0 -58
  296. package/dist/sleep-redact.d.ts.map +0 -1
  297. package/dist/sleep-redact.js +0 -80
  298. package/dist/sleep-redact.js.map +0 -1
  299. package/dist/src/ablation.js +0 -138
  300. package/dist/src/ablation.js.map +0 -1
  301. package/dist/src/ambient.js +0 -148
  302. package/dist/src/ambient.js.map +0 -1
  303. package/dist/src/api.js +0 -2109
  304. package/dist/src/api.js.map +0 -1
  305. package/dist/src/audit-prune.js +0 -109
  306. package/dist/src/audit-prune.js.map +0 -1
  307. package/dist/src/audit.js +0 -213
  308. package/dist/src/audit.js.map +0 -1
  309. package/dist/src/auth.js +0 -97
  310. package/dist/src/auth.js.map +0 -1
  311. package/dist/src/autolearn.js +0 -174
  312. package/dist/src/autolearn.js.map +0 -1
  313. package/dist/src/availability.js +0 -94
  314. package/dist/src/availability.js.map +0 -1
  315. package/dist/src/capture.js +0 -1346
  316. package/dist/src/capture.js.map +0 -1
  317. package/dist/src/card-detail.js +0 -15
  318. package/dist/src/card-detail.js.map +0 -1
  319. package/dist/src/card.js +0 -19
  320. package/dist/src/card.js.map +0 -1
  321. package/dist/src/cli.js +0 -9387
  322. package/dist/src/cli.js.map +0 -1
  323. package/dist/src/client.js +0 -237
  324. package/dist/src/client.js.map +0 -1
  325. package/dist/src/compare.js +0 -122
  326. package/dist/src/compare.js.map +0 -1
  327. package/dist/src/config.js +0 -135
  328. package/dist/src/config.js.map +0 -1
  329. package/dist/src/connectors/github/backfill.js +0 -281
  330. package/dist/src/connectors/github/backfill.js.map +0 -1
  331. package/dist/src/connectors/github/cli-impl.js +0 -234
  332. package/dist/src/connectors/github/cli-impl.js.map +0 -1
  333. package/dist/src/connectors/github/deletion.js +0 -85
  334. package/dist/src/connectors/github/deletion.js.map +0 -1
  335. package/dist/src/connectors/github/dlq.js +0 -190
  336. package/dist/src/connectors/github/dlq.js.map +0 -1
  337. package/dist/src/connectors/github/idempotency.js +0 -27
  338. package/dist/src/connectors/github/idempotency.js.map +0 -1
  339. package/dist/src/connectors/github/ingest.js +0 -156
  340. package/dist/src/connectors/github/ingest.js.map +0 -1
  341. package/dist/src/connectors/github/octokit-client.js +0 -70
  342. package/dist/src/connectors/github/octokit-client.js.map +0 -1
  343. package/dist/src/connectors/github/ratelimit.js +0 -31
  344. package/dist/src/connectors/github/ratelimit.js.map +0 -1
  345. package/dist/src/connectors/github/scope.js +0 -13
  346. package/dist/src/connectors/github/scope.js.map +0 -1
  347. package/dist/src/connectors/github/signature.js +0 -81
  348. package/dist/src/connectors/github/signature.js.map +0 -1
  349. package/dist/src/connectors/github/tenant-routing.js +0 -69
  350. package/dist/src/connectors/github/tenant-routing.js.map +0 -1
  351. package/dist/src/connectors/github/transform.js +0 -103
  352. package/dist/src/connectors/github/transform.js.map +0 -1
  353. package/dist/src/connectors/github/types.js +0 -100
  354. package/dist/src/connectors/github/types.js.map +0 -1
  355. package/dist/src/connectors/slack/backfill.js +0 -78
  356. package/dist/src/connectors/slack/backfill.js.map +0 -1
  357. package/dist/src/connectors/slack/deletion.js +0 -59
  358. package/dist/src/connectors/slack/deletion.js.map +0 -1
  359. package/dist/src/connectors/slack/dlq.js +0 -224
  360. package/dist/src/connectors/slack/dlq.js.map +0 -1
  361. package/dist/src/connectors/slack/idempotency.js +0 -31
  362. package/dist/src/connectors/slack/idempotency.js.map +0 -1
  363. package/dist/src/connectors/slack/ingest.js +0 -109
  364. package/dist/src/connectors/slack/ingest.js.map +0 -1
  365. package/dist/src/connectors/slack/ratelimit.js +0 -18
  366. package/dist/src/connectors/slack/ratelimit.js.map +0 -1
  367. package/dist/src/connectors/slack/scope.js +0 -13
  368. package/dist/src/connectors/slack/scope.js.map +0 -1
  369. package/dist/src/connectors/slack/signature.js +0 -27
  370. package/dist/src/connectors/slack/signature.js.map +0 -1
  371. package/dist/src/connectors/slack/tenant-routing.js +0 -41
  372. package/dist/src/connectors/slack/tenant-routing.js.map +0 -1
  373. package/dist/src/connectors/slack/transform.js +0 -47
  374. package/dist/src/connectors/slack/transform.js.map +0 -1
  375. package/dist/src/connectors/slack/types.js +0 -35
  376. package/dist/src/connectors/slack/types.js.map +0 -1
  377. package/dist/src/connectors/slack/web-client.js +0 -43
  378. package/dist/src/connectors/slack/web-client.js.map +0 -1
  379. package/dist/src/connectors/slack/workspaces.js +0 -62
  380. package/dist/src/connectors/slack/workspaces.js.map +0 -1
  381. package/dist/src/consolidate.js +0 -978
  382. package/dist/src/consolidate.js.map +0 -1
  383. package/dist/src/correction-latency.js +0 -74
  384. package/dist/src/correction-latency.js.map +0 -1
  385. package/dist/src/customer-notes.js +0 -325
  386. package/dist/src/customer-notes.js.map +0 -1
  387. package/dist/src/dag.js +0 -352
  388. package/dist/src/dag.js.map +0 -1
  389. package/dist/src/dashboard.js +0 -258
  390. package/dist/src/dashboard.js.map +0 -1
  391. package/dist/src/db.js +0 -2731
  392. package/dist/src/db.js.map +0 -1
  393. package/dist/src/decisions.js +0 -304
  394. package/dist/src/decisions.js.map +0 -1
  395. package/dist/src/dedupe.js +0 -141
  396. package/dist/src/dedupe.js.map +0 -1
  397. package/dist/src/embedding-provider.js +0 -314
  398. package/dist/src/embedding-provider.js.map +0 -1
  399. package/dist/src/embeddings.js +0 -543
  400. package/dist/src/embeddings.js.map +0 -1
  401. package/dist/src/eval-suite.js +0 -294
  402. package/dist/src/eval-suite.js.map +0 -1
  403. package/dist/src/eval.js +0 -187
  404. package/dist/src/eval.js.map +0 -1
  405. package/dist/src/extract.js +0 -117
  406. package/dist/src/extract.js.map +0 -1
  407. package/dist/src/forward-claim-detector.js +0 -117
  408. package/dist/src/forward-claim-detector.js.map +0 -1
  409. package/dist/src/goals.js +0 -399
  410. package/dist/src/goals.js.map +0 -1
  411. package/dist/src/graph-extract.js +0 -314
  412. package/dist/src/graph-extract.js.map +0 -1
  413. package/dist/src/graph-recall.js +0 -277
  414. package/dist/src/graph-recall.js.map +0 -1
  415. package/dist/src/graph-stream.js +0 -176
  416. package/dist/src/graph-stream.js.map +0 -1
  417. package/dist/src/graph-view.js +0 -310
  418. package/dist/src/graph-view.js.map +0 -1
  419. package/dist/src/graph.js +0 -762
  420. package/dist/src/graph.js.map +0 -1
  421. package/dist/src/handoff.js +0 -65
  422. package/dist/src/handoff.js.map +0 -1
  423. package/dist/src/hooks.js +0 -926
  424. package/dist/src/hooks.js.map +0 -1
  425. package/dist/src/importers.js +0 -881
  426. package/dist/src/importers.js.map +0 -1
  427. package/dist/src/incidents.js +0 -336
  428. package/dist/src/incidents.js.map +0 -1
  429. package/dist/src/index.js +0 -32
  430. package/dist/src/index.js.map +0 -1
  431. package/dist/src/invalidation.js +0 -129
  432. package/dist/src/invalidation.js.map +0 -1
  433. package/dist/src/mcp/framing.js +0 -45
  434. package/dist/src/mcp/framing.js.map +0 -1
  435. package/dist/src/mcp/server.js +0 -1298
  436. package/dist/src/mcp/server.js.map +0 -1
  437. package/dist/src/memory-value-weights.js +0 -31
  438. package/dist/src/memory-value-weights.js.map +0 -1
  439. package/dist/src/memory-value.js +0 -261
  440. package/dist/src/memory-value.js.map +0 -1
  441. package/dist/src/memory.js +0 -425
  442. package/dist/src/memory.js.map +0 -1
  443. package/dist/src/multihop.js +0 -35
  444. package/dist/src/multihop.js.map +0 -1
  445. package/dist/src/owner-validation.js +0 -56
  446. package/dist/src/owner-validation.js.map +0 -1
  447. package/dist/src/path-context.js +0 -48
  448. package/dist/src/path-context.js.map +0 -1
  449. package/dist/src/physics-config.js +0 -26
  450. package/dist/src/physics-config.js.map +0 -1
  451. package/dist/src/physics-state.js +0 -172
  452. package/dist/src/physics-state.js.map +0 -1
  453. package/dist/src/physics.js +0 -378
  454. package/dist/src/physics.js.map +0 -1
  455. package/dist/src/policies.js +0 -414
  456. package/dist/src/policies.js.map +0 -1
  457. package/dist/src/postinstall.js +0 -101
  458. package/dist/src/postinstall.js.map +0 -1
  459. package/dist/src/predictions.js +0 -629
  460. package/dist/src/predictions.js.map +0 -1
  461. package/dist/src/processes.js +0 -349
  462. package/dist/src/processes.js.map +0 -1
  463. package/dist/src/project-briefs.js +0 -492
  464. package/dist/src/project-briefs.js.map +0 -1
  465. package/dist/src/project-identity.js +0 -200
  466. package/dist/src/project-identity.js.map +0 -1
  467. package/dist/src/provenance-coverage.js +0 -23
  468. package/dist/src/provenance-coverage.js.map +0 -1
  469. package/dist/src/rate-limit.js +0 -60
  470. package/dist/src/rate-limit.js.map +0 -1
  471. package/dist/src/raw-archive-mirror-cleanup.js +0 -55
  472. package/dist/src/raw-archive-mirror-cleanup.js.map +0 -1
  473. package/dist/src/raw-archive.js +0 -91
  474. package/dist/src/raw-archive.js.map +0 -1
  475. package/dist/src/recall-history.js +0 -235
  476. package/dist/src/recall-history.js.map +0 -1
  477. package/dist/src/recall-scope.js +0 -89
  478. package/dist/src/recall-scope.js.map +0 -1
  479. package/dist/src/recall-trace.js +0 -186
  480. package/dist/src/recall-trace.js.map +0 -1
  481. package/dist/src/refine-llm.js +0 -156
  482. package/dist/src/refine-llm.js.map +0 -1
  483. package/dist/src/reject-flow.js +0 -209
  484. package/dist/src/reject-flow.js.map +0 -1
  485. package/dist/src/rejection.js +0 -160
  486. package/dist/src/rejection.js.map +0 -1
  487. package/dist/src/replay.js +0 -122
  488. package/dist/src/replay.js.map +0 -1
  489. package/dist/src/rerankers/cross-encoder.js +0 -137
  490. package/dist/src/rerankers/cross-encoder.js.map +0 -1
  491. package/dist/src/rerankers/index.js +0 -20
  492. package/dist/src/rerankers/index.js.map +0 -1
  493. package/dist/src/rerankers/jev.js +0 -119
  494. package/dist/src/rerankers/jev.js.map +0 -1
  495. package/dist/src/rerankers/llm.js +0 -80
  496. package/dist/src/rerankers/llm.js.map +0 -1
  497. package/dist/src/rerankers/types.js +0 -2
  498. package/dist/src/rerankers/types.js.map +0 -1
  499. package/dist/src/rrf.js +0 -61
  500. package/dist/src/rrf.js.map +0 -1
  501. package/dist/src/salience.js +0 -74
  502. package/dist/src/salience.js.map +0 -1
  503. package/dist/src/scheduler.js +0 -77
  504. package/dist/src/scheduler.js.map +0 -1
  505. package/dist/src/scope.js +0 -35
  506. package/dist/src/scope.js.map +0 -1
  507. package/dist/src/search.js +0 -1003
  508. package/dist/src/search.js.map +0 -1
  509. package/dist/src/secret-detect.js +0 -95
  510. package/dist/src/secret-detect.js.map +0 -1
  511. package/dist/src/server-detect.js +0 -231
  512. package/dist/src/server-detect.js.map +0 -1
  513. package/dist/src/server.js +0 -3255
  514. package/dist/src/server.js.map +0 -1
  515. package/dist/src/shared.js +0 -502
  516. package/dist/src/shared.js.map +0 -1
  517. package/dist/src/skills.js +0 -346
  518. package/dist/src/skills.js.map +0 -1
  519. package/dist/src/sleep-redact.js +0 -80
  520. package/dist/src/sleep-redact.js.map +0 -1
  521. package/dist/src/sso.js +0 -22
  522. package/dist/src/sso.js.map +0 -1
  523. package/dist/src/stdin.js +0 -41
  524. package/dist/src/stdin.js.map +0 -1
  525. package/dist/src/store.js +0 -3749
  526. package/dist/src/store.js.map +0 -1
  527. package/dist/src/tenant.js +0 -17
  528. package/dist/src/tenant.js.map +0 -1
  529. package/dist/src/trace.js +0 -72
  530. package/dist/src/trace.js.map +0 -1
  531. package/dist/src/version.js +0 -40
  532. package/dist/src/version.js.map +0 -1
  533. package/dist/src/working-memory.js +0 -157
  534. package/dist/src/working-memory.js.map +0 -1
  535. package/dist/src/yaml.js +0 -107
  536. package/dist/src/yaml.js.map +0 -1
  537. package/dist/sso.d.ts +0 -13
  538. package/dist/sso.d.ts.map +0 -1
  539. package/dist/sso.js +0 -22
  540. package/dist/sso.js.map +0 -1
  541. package/dist/stdin.d.ts.map +0 -1
  542. package/dist/stdin.js.map +0 -1
  543. package/dist/store.d.ts.map +0 -1
  544. package/dist/store.js.map +0 -1
  545. package/dist/tenant.d.ts.map +0 -1
  546. package/dist/tenant.js.map +0 -1
  547. package/dist/trace.d.ts.map +0 -1
  548. package/dist/trace.js.map +0 -1
  549. package/dist/version.d.ts.map +0 -1
  550. package/dist/version.js.map +0 -1
  551. package/dist/working-memory.d.ts.map +0 -1
  552. package/dist/working-memory.js.map +0 -1
  553. package/dist/yaml.d.ts.map +0 -1
  554. package/dist/yaml.js.map +0 -1
@@ -1,3255 +0,0 @@
1
- import { createServer } from 'node:http';
2
- import { createHash } from 'node:crypto';
3
- import { dirname, resolve } from 'node:path';
4
- import { resolveProjectIdentity } from './project-identity.js';
5
- import { detectServer, writePidfile, removePidfileIfOwned } from './server-detect.js';
6
- import { resolveTenantId } from './tenant.js';
7
- import { openHippoDb, closeHippoDb } from './db.js';
8
- import { updateStats } from './store.js';
9
- import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, } from './recall-history.js';
10
- import { appendAuditEvent } from './audit.js';
11
- // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
12
- // for the HTTP pipeline. Separate from CLI/MCP rings per plan v3 (per-
13
- // pipeline rings; no IPC). HTTP is the only caller that threads its
14
- // snapshot through opts.recallHistory to api.recall — api.recall's
15
- // anchoringHint on the returned RecallResult IS the user-visible hint
16
- // here (no separate compute needed).
17
- const sessionRecallHistoryHttp = new Map();
18
- /** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
19
- export function __resetSessionRecallHistoryHttp() {
20
- sessionRecallHistoryHttp.clear();
21
- }
22
- import { PACKAGE_VERSION } from './version.js';
23
- import { validateApiKey } from './auth.js';
24
- import { createRateLimiter } from './rate-limit.js';
25
- import { remember, recall, RecallContractError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, } from './api.js';
26
- import { buildGraphModel } from './graph-view.js';
27
- import { MAX_ENTITY_NAME_LEN } from './graph.js';
28
- import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
29
- import { saveDecision, closeDecision, loadDecisionById, loadDecisions, VALID_DECISION_STATES, } from './decisions.js';
30
- import { saveIncident, resolveIncident, closeIncident, loadIncidentById, loadIncidents, VALID_INCIDENT_STATES, } from './incidents.js';
31
- import { saveProcess, closeProcess, loadProcessById, loadProcesses, VALID_PROCESS_STATES, } from './processes.js';
32
- import { savePolicy, closePolicy, loadPolicyById, loadPolicies, loadPoliciesAsOf, VALID_POLICY_STATES, } from './policies.js';
33
- import { saveSkill, closeSkill, loadSkillById, loadSkills, exportSkills, VALID_SKILL_STATES, } from './skills.js';
34
- import { saveProjectBrief, closeProjectBrief, loadProjectBriefById, loadProjectBriefs, assembleBriefFromReceipts, refreshBrief, VALID_BRIEF_STATES, } from './project-briefs.js';
35
- import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomerNotes, VALID_NOTE_STATES, } from './customer-notes.js';
36
- import { handleMcpRequest } from './mcp/server.js';
37
- import { verifySlackSignature } from './connectors/slack/signature.js';
38
- import { isSlackEventEnvelope, isSlackMessageEvent } from './connectors/slack/types.js';
39
- import { ingestMessage } from './connectors/slack/ingest.js';
40
- import { handleMessageDeleted } from './connectors/slack/deletion.js';
41
- import { writeToDlq } from './connectors/slack/dlq.js';
42
- import { resolveTenantForTeam } from './connectors/slack/tenant-routing.js';
43
- import { verifyGitHubSignature } from './connectors/github/signature.js';
44
- import { isGitHubWebhookEnvelope, isGitHubIssueEvent, isGitHubIssueCommentEvent, isGitHubPullRequestEvent, isGitHubPullRequestReviewCommentEvent, } from './connectors/github/types.js';
45
- import { ingestEvent as ingestGitHubEvent } from './connectors/github/ingest.js';
46
- import { handleCommentDeleted as handleGitHubCommentDeleted } from './connectors/github/deletion.js';
47
- import { writeToDlq as writeToGitHubDlq } from './connectors/github/dlq.js';
48
- import { resolveTenantForGitHub } from './connectors/github/tenant-routing.js';
49
- import { computeDeletionKey as computeGitHubDeletionKey } from './connectors/github/signature.js';
50
- // Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
51
- // New unauth routes MUST be added here AND get a corresponding entry in
52
- // tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
53
- // `path.startsWith` — pattern-positional auth is bypass-by-accident.
54
- //
55
- // The route handlers consult `isPublicRoute` before invoking
56
- // `buildContextWithAuth` / `requireAuth`. Adding a route here without
57
- // adding the corresponding `isPublicRoute` short-circuit in a handler is
58
- // a no-op (auth still applies), so the failure mode is fail-closed.
59
- const PUBLIC_ROUTES = new Set([
60
- 'POST /v1/connectors/slack/events',
61
- 'POST /v1/connectors/github/events',
62
- ]);
63
- function isPublicRoute(method, path) {
64
- return PUBLIC_ROUTES.has(`${method} ${path}`);
65
- }
66
- const VALID_AUDIT_OPS = new Set([
67
- 'remember',
68
- 'recall',
69
- 'promote',
70
- 'supersede',
71
- 'forget',
72
- 'archive_raw',
73
- 'auth_revoke',
74
- 'auth_create', // v1.12.4: emitted by api.authCreate
75
- 'outcome', // v1.11.5: pre-existing drift — emitted today but rejected by old Set
76
- 'consolidate', // v1.11.5: emitted by api.sleep / POST /v1/sleep
77
- 'audit_prune', // v1.12.9: emitted by pruneAuditLog
78
- 'summary_marked_dirty', // v0.30 / E1 — lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS (v1.11.5 CRIT A institutional rule)
79
- 'summary_marked_clean', // v0.30 / E3 — buildDag post-link clean op; lockstep
80
- 'summary_rebuilt', // v0.30 / E3 — sleep-cycle rebuild op; lockstep
81
- 'predict_create', // v0.31 / E2 prediction first-class object — emitted by savePrediction
82
- 'predict_close', // v0.31 / E2 — emitted by closePrediction
83
- 'predict_baserate', // v0.31 / J3 — emitted by computePredictionBaserate
84
- 'recall_autodebias_hint', // v0.32 / J3.2 — emitted by computePlanningFallacyHint on success
85
- 'recall_autodebias_hint_no_class_match', // v0.32 / J3.2 — telemetry: forward-claim, no class scored
86
- 'recall_autodebias_hint_tiebreak', // v0.32 / J3.2 — telemetry: forward-claim, >=2 classes tied
87
- 'recall_anchor_detected_query_repeat', // v0.33 / J1 — emitted by detector on R1 fire
88
- 'recall_anchor_detected_memory_dominance', // v0.33 / J1 — emitted by detector on R2 fire
89
- 'recall_anchor_skipped_no_session', // v0.33 / J1 — telemetry: no sessionId, ring skipped
90
- 'recall_availability_detected', // v1.13.x / J2 - emitted when availability/recency-bias hint fires
91
- 'decision_create', // E2 decision first-class object — emitted by saveDecision
92
- 'decision_supersede', // E2 — emitted by saveDecision when --supersedes resolves to an active decision row
93
- 'decision_close', // E2 — emitted by closeDecision
94
- 'incident_open', // E2 incident first-class object — emitted by saveIncident
95
- 'incident_resolve', // E2 — emitted by resolveIncident (open -> resolved)
96
- 'incident_close', // E2 — emitted by closeIncident (open|resolved -> closed)
97
- 'process_create', // E2 process first-class object — emitted by saveProcess
98
- 'process_supersede', // E2 — emitted by saveProcess on a supersession
99
- 'process_close', // E2 — emitted by closeProcess
100
- 'policy_create', // E2 policy first-class object — emitted by savePolicy
101
- 'policy_supersede', // E2 — emitted by savePolicy on a supersession
102
- 'policy_close', // E2 — emitted by closePolicy
103
- 'skill_create', // E2 skill first-class object — emitted by saveSkill
104
- 'skill_supersede', // E2 — emitted by saveSkill on a supersession
105
- 'skill_close', // E2 — emitted by closeSkill
106
- 'project_brief_create', // E2 project_brief first-class object — emitted by saveProjectBrief
107
- 'project_brief_supersede', // E2 — emitted by saveProjectBrief on a supersession (incl. refresh)
108
- 'project_brief_close', // E2 — emitted by closeProjectBrief
109
- 'customer_note_create', // E2 customer_note first-class object — emitted by saveCustomerNote
110
- 'customer_note_supersede', // E2 — emitted by saveCustomerNote on a supersession
111
- 'customer_note_close', // E2 — emitted by closeCustomerNote
112
- 'mv_rescue', // LC2-E3 — emitted by consolidate() per rescue; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
113
- 'reject_value', // AT1 — emitted by `hippo reject`; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
114
- 'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
115
- 'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
116
- 'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
117
- ]);
118
- // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
119
- // enough to dump a small deployment's full audit log without paginating, but
120
- // small enough that a malicious client can't ask for the world.
121
- const MAX_AUDIT_LIMIT = 10000;
122
- function isJsonString(value) {
123
- return typeof value === 'string';
124
- }
125
- function isJsonNumber(value) {
126
- return typeof value === 'number';
127
- }
128
- function isJsonBoolean(value) {
129
- return typeof value === 'boolean';
130
- }
131
- function isJsonObjectRecord(value) {
132
- return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
133
- }
134
- // node:http header values are `string | string[] | undefined` (never a bare
135
- // unknown), so this gets its own predicate rather than reusing isJsonString.
136
- function isHeaderString(value) {
137
- return typeof value === 'string';
138
- }
139
- // server.address() returns AddressInfo once a TCP socket is bound; null before
140
- // listening, a string only for pipe/unix-socket listeners (never used here).
141
- function isAddressInfo(a) {
142
- return a !== null && typeof a !== 'string';
143
- }
144
- // Runtime membership check for a `ReadonlySet<T>` of string-literal union
145
- // members, used at every `body` field validated against a VALID_* set below.
146
- // Set<T>.has(value: T) itself gives no narrowing (its parameter type is T,
147
- // not a type predicate) so callers previously needed a separate `as T` cast
148
- // at both the check and the later usage; this helper is the one place that
149
- // assertion lives, so downstream call sites narrow via the `value is T`
150
- // return instead of re-asserting.
151
- function isSetMember(set, value) {
152
- // SAFETY: `value as T` is discarded unless `set.has` (the real runtime
153
- // check) confirms membership; the `value is T` return type is what
154
- // performs the actual narrowing for callers.
155
- return set.has(value);
156
- }
157
- // HTTP-boundary validation for a process `steps` body (untrusted). Returns the
158
- // step strings (saveProcess re-validates + trims, this is the fail-fast 400
159
- // gate). Caps mirror src/processes.ts MAX_PROCESS_STEPS / MAX_PROCESS_STEP_LEN.
160
- function validateProcessStepsBody(raw) {
161
- if (raw === undefined || raw === null)
162
- return [];
163
- if (!Array.isArray(raw)) {
164
- throw new HttpError(400, 'steps must be an array of strings');
165
- }
166
- if (raw.length > 200) {
167
- throw new HttpError(400, 'steps exceeds 200-step cap');
168
- }
169
- for (const item of raw) {
170
- if (!isJsonString(item)) {
171
- throw new HttpError(400, 'each step must be a string');
172
- }
173
- if (item.trim().length === 0) {
174
- throw new HttpError(400, 'a step is empty');
175
- }
176
- if (item.length > 2000) {
177
- throw new HttpError(400, 'a step exceeds the 2000-character cap');
178
- }
179
- }
180
- // SAFETY: every item in raw was confirmed to be a string in the loop above.
181
- return raw;
182
- }
183
- // HTTP-boundary check for an optional policy date field (validFrom/validTo).
184
- // Type + length only; savePolicy/loadPoliciesAsOf normalize + format-validate the
185
- // value (an unparseable date throws there -> mapped to 400). 64-char cap bounds a
186
- // junk string before it reaches the Date parser.
187
- function optionalDateField(raw, label) {
188
- if (raw === undefined || raw === null)
189
- return undefined;
190
- if (!isJsonString(raw)) {
191
- throw new HttpError(400, `${label} must be a string`);
192
- }
193
- if (raw.length > 64) {
194
- throw new HttpError(400, `${label} exceeds 64-character cap`);
195
- }
196
- return raw;
197
- }
198
- // Parse a `?limit=` query param for the E2 list routes. Defaults to 100; requires
199
- // a positive INTEGER <= 1000. Number.isInteger rejects fractional values like
200
- // "1.5" that Number.isFinite would pass but SQLite `LIMIT ?` rejects with a
201
- // datatype mismatch (a 500). Shared across the decision/incident/process/policy
202
- // list routes so the guard cannot drift (codex review 2026-05-30 P2: fractional
203
- // limit reached SQLite on the policy route; the same latent hole existed in the
204
- // sibling routes this was copied from).
205
- function parseListLimit(limitRaw) {
206
- if (limitRaw === null)
207
- return 100;
208
- const limit = Number(limitRaw);
209
- if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
210
- throw new HttpError(400, 'limit must be a positive integer <= 1000');
211
- }
212
- return limit;
213
- }
214
- const VALID_KINDS = new Set([
215
- 'raw',
216
- 'distilled',
217
- 'superseded',
218
- 'archived',
219
- ]);
220
- // Pinned at module load. Bumped alongside package.json on releases. The
221
- // HTTP /health response uses this; reading package.json synchronously here
222
- // would couple the daemon to its on-disk install path, which we want to
223
- // avoid for tests that mkdtemp a hippoRoot.
224
- // v1.3.1: source from src/version.ts so /health no longer reports stale 0.39.0.
225
- const VERSION = PACKAGE_VERSION;
226
- // 1 MB body cap. The CLI never sends payloads near this; anything bigger is
227
- // almost certainly a misconfigured client or a deliberate memory-blowup attempt.
228
- const MAX_BODY_BYTES = 1024 * 1024;
229
- const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
230
- const JSON_HEADERS = { 'content-type': 'application/json' };
231
- class HttpError extends Error {
232
- status;
233
- constructor(status, message) {
234
- super(message);
235
- this.status = status;
236
- }
237
- }
238
- class BodyTooLargeError extends Error {
239
- }
240
- function sendJson(res, status, body) {
241
- res.writeHead(status, JSON_HEADERS);
242
- res.end(JSON.stringify(body));
243
- }
244
- function sendError(res, status, message) {
245
- sendJson(res, status, { error: message });
246
- }
247
- /**
248
- * Read the entire request body into a Buffer. Caps at MAX_BODY_BYTES to keep
249
- * a malicious or buggy client from exhausting memory. The cap is enforced
250
- * mid-stream so we don't wait for an attacker to finish before erroring out.
251
- */
252
- async function readBody(req) {
253
- const chunks = [];
254
- let total = 0;
255
- for await (const chunk of req) {
256
- // SAFETY: IncomingMessage never runs setEncoding() here, so every
257
- // streamed chunk is a Buffer, not a decoded string.
258
- const buf = chunk;
259
- total += buf.length;
260
- if (total > MAX_BODY_BYTES) {
261
- throw new BodyTooLargeError('request body exceeds 1MB');
262
- }
263
- chunks.push(buf);
264
- }
265
- return Buffer.concat(chunks).toString('utf8');
266
- }
267
- async function parseJsonBody(req) {
268
- const raw = await readBody(req);
269
- if (raw.length === 0)
270
- return {};
271
- try {
272
- const parsed = JSON.parse(raw);
273
- if (!isJsonObjectRecord(parsed)) {
274
- throw new HttpError(400, 'request body must be a JSON object');
275
- }
276
- return parsed;
277
- }
278
- catch (e) {
279
- if (e instanceof HttpError)
280
- throw e;
281
- throw new HttpError(400, 'invalid JSON body');
282
- }
283
- }
284
- /**
285
- * Map an error thrown by an api.* function into an HTTP status + message.
286
- * api.* uses plain Error, so we discriminate by message pattern. Stable
287
- * patterns we rely on:
288
- * - /not found/i → 404 (forget on unknown id, supersede on unknown old id, etc.)
289
- * - /unknown/i → 404 (auth_revoke on unknown key_id)
290
- * - /already superseded/i → 409 (chain conflict)
291
- * - /not raw/i → 400 (archive_raw on non-raw row)
292
- * Everything else maps to 400 (bad input).
293
- */
294
- function mapApiError(err) {
295
- const message = err instanceof Error ? err.message : String(err);
296
- const lower = message.toLowerCase();
297
- if (/not found/.test(lower) || /^unknown /.test(lower)) {
298
- return { status: 404, message };
299
- }
300
- if (/already superseded/.test(lower)) {
301
- return { status: 409, message };
302
- }
303
- return { status: 400, message };
304
- }
305
- function parseRequest(req) {
306
- const url = new URL(req.url ?? '/', 'http://placeholder');
307
- return {
308
- method: req.method ?? 'GET',
309
- path: url.pathname,
310
- query: url.searchParams,
311
- };
312
- }
313
- /**
314
- * Lightweight pattern matcher for /v1/memories/:id/<action>. Avoids pulling
315
- * in a router dependency for the half-dozen patterns we actually use.
316
- *
317
- * Returns null if `path` does not match `pattern`. Otherwise returns an object
318
- * mapping each :param name to its value. Path segments are exact-matched
319
- * except for parameter slots.
320
- */
321
- function matchPath(pattern, path) {
322
- const patternParts = pattern.split('/');
323
- const pathParts = path.split('/');
324
- if (patternParts.length !== pathParts.length)
325
- return null;
326
- const params = {};
327
- for (let i = 0; i < patternParts.length; i++) {
328
- const pp = patternParts[i];
329
- const ap = pathParts[i];
330
- if (pp.startsWith(':')) {
331
- if (ap.length === 0)
332
- return null;
333
- params[pp.slice(1)] = decodeURIComponent(ap);
334
- }
335
- else if (pp !== ap) {
336
- return null;
337
- }
338
- }
339
- return params;
340
- }
341
- /**
342
- * Recognise loopback remote addresses. Node reports IPv6-mapped IPv4 as
343
- * '::ffff:127.0.0.1' on dual-stack sockets, so we accept that alongside
344
- * the bare v4 and v6 loopbacks. Anything else is treated as remote.
345
- */
346
- export function isLoopback(remoteAddress) {
347
- if (!remoteAddress)
348
- return false;
349
- if (remoteAddress === '127.0.0.1')
350
- return true;
351
- if (remoteAddress === '::1')
352
- return true;
353
- if (remoteAddress === '::ffff:127.0.0.1')
354
- return true;
355
- return false;
356
- }
357
- function readAuthHeader(req) {
358
- const raw = req.headers['authorization'];
359
- if (raw === undefined)
360
- return { kind: 'absent' };
361
- const value = Array.isArray(raw) ? raw[0] : raw;
362
- if (!isHeaderString(value) || value.length === 0) {
363
- return { kind: 'malformed' };
364
- }
365
- const space = value.indexOf(' ');
366
- if (space < 0)
367
- return { kind: 'malformed' };
368
- const scheme = value.slice(0, space);
369
- const token = value.slice(space + 1).trim();
370
- if (scheme.toLowerCase() !== 'bearer')
371
- return { kind: 'malformed' };
372
- if (token.length === 0)
373
- return { kind: 'malformed' };
374
- return { kind: 'bearer', token };
375
- }
376
- /**
377
- * Build a per-client key for MCP state isolation under HTTP-MCP. Used by
378
- * mcp/server.ts to scope `lastRecalledIds` to the calling client so two
379
- * clients on the same tenant cannot poison each other's outcome feedback.
380
- *
381
- * Token is hashed (sha256, 16-hex-char prefix) so we never log or persist
382
- * the raw bearer. Combined with remoteAddress so two clients sharing a key
383
- * (e.g. on a shared Postman environment) are still separable in the common
384
- * case. 'noauth' covers loopback no-auth and is acceptable because that
385
- * path is single-host single-user.
386
- */
387
- function buildMcpClientKey(req) {
388
- const auth = readAuthHeader(req);
389
- const tokenHash = auth.kind === 'bearer'
390
- ? createHash('sha256').update(auth.token).digest('hex').slice(0, 16)
391
- : 'noauth';
392
- const addr = req.socket.remoteAddress ?? 'unknown';
393
- return `http:${tokenHash}:${addr}`;
394
- }
395
- /**
396
- * Rate-limit key for a request. Defaults to the socket's remote address.
397
- *
398
- * Behind a TLS-terminating proxy (Fly, most PaaS ingress) every socket
399
- * carries the proxy's address, so per-IP buckets collapse into one global
400
- * bucket that unauthenticated traffic can drain before auth runs. Set
401
- * HIPPO_CLIENT_IP_HEADER to the header the proxy stamps with the real
402
- * client address (fly-client-ip on Fly, which the edge always overwrites)
403
- * to key buckets per client instead.
404
- *
405
- * Only set this when a trusted proxy fronts EVERY request: a directly
406
- * reachable server honoring the header would let clients mint a fresh
407
- * bucket per request and bypass the limiter entirely.
408
- */
409
- export function clientIpForRateLimit(req) {
410
- const header = process.env.HIPPO_CLIENT_IP_HEADER?.toLowerCase();
411
- if (header) {
412
- const raw = req.headers[header];
413
- const first = Array.isArray(raw) ? raw[0] : raw;
414
- // Take the first entry of a comma-joined list (proxy chains append).
415
- const ip = first?.split(',')[0]?.trim();
416
- if (ip)
417
- return ip;
418
- }
419
- return req.socket.remoteAddress ?? 'unknown';
420
- }
421
- /**
422
- * Build a per-request Context from the Authorization header and remote
423
- * address. Throws HttpError(401) for invalid / missing credentials. Opens
424
- * the DB only when a Bearer token is present so loopback no-auth requests
425
- * stay cheap.
426
- */
427
- function buildContextWithAuth(req, hippoRoot) {
428
- const auth = readAuthHeader(req);
429
- if (auth.kind === 'malformed') {
430
- throw new HttpError(401, 'invalid api key');
431
- }
432
- if (auth.kind === 'bearer') {
433
- const db = openHippoDb(hippoRoot);
434
- try {
435
- const result = validateApiKey(db, auth.token);
436
- if (!result.valid || !result.tenantId || !result.keyId || !result.role) {
437
- throw new HttpError(401, 'invalid api key');
438
- }
439
- return {
440
- hippoRoot,
441
- tenantId: result.tenantId,
442
- actor: {
443
- subject: `api_key:${result.keyId}`,
444
- role: result.role,
445
- },
446
- };
447
- }
448
- finally {
449
- closeHippoDb(db);
450
- }
451
- }
452
- // No Authorization header. Loopback-only fallback, unless explicitly
453
- // disabled via HIPPO_REQUIRE_AUTH=1 (used by the bearer-lockdown test
454
- // and by deployments that want to forbid the local-CLI escape hatch).
455
- if (process.env.HIPPO_REQUIRE_AUTH === '1') {
456
- throw new HttpError(401, 'auth required');
457
- }
458
- if (!isLoopback(req.socket.remoteAddress)) {
459
- throw new HttpError(401, 'auth required');
460
- }
461
- // v1.12.0: loopback fallback is process-local, treat as admin.
462
- return {
463
- hippoRoot,
464
- tenantId: resolveTenantId({}),
465
- actor: { subject: 'localhost:cli', role: 'admin' },
466
- };
467
- }
468
- /**
469
- * Auth check for routes that do not need a tenant Context (e.g. MCP transport,
470
- * which builds its own root resolution via findHippoRoot). Throws HttpError
471
- * 401 the same way buildContextWithAuth does, but skips building the Context
472
- * envelope. Loopback no-auth still passes.
473
- */
474
- function requireAuth(req, hippoRoot) {
475
- const auth = readAuthHeader(req);
476
- if (auth.kind === 'malformed') {
477
- throw new HttpError(401, 'invalid api key');
478
- }
479
- if (auth.kind === 'bearer') {
480
- const db = openHippoDb(hippoRoot);
481
- try {
482
- const result = validateApiKey(db, auth.token);
483
- if (!result.valid) {
484
- throw new HttpError(401, 'invalid api key');
485
- }
486
- }
487
- finally {
488
- closeHippoDb(db);
489
- }
490
- return;
491
- }
492
- if (process.env.HIPPO_REQUIRE_AUTH === '1') {
493
- throw new HttpError(401, 'auth required');
494
- }
495
- if (!isLoopback(req.socket.remoteAddress)) {
496
- throw new HttpError(401, 'auth required');
497
- }
498
- }
499
- function getString(obj, key) {
500
- const v = obj[key];
501
- return isJsonString(v) ? v : undefined;
502
- }
503
- function getStringArray(obj, key) {
504
- const v = obj[key];
505
- if (!Array.isArray(v))
506
- return undefined;
507
- if (!v.every(isJsonString))
508
- return undefined;
509
- return v;
510
- }
511
- /**
512
- * Reject URL-encoded slashes in path segments BEFORE the URL parser decodes
513
- * them — otherwise `%2F` becomes `/`, path-split runs, and the route either
514
- * silently 404s or matches the wrong template.
515
- *
516
- * codex round 3 P2: only scan the PATHNAME portion of the raw URL, not the
517
- * query string. Pre-fix, `?q=https%3A%2F%2Fexample.com` would 400 because
518
- * the regex matched `%2F` anywhere in `req.url`. Recall queries containing
519
- * URLs would have been rejected as bypass attempts. Splitting on the first
520
- * `?` confines the check to the path.
521
- */
522
- function rejectEncodedSlash(rawUrl) {
523
- const queryIdx = rawUrl.indexOf('?');
524
- const pathname = queryIdx === -1 ? rawUrl : rawUrl.slice(0, queryIdx);
525
- if (/%2[Ff]/.test(pathname)) {
526
- throw new HttpError(400, 'URL-encoded slash (%2F) not allowed in path segments');
527
- }
528
- }
529
- /**
530
- * v1.6.4: charset + length validation for `:id` route captures. Routes call
531
- * this immediately after `matchPath` to reject empty / overlong / illegal
532
- * ids with a useful 400 instead of silently falling through to "not found".
533
- *
534
- * Allowed charset matches all production id shapes Hippo emits: `mem_<hex>`,
535
- * `sum_<hex>`, `sess-<id>`, Slack bot ids like `B01ABCD`, etc. The `:` and
536
- * `.` are allowed for forward-compat. The `/` is intentionally absent —
537
- * Hippo never emits ids with slashes, and `rejectEncodedSlash` already
538
- * stops `%2F`-smuggled ones at the front door.
539
- */
540
- const ID_SEGMENT_RE = /^[A-Za-z0-9_:.\-]+$/;
541
- function validateIdSegment(id, fieldName) {
542
- if (id.length === 0)
543
- throw new HttpError(400, `${fieldName} is required`);
544
- if (id.length > 256)
545
- throw new HttpError(400, `${fieldName} exceeds 256-character cap`);
546
- if (!ID_SEGMENT_RE.test(id)) {
547
- throw new HttpError(400, `${fieldName} contains invalid characters; allowed: A-Z a-z 0-9 _ : . -`);
548
- }
549
- }
550
- async function handleRequest(req, res, opts, startedAt, limiter) {
551
- // v1.6.4: pre-decode raw-URL slash check. Catches `%2F` / `%2f` before
552
- // Node's URL parser collapses them and they slip past the route table.
553
- rejectEncodedSlash(req.url ?? '/');
554
- const { method, path, query } = parseRequest(req);
555
- if (method === 'GET' && path === '/health') {
556
- // Loopback callers (detectServer's stale-pidfile probe reads version and
557
- // pid) get the full body. Non-loopback callers get liveness only: the
558
- // version string would fingerprint the build for the public internet and
559
- // the pid is noise. Platform health checks only need the 200.
560
- if (isLoopback(req.socket.remoteAddress)) {
561
- sendJson(res, 200, {
562
- ok: true,
563
- version: VERSION,
564
- started_at: startedAt,
565
- pid: process.pid,
566
- });
567
- }
568
- else {
569
- sendJson(res, 200, { ok: true });
570
- }
571
- return;
572
- }
573
- // E3: per-IP rate limit on /v1/* to bound api-key-id enumeration. /health
574
- // (a liveness probe) and non-/v1 paths are never throttled. A 429 thrown
575
- // here lands in the createServer catch like any other HttpError.
576
- //
577
- // Keyed on the socket's remote address by default. Behind a TLS-terminating
578
- // proxy every socket carries the proxy's address, collapsing the per-IP
579
- // buckets into one global bucket that pre-auth traffic can drain; set
580
- // HIPPO_CLIENT_IP_HEADER there so each real client gets its own bucket
581
- // (see clientIpForRateLimit).
582
- if (limiter && path.startsWith('/v1/')) {
583
- const ip = clientIpForRateLimit(req);
584
- if (!limiter.check(ip)) {
585
- throw new HttpError(429, 'rate limit exceeded');
586
- }
587
- }
588
- // POST /v1/memories
589
- if (method === 'POST' && path === '/v1/memories') {
590
- const body = await parseJsonBody(req);
591
- const content = getString(body, 'content');
592
- if (!content) {
593
- throw new HttpError(400, 'content is required');
594
- }
595
- const kindRaw = getString(body, 'kind');
596
- if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
597
- throw new HttpError(400, `invalid kind: ${kindRaw}`);
598
- }
599
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
600
- const result = remember(ctx, {
601
- content,
602
- kind: kindRaw,
603
- scope: getString(body, 'scope'),
604
- owner: getString(body, 'owner'),
605
- artifactRef: getString(body, 'artifactRef'),
606
- tags: getStringArray(body, 'tags'),
607
- });
608
- sendJson(res, 200, result);
609
- return;
610
- }
611
- // GET /v1/graph?entity=NAME&limit=N — read-only entity/relation graph (tenant-scoped)
612
- if (method === 'GET' && path === '/v1/graph') {
613
- const entityRaw = query.get('entity');
614
- // Cap at the graph entity-name cap (512), not the id-shaped 256, so a valid
615
- // long decision/policy name remains focusable over HTTP (codex P2).
616
- if (entityRaw !== null && entityRaw.length > MAX_ENTITY_NAME_LEN) {
617
- throw new HttpError(400, `entity exceeds the ${MAX_ENTITY_NAME_LEN}-character cap`);
618
- }
619
- const limit = parseListLimit(query.get('limit'));
620
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
621
- const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
622
- entity: entityRaw ?? undefined,
623
- limit,
624
- });
625
- sendJson(res, 200, model);
626
- return;
627
- }
628
- // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
629
- if (method === 'GET' && path === '/v1/memories') {
630
- const q = query.get('q');
631
- if (!q) {
632
- throw new HttpError(400, 'q is required');
633
- }
634
- const limitRaw = query.get('limit');
635
- const limit = limitRaw === null ? undefined : Number(limitRaw);
636
- if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
637
- throw new HttpError(400, 'limit must be a positive number');
638
- }
639
- const mode = query.get('mode');
640
- if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
641
- throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
642
- }
643
- const scope = query.get('scope');
644
- const includeContinuityRaw = query.get('include_continuity');
645
- const includeContinuity = includeContinuityRaw === '1'
646
- || includeContinuityRaw === 'true';
647
- // v1.6.2: surface the v1.5.0/v1.5.2 RecallOpts additions to HTTP
648
- // callers. Pre-v1.6.2 the route silently ignored these so the
649
- // session-scoped fresh-tail and summary substitution were JS-only.
650
- const freshTailCountRaw = query.get('fresh_tail_count');
651
- const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
652
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
653
- throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
654
- }
655
- // v1.6.3 senior-review P1-3: cap session_id length consistent with the
656
- // rest of the API. Untrimmed strings round-trip through the SQL layer
657
- // and through any downstream metric/log; 256 is generous for a session
658
- // id and matches the rest of this file's id-shaped param parsers.
659
- const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
660
- if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
661
- throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
662
- }
663
- const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
664
- ? freshTailSessionIdRaw
665
- : undefined;
666
- // v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
667
- // convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
668
- // so `?summarize_overflow=banana` and `?summarize_overflow=` both
669
- // turned it on. Surface convention drift fixed.
670
- const summarizeOverflowRaw = query.get('summarize_overflow');
671
- const summarizeOverflow = summarizeOverflowRaw === null
672
- ? undefined
673
- : (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
674
- // v1.7.2 T4: forward as Number(...) — NaN, 0, negative all reach
675
- // recall() which throws RecallContractError with code='invalid_scorer_window'.
676
- // No transport-side validation; recall() owns the contract.
677
- const scorerWindowRaw = query.get('scorer_window');
678
- const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
679
- // v1.7.4: session_id for the dlPFC goal-stack boost. 256-char cap mirrors
680
- // fresh_tail_session_id (above). Trim then drop if empty so api.recall
681
- // sees undefined when the param is omitted or whitespace-only.
682
- const sessionIdRaw = query.get('session_id');
683
- if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
684
- throw new HttpError(400, 'session_id exceeds 256-character cap');
685
- }
686
- const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
687
- ? sessionIdRaw.trim()
688
- : undefined;
689
- // A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
690
- // lifecycle re-ranking trace (goal-boost step on the api pipeline) +
691
- // rerankPipeline:'api' to each result item; the field then rides on the
692
- // serialized RecallResult. Mirrors the include_continuity convention.
693
- const explainRaw = query.get('explain');
694
- const explain = explainRaw === '1' || explainRaw === 'true';
695
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
696
- // v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
697
- // ring snapshot via opts.recallHistory so api.recall's own
698
- // anchoringHint compute path activates. Unlike CLI (which computes
699
- // its own hint separately because cmdRecall runs its own physics/
700
- // hybrid pipeline outside api.recall), HTTP's /v1/memories response
701
- // body IS api.recall's result directly. So the api.recall-computed
702
- // hint flows through. HIPPO_ANCHORING=off short-circuits.
703
- let httpRecallHistory;
704
- let httpRingKey;
705
- if (process.env.HIPPO_ANCHORING !== 'off') {
706
- if (sessionId) {
707
- // Codex round-5 P2 catch: do NOT mutate sessionRecallHistoryHttp
708
- // before recall() preflight runs. A request with an invalid
709
- // scorer_window / fresh_tail_count would create-or-touch the
710
- // session ring (LRU-evicting valid sessions) even though recall
711
- // throws 400. Snapshot the EXISTING ring if present; only
712
- // create-or-touch after the recall returns successfully.
713
- httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
714
- const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
715
- httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
716
- }
717
- else {
718
- // Telemetry: caller had no session_id so ring tracking skipped.
719
- // Per the normal recall-audit convention (api.ts:854 stores
720
- // SHA-256/16 hash of the query, NOT raw text), avoid retaining
721
- // prompts in audit_log here too — query content can contain
722
- // secrets, PII, or RTBF-restricted material. Codex round-2 P2
723
- // catch: hashQueryText is a 32-bit FNV-1a designed for recall
724
- // matching, NOT a privacy hash; brute-force trivial for low-
725
- // entropy queries. Use the same SHA-256/16 truncation as the
726
- // canonical recall audit.
727
- const dbForAudit = openHippoDb(opts.hippoRoot);
728
- try {
729
- appendAuditEvent(dbForAudit, {
730
- tenantId: ctx.tenantId,
731
- actor: ctx.actor.subject,
732
- op: 'recall_anchor_skipped_no_session',
733
- targetId: undefined,
734
- metadata: {
735
- query_hash: createHash('sha256').update(q).digest('hex').slice(0, 16),
736
- query_length: q.length,
737
- },
738
- });
739
- }
740
- finally {
741
- closeHippoDb(dbForAudit);
742
- }
743
- }
744
- }
745
- const recallExtra = {};
746
- if (freshTailCount !== undefined)
747
- recallExtra.freshTailCount = freshTailCount;
748
- if (freshTailSessionId !== undefined)
749
- recallExtra.freshTailSessionId = freshTailSessionId;
750
- if (summarizeOverflow !== undefined)
751
- recallExtra.summarizeOverflow = summarizeOverflow;
752
- if (scorerWindow !== undefined)
753
- recallExtra.scorerWindow = scorerWindow;
754
- if (sessionId !== undefined)
755
- recallExtra.sessionId = sessionId;
756
- if (httpRecallHistory !== undefined)
757
- recallExtra.recallHistory = httpRecallHistory;
758
- if (explain)
759
- recallExtra.explain = explain;
760
- const result = recall(ctx, {
761
- query: q,
762
- limit,
763
- mode: mode ?? undefined,
764
- scope: scope ?? undefined,
765
- includeContinuity,
766
- ...recallExtra,
767
- });
768
- // v0.33 / J1 — append AFTER recall completes (snapshot was taken before
769
- // recall() ran). anchoredOn carries the memoryId of any hint that fired
770
- // (api.recall computed it from the same snapshot we passed in), feeding
771
- // the cooldown logic for the NEXT recall on this session.
772
- // Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
773
- // returns successfully. Invalid requests that throw 400 in recall()
774
- // never reach this point, so they cannot LRU-evict valid sessions.
775
- if (httpRingKey) {
776
- const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
777
- const topId = result.results[0]?.id ?? null;
778
- appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
779
- }
780
- // Each recall surface counts its own hits; api.recall is no chokepoint,
781
- // since the CLI never calls it and MCP shows the user a different band.
782
- updateStats(opts.hippoRoot, { recalled: result.results.length });
783
- // Continuity payloads should never be cached. The caller is asking for
784
- // session-state-aware data; intermediaries must not reuse it across users.
785
- if (includeContinuity) {
786
- res.setHeader('Cache-Control', 'no-store');
787
- }
788
- sendJson(res, 200, result);
789
- return;
790
- }
791
- // GET /v1/sessions/:id/assemble?budget=N&freshTail=N&summarizeOlder=0|1
792
- // Phase 2 context-engine API. Returns ordered AssembledContextItem[]
793
- // with fresh-tail raws + summary substitutions + bio-aware budget fit.
794
- // Tenant scope from Bearer; default-deny on private rows.
795
- const assembleMatch = matchPath('/v1/sessions/:id/assemble', path);
796
- if (method === 'GET' && assembleMatch) {
797
- validateIdSegment(assembleMatch.id, 'session id');
798
- const budgetRaw = query.get('budget');
799
- const budget = budgetRaw === null ? undefined : Number(budgetRaw);
800
- if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
801
- throw new HttpError(400, 'budget must be a positive number');
802
- }
803
- const ftRaw = query.get('freshTail');
804
- const freshTailCount = ftRaw === null ? undefined : Number(ftRaw);
805
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
806
- throw new HttpError(400, 'freshTail must be a non-negative number');
807
- }
808
- // v1.6.3 senior review P1: same strict-parse convention as the v1.6.3
809
- // summarize_overflow tighten on /v1/memories. Pre-v1.6.3 accepted any
810
- // non-'0'/'false' as true; ?summarizeOlder=banana now correctly returns
811
- // false (matches includeContinuity convention).
812
- const sumOlderRaw = query.get('summarizeOlder');
813
- const summarizeOlder = sumOlderRaw === null
814
- ? undefined
815
- : (sumOlderRaw === '1' || sumOlderRaw === 'true');
816
- const scopeQ = query.get('scope');
817
- const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
818
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
819
- const assembleExtra = {};
820
- if (budget !== undefined)
821
- assembleExtra.budget = budget;
822
- if (freshTailCount !== undefined)
823
- assembleExtra.freshTailCount = freshTailCount;
824
- if (summarizeOlder !== undefined)
825
- assembleExtra.summarizeOlder = summarizeOlder;
826
- if (scope !== undefined)
827
- assembleExtra.scope = scope;
828
- const result = assemble(ctx, assembleMatch.id, assembleExtra);
829
- sendJson(res, 200, result);
830
- return;
831
- }
832
- // GET /v1/recall/drill/:id?limit=N&budget=N
833
- // Companion to /v1/memories. When recall surfaces a level-2 summary in
834
- // place of overflowed children (RecallResultItem.isSummary === true), the
835
- // caller drills into the summary id to recover the originals. Tenant
836
- // scoped via Bearer; default-deny on private scopes for both summary
837
- // and children.
838
- const drillMatch = matchPath('/v1/recall/drill/:id', path);
839
- if (method === 'GET' && drillMatch) {
840
- validateIdSegment(drillMatch.id, 'summary id');
841
- const limitRaw = query.get('limit');
842
- const limit = limitRaw === null ? undefined : Number(limitRaw);
843
- if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
844
- throw new HttpError(400, 'limit must be a positive number');
845
- }
846
- const budgetRaw = query.get('budget');
847
- const budget = budgetRaw === null ? undefined : Number(budgetRaw);
848
- if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
849
- throw new HttpError(400, 'budget must be a positive number');
850
- }
851
- // v0.30 / E5: depth query param walks N levels (default 1, hard cap 10).
852
- const depthRaw = query.get('depth');
853
- let depth;
854
- if (depthRaw !== null) {
855
- const parsed = Number(depthRaw);
856
- // L4 fold: reject out-of-range explicitly (no silent clamp).
857
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
858
- throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
859
- }
860
- depth = parsed;
861
- }
862
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
863
- const drillExtra = {};
864
- if (limit !== undefined)
865
- drillExtra.limit = limit;
866
- if (budget !== undefined)
867
- drillExtra.budget = budget;
868
- if (depth !== undefined)
869
- drillExtra.depth = depth;
870
- const result = drillDown(ctx, drillMatch.id, drillExtra);
871
- if ('failure' in result) {
872
- // v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
873
- // as 404 to avoid leaking cross-tenant existence or scope grants.
874
- if (result.failure === 'not_drillable') {
875
- throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
876
- }
877
- throw new HttpError(404, 'No drillable summary at this id');
878
- }
879
- sendJson(res, 200, result);
880
- return;
881
- }
882
- // /v1/memories/:id/* and DELETE /v1/memories/:id
883
- const archiveMatch = matchPath('/v1/memories/:id/archive', path);
884
- if (method === 'POST' && archiveMatch) {
885
- validateIdSegment(archiveMatch.id, 'memory id');
886
- const body = await parseJsonBody(req);
887
- const reason = getString(body, 'reason');
888
- if (!reason) {
889
- throw new HttpError(400, 'reason is required');
890
- }
891
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
892
- const result = archiveRaw(ctx, archiveMatch.id, reason);
893
- sendJson(res, 200, result);
894
- return;
895
- }
896
- const supersedeMatch = matchPath('/v1/memories/:id/supersede', path);
897
- if (method === 'POST' && supersedeMatch) {
898
- validateIdSegment(supersedeMatch.id, 'memory id');
899
- const body = await parseJsonBody(req);
900
- const content = getString(body, 'content');
901
- if (!content) {
902
- throw new HttpError(400, 'content is required');
903
- }
904
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
905
- const result = supersede(ctx, supersedeMatch.id, content);
906
- sendJson(res, 200, result);
907
- return;
908
- }
909
- const promoteMatch = matchPath('/v1/memories/:id/promote', path);
910
- if (method === 'POST' && promoteMatch) {
911
- validateIdSegment(promoteMatch.id, 'memory id');
912
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
913
- const result = promote(ctx, promoteMatch.id);
914
- sendJson(res, 200, result);
915
- return;
916
- }
917
- const idMatch = matchPath('/v1/memories/:id', path);
918
- if (method === 'DELETE' && idMatch) {
919
- validateIdSegment(idMatch.id, 'memory id');
920
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
921
- const result = forget(ctx, idMatch.id);
922
- sendJson(res, 200, result);
923
- return;
924
- }
925
- // POST /v1/outcome — apply a positive/negative outcome to memory ids.
926
- // Body: {ids?: string[], good: boolean}. If ids omitted, falls back to
927
- // the last-recall path (api.outcomeForLastRecall); returned shape is
928
- // {applied, ids} in that case so callers can disambiguate "no recent
929
- // recall" from "all ids skipped". Each applied id writes one audit_log
930
- // row (op='outcome', actor from Bearer).
931
- if (method === 'POST' && path === '/v1/outcome') {
932
- const body = await parseJsonBody(req);
933
- const good = body['good'];
934
- if (!isJsonBoolean(good)) {
935
- throw new HttpError(400, 'good is required (boolean)');
936
- }
937
- const idsRaw = body['ids'];
938
- let ids;
939
- if (idsRaw !== undefined) {
940
- if (!Array.isArray(idsRaw)) {
941
- throw new HttpError(400, 'ids must be an array of non-empty strings');
942
- }
943
- const isNonEmptyId = (item) => isJsonString(item) && item.length > 0;
944
- if (!idsRaw.every(isNonEmptyId)) {
945
- throw new HttpError(400, 'ids must be an array of non-empty strings');
946
- }
947
- // v1.11.5: DoS cap on ids.length. Each id triggers ~3 DB ops (readEntry +
948
- // writeEntry + appendAuditEvent). N=1000 keeps per-request work bounded
949
- // to sub-second wall time on SQLite hot path. Cap BEFORE buildContextWithAuth
950
- // so attack traffic doesn't pay the api-key lookup cost.
951
- if (idsRaw.length > 1000) {
952
- throw new HttpError(400, 'ids exceeds 1000-id cap');
953
- }
954
- ids = idsRaw;
955
- }
956
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
957
- if (ids !== undefined) {
958
- const { applied } = outcome(ctx, ids, good);
959
- sendJson(res, 200, { applied });
960
- }
961
- else {
962
- const result = outcomeForLastRecall(ctx, good);
963
- sendJson(res, 200, result);
964
- }
965
- return;
966
- }
967
- // GET /v1/context — assemble a budget-bounded context bundle. Returns
968
- // ContextResult JSON (entries + tokens + activeSnapshot + sessionHandoff
969
- // + recentEvents). No server-side rendering; clients render. Tenant-scoped
970
- // via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
971
- // (matches cmdContext); real-query hybrid search emits one 'recall' row.
972
- if (method === 'GET' && path === '/v1/context') {
973
- const q = query.get('q') ?? undefined;
974
- // v1.11.5: DoS cap on q-param length. 1024 covers real multi-clause queries
975
- // (pasted error messages, multi-stem searches) while bounding BM25
976
- // tokenisation cost (~150 tokens worst case at 1024 chars).
977
- if (q !== undefined && q.length > 1024) {
978
- throw new HttpError(400, 'q exceeds 1024-character cap');
979
- }
980
- const budgetRaw = query.get('budget');
981
- let budget;
982
- if (budgetRaw !== null) {
983
- budget = Number(budgetRaw);
984
- if (!Number.isFinite(budget) || budget < 0) {
985
- throw new HttpError(400, 'budget must be a non-negative number');
986
- }
987
- }
988
- const limitRaw = query.get('limit');
989
- let limit;
990
- if (limitRaw !== null) {
991
- limit = Number(limitRaw);
992
- if (!Number.isFinite(limit) || limit <= 0) {
993
- throw new HttpError(400, 'limit must be a positive number');
994
- }
995
- }
996
- const pinnedOnlyRaw = query.get('pinned_only');
997
- const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
998
- const scopeRaw = query.get('scope');
999
- if (scopeRaw !== null && scopeRaw.length > 256) {
1000
- throw new HttpError(400, 'scope exceeds 256-character cap');
1001
- }
1002
- const scope = scopeRaw === null ? undefined : scopeRaw;
1003
- const includeRecentRaw = query.get('include_recent');
1004
- let includeRecent;
1005
- if (includeRecentRaw !== null) {
1006
- includeRecent = Number(includeRecentRaw);
1007
- if (!Number.isFinite(includeRecent) || includeRecent < 0) {
1008
- throw new HttpError(400, 'include_recent must be a non-negative number');
1009
- }
1010
- }
1011
- // v39 memory scope isolation: cross_project=1|true re-includes
1012
- // other-project rows (tagged category 'cross-project' in the response).
1013
- // The partition identity comes from the SERVED STORE's location, not the
1014
- // daemon's process cwd - a daemon started from anywhere still isolates
1015
- // the project it serves.
1016
- const crossProjectRaw = query.get('cross_project');
1017
- const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
1018
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1019
- const result = await getContext(ctx, {
1020
- q,
1021
- budget,
1022
- limit,
1023
- pinnedOnly,
1024
- scope,
1025
- includeRecent,
1026
- crossProject,
1027
- currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
1028
- });
1029
- sendJson(res, 200, result);
1030
- return;
1031
- }
1032
- // POST /v1/sleep — host-wide consolidation pipeline (consolidate + dedup +
1033
- // audit + share + ambient). serve() refuses non-loopback hosts at boot, AND
1034
- // this per-request loopback assertion makes the host-wide semantic fail-
1035
- // closed regardless of any future serve() boot-config change. Body:
1036
- // {dry_run?, no_share?}. Returns SleepResult JSON.
1037
- //
1038
- // Tenant scope (Episode A follow-up tracked in TODOS.md): api.sleep operates
1039
- // on the WHOLE hippoRoot (cross-tenant by design, matching CLI cmdSleep).
1040
- // The loopback-only guard is the trust boundary today. Future non-loopback
1041
- // serving needs an admin-role gate before exposing this route.
1042
- if (method === 'POST' && path === '/v1/sleep') {
1043
- // Defensive per-request loopback guard. Uses the canonical isLoopback()
1044
- // helper above so any future extension (additional mapped/IPv6 forms,
1045
- // NAT64 prefixes) flows through without drift. serve()'s boot-time host
1046
- // check is the primary trust boundary; this is belt-and-suspenders.
1047
- if (!isLoopback(req.socket.remoteAddress)) {
1048
- throw new HttpError(403, '/v1/sleep is loopback-only (host-wide consolidation; see CHANGELOG v1.11.4)');
1049
- }
1050
- // v1.12.0 A5 v2 sub-1: admin-role gate. Forward-defensive — exists today
1051
- // under loopback-only enforcement (loopback fallback is admin by default;
1052
- // any Bearer-authed caller now carries an explicit role from the api_keys
1053
- // row). When non-loopback serving lands, this gate is the actual auth
1054
- // boundary on host-wide sleep.
1055
- const sleepCtx = buildContextWithAuth(req, opts.hippoRoot);
1056
- if (sleepCtx.actor.role !== 'admin') {
1057
- throw new HttpError(403, '/v1/sleep requires admin role');
1058
- }
1059
- const body = await parseJsonBody(req);
1060
- const dryRunRaw = body['dry_run'];
1061
- if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
1062
- throw new HttpError(400, 'dry_run must be a boolean');
1063
- }
1064
- const noShareRaw = body['no_share'];
1065
- if (noShareRaw !== undefined && !isJsonBoolean(noShareRaw)) {
1066
- throw new HttpError(400, 'no_share must be a boolean');
1067
- }
1068
- // v1.12.0: sleepCtx already built above for the admin-role gate; reuse.
1069
- const result = await sleep(sleepCtx, {
1070
- dryRun: dryRunRaw === true,
1071
- noShare: noShareRaw === true,
1072
- });
1073
- sendJson(res, 200, result);
1074
- return;
1075
- }
1076
- // POST /v1/auth/keys — mint a new API key. Plaintext lands in the response
1077
- // body (Task 8): the HTTP layer hands it to the client; the user-facing
1078
- // "store this somewhere safe" warning belongs in the CLI client, not here.
1079
- if (method === 'POST' && path === '/v1/auth/keys') {
1080
- const body = await parseJsonBody(req);
1081
- const labelRaw = body['label'];
1082
- if (labelRaw !== undefined && !isJsonString(labelRaw)) {
1083
- throw new HttpError(400, 'label must be a string');
1084
- }
1085
- // v1.12.3: optional body.role mirrors the --role CLI flag. Validated
1086
- // strictly — anything other than 'admin'|'member' is a 400 (no silent
1087
- // fallback to admin). Required note: admin Bearer can mint a member
1088
- // key for the same tenant; member Bearer minting an admin key is NOT
1089
- // blocked here today (auth_create is currently unaudited per the A5 v2
1090
- // note in authCreate doc). The HTTP layer trusts the buildContextWithAuth
1091
- // role check at admin-gated routes; mint surface remains permissive.
1092
- const roleRaw = body['role'];
1093
- let role;
1094
- if (roleRaw !== undefined) {
1095
- if (roleRaw !== 'admin' && roleRaw !== 'member') {
1096
- throw new HttpError(400, "role must be 'admin' or 'member'");
1097
- }
1098
- role = roleRaw;
1099
- }
1100
- // Security: any `tenantId` in the body is IGNORED. The minted key is
1101
- // bound to the caller's authenticated tenant (ctx.tenantId, resolved
1102
- // from the Bearer token). Forwarding body.tenantId here would let
1103
- // tenant A mint a key for tenant B — see authCreate doc comment.
1104
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1105
- const result = authCreate(ctx, {
1106
- label: labelRaw,
1107
- role,
1108
- });
1109
- sendJson(res, 200, result);
1110
- return;
1111
- }
1112
- // GET /v1/auth/keys?active=true — list keys visible to ctx.tenantId.
1113
- // `active` defaults to true so the common case (show me usable keys) is
1114
- // a single GET; ?active=false includes revoked rows.
1115
- if (method === 'GET' && path === '/v1/auth/keys') {
1116
- const activeRaw = query.get('active');
1117
- let active = true;
1118
- if (activeRaw !== null) {
1119
- if (activeRaw === 'true')
1120
- active = true;
1121
- else if (activeRaw === 'false')
1122
- active = false;
1123
- else
1124
- throw new HttpError(400, "active must be 'true' or 'false'");
1125
- }
1126
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1127
- const result = authList(ctx, { active });
1128
- sendJson(res, 200, result);
1129
- return;
1130
- }
1131
- // DELETE /v1/auth/keys/:keyId — revoke. authRevoke throws "Unknown key_id"
1132
- // for missing OR cross-tenant keys (no info leak), which mapApiError
1133
- // converts to 404. We return 200 with the result body rather than 204 to
1134
- // surface revokedAt to the caller.
1135
- const keyMatch = matchPath('/v1/auth/keys/:keyId', path);
1136
- if (method === 'DELETE' && keyMatch) {
1137
- validateIdSegment(keyMatch.keyId, 'key id');
1138
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1139
- const result = authRevoke(ctx, keyMatch.keyId);
1140
- sendJson(res, 200, result);
1141
- return;
1142
- }
1143
- // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
1144
- // validated at the route boundary so an invalid value lands a 400 before
1145
- // we hit the DB.
1146
- if (method === 'GET' && path === '/v1/audit') {
1147
- const opRaw = query.get('op');
1148
- let op;
1149
- if (opRaw !== null) {
1150
- if (!isSetMember(VALID_AUDIT_OPS, opRaw)) {
1151
- throw new HttpError(400, `invalid op: ${opRaw}`);
1152
- }
1153
- op = opRaw;
1154
- }
1155
- const sinceRaw = query.get('since');
1156
- let since;
1157
- if (sinceRaw !== null) {
1158
- const parsed = Date.parse(sinceRaw);
1159
- if (!Number.isFinite(parsed)) {
1160
- throw new HttpError(400, `invalid since: ${sinceRaw}`);
1161
- }
1162
- since = sinceRaw;
1163
- }
1164
- const limitRaw = query.get('limit');
1165
- let limit;
1166
- if (limitRaw !== null) {
1167
- const parsed = Number(limitRaw);
1168
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > MAX_AUDIT_LIMIT) {
1169
- throw new HttpError(400, `limit must be an integer between 1 and ${MAX_AUDIT_LIMIT}`);
1170
- }
1171
- limit = parsed;
1172
- }
1173
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1174
- // D2 v1.12.10: optional ?tenant=<t> param. Defaults to ctx.tenantId
1175
- // (caller's own tenant). Lets admins query the '__host__' synthetic
1176
- // tenant where host-wide ops like 'consolidate' are recorded. Today
1177
- // the audit route inherits the auth gate on /v1/audit (Bearer auth);
1178
- // a future per-tenant authz check should ensure non-admin Bearers
1179
- // can only pass their own tenant_id here.
1180
- const tenantOverride = query.get('tenant');
1181
- const effectiveCtx = tenantOverride !== null && tenantOverride !== ''
1182
- ? { ...ctx, tenantId: tenantOverride }
1183
- : ctx;
1184
- const result = auditList(effectiveCtx, { op, since, limit });
1185
- sendJson(res, 200, result);
1186
- return;
1187
- }
1188
- // ── E2 prediction first-class object (v0.31) ──
1189
- // docs/plans/2026-05-26-e2-prediction-object.md
1190
- //
1191
- // 4 routes: POST /v1/predictions (create), GET /v1/predictions (list),
1192
- // GET /v1/predictions/:id (show), POST /v1/predictions/:id/close (close).
1193
- // All Bearer-authed + tenant-scoped via buildContextWithAuth. closure_state
1194
- // validated against VALID_CLOSURE_STATES (3 states). DoS caps on claim
1195
- // (4096 chars) + closureNote (2048 chars) per v1.11.4 pattern.
1196
- if (method === 'POST' && path === '/v1/predictions') {
1197
- const body = await parseJsonBody(req);
1198
- const claim = body['claim'];
1199
- if (!isJsonString(claim) || claim.length === 0) {
1200
- throw new HttpError(400, 'claim is required (non-empty string)');
1201
- }
1202
- if (claim.length > 4096) {
1203
- throw new HttpError(400, 'claim exceeds 4096-character cap');
1204
- }
1205
- const classTag = body['classTag'];
1206
- if (!isJsonString(classTag) || classTag.length === 0) {
1207
- throw new HttpError(400, 'classTag is required (non-empty string)');
1208
- }
1209
- const estimate = body['estimate'];
1210
- let estimateValue;
1211
- if (estimate !== undefined && estimate !== null) {
1212
- if (!isJsonNumber(estimate) || !Number.isFinite(estimate)) {
1213
- throw new HttpError(400, 'estimate must be a finite number');
1214
- }
1215
- estimateValue = estimate;
1216
- }
1217
- const unit = body['unit'];
1218
- let estimateUnit;
1219
- if (unit !== undefined && unit !== null) {
1220
- if (!isJsonString(unit)) {
1221
- throw new HttpError(400, 'unit must be a string');
1222
- }
1223
- estimateUnit = unit;
1224
- }
1225
- const targetDate = body['targetDate'];
1226
- let targetDateValue;
1227
- if (targetDate !== undefined && targetDate !== null) {
1228
- if (!isJsonString(targetDate)) {
1229
- throw new HttpError(400, 'targetDate must be an ISO date string');
1230
- }
1231
- targetDateValue = targetDate;
1232
- }
1233
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1234
- const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
1235
- classTag,
1236
- claimText: claim,
1237
- estimateValue,
1238
- estimateUnit,
1239
- targetDate: targetDateValue,
1240
- }, ctx.actor.subject);
1241
- sendJson(res, 201, { prediction });
1242
- return;
1243
- }
1244
- if (method === 'GET' && path === '/v1/predictions') {
1245
- const classTag = query.get('class') ?? undefined;
1246
- const status = query.get('status') ?? 'all';
1247
- const limit = parseListLimit(query.get('limit'));
1248
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1249
- let predictions;
1250
- if (status === 'all') {
1251
- if (classTag) {
1252
- predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, { limit });
1253
- }
1254
- else {
1255
- predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, { limit });
1256
- }
1257
- }
1258
- else if (status === 'open') {
1259
- predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, {
1260
- classTag: classTag || undefined,
1261
- limit,
1262
- });
1263
- }
1264
- else {
1265
- if (!isSetMember(VALID_CLOSURE_STATES, status)) {
1266
- throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
1267
- }
1268
- if (!classTag) {
1269
- throw new HttpError(400, 'status filter (non-open) requires class param');
1270
- }
1271
- predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, {
1272
- closureState: status,
1273
- limit,
1274
- });
1275
- }
1276
- sendJson(res, 200, { predictions });
1277
- return;
1278
- }
1279
- // J3 reference-class / planning-fallacy detector (v0.31).
1280
- // Order matters: this must match BEFORE /v1/predictions/:id since 'stats'
1281
- // is not a number — the :id regex requires \d+ so they don't conflict,
1282
- // but routing this first avoids the dispatch order risk.
1283
- if (method === 'GET' && path === '/v1/predictions/stats') {
1284
- const classTag = query.get('class');
1285
- if (!classTag || classTag.length === 0) {
1286
- throw new HttpError(400, 'class param is required');
1287
- }
1288
- if (classTag.length > 256) {
1289
- throw new HttpError(400, 'class exceeds 256-character cap');
1290
- }
1291
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1292
- const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
1293
- sendJson(res, 200, { baserate });
1294
- return;
1295
- }
1296
- const predictionByIdMatch = path.match(/^\/v1\/predictions\/(\d+)$/);
1297
- if (method === 'GET' && predictionByIdMatch) {
1298
- const id = parseInt(predictionByIdMatch[1], 10);
1299
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1300
- const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
1301
- if (!prediction) {
1302
- throw new HttpError(404, `prediction ${id} not found`);
1303
- }
1304
- sendJson(res, 200, { prediction });
1305
- return;
1306
- }
1307
- const predictionCloseMatch = path.match(/^\/v1\/predictions\/(\d+)\/close$/);
1308
- if (method === 'POST' && predictionCloseMatch) {
1309
- const id = parseInt(predictionCloseMatch[1], 10);
1310
- const body = await parseJsonBody(req);
1311
- const state = body['state'];
1312
- if (!isJsonString(state) || !isSetMember(VALID_CLOSURE_STATES, state) || state === 'open') {
1313
- throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
1314
- }
1315
- const actual = body['actual'];
1316
- let actualValue;
1317
- if (actual !== undefined && actual !== null) {
1318
- if (!isJsonNumber(actual) || !Number.isFinite(actual)) {
1319
- throw new HttpError(400, 'actual must be a finite number');
1320
- }
1321
- actualValue = actual;
1322
- }
1323
- const note = body['note'];
1324
- let closureNote;
1325
- if (note !== undefined && note !== null) {
1326
- if (!isJsonString(note)) {
1327
- throw new HttpError(400, 'note must be a string');
1328
- }
1329
- if (note.length > 2048) {
1330
- throw new HttpError(400, 'note exceeds 2048-character cap');
1331
- }
1332
- closureNote = note;
1333
- }
1334
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1335
- try {
1336
- const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1337
- closureState: state,
1338
- actualValue,
1339
- closureNote,
1340
- }, ctx.actor.subject);
1341
- sendJson(res, 200, { prediction });
1342
- }
1343
- catch (e) {
1344
- const msg = e instanceof Error ? e.message : String(e);
1345
- if (msg.includes('not found')) {
1346
- throw new HttpError(404, msg);
1347
- }
1348
- throw e;
1349
- }
1350
- return;
1351
- }
1352
- // ── decisions (E2 first-class object) ──
1353
- //
1354
- // 5 routes: POST /v1/decisions (create, optional supersedesDecisionId),
1355
- // GET /v1/decisions (list, status filter), GET /v1/decisions/:id (show),
1356
- // POST /v1/decisions/:id/supersede (create a successor + supersede :id),
1357
- // POST /v1/decisions/:id/close (retire). Bearer-authed + tenant-scoped via
1358
- // buildContextWithAuth. status validated against VALID_DECISION_STATES.
1359
- // DoS caps: text 4096, context 4096 (v1.11.4 pattern). The HTTP surface is
1360
- // new (no legacy --supersedes <memory-id> constraint), so it supersedes by
1361
- // table id and never weakens a memory mirror.
1362
- if (method === 'POST' && path === '/v1/decisions') {
1363
- const body = await parseJsonBody(req);
1364
- const text = body['text'];
1365
- if (!isJsonString(text) || text.length === 0) {
1366
- throw new HttpError(400, 'text is required (non-empty string)');
1367
- }
1368
- if (text.length > 4096) {
1369
- throw new HttpError(400, 'text exceeds 4096-character cap');
1370
- }
1371
- const contextRaw = body['context'];
1372
- let context;
1373
- if (contextRaw !== undefined && contextRaw !== null) {
1374
- if (!isJsonString(contextRaw)) {
1375
- throw new HttpError(400, 'context must be a string');
1376
- }
1377
- if (contextRaw.length > 4096) {
1378
- throw new HttpError(400, 'context exceeds 4096-character cap');
1379
- }
1380
- context = contextRaw;
1381
- }
1382
- const supRaw = body['supersedesDecisionId'];
1383
- let supersedesDecisionId;
1384
- if (supRaw !== undefined && supRaw !== null) {
1385
- if (!isJsonNumber(supRaw) || !Number.isInteger(supRaw) || supRaw <= 0) {
1386
- throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
1387
- }
1388
- supersedesDecisionId = supRaw;
1389
- }
1390
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1391
- try {
1392
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1393
- decisionText: text,
1394
- context,
1395
- supersedesDecisionId,
1396
- }, ctx.actor.subject);
1397
- sendJson(res, 201, { decision });
1398
- }
1399
- catch (e) {
1400
- const msg = e instanceof Error ? e.message : String(e);
1401
- if (msg.includes('not found') || msg.includes('not active')) {
1402
- throw new HttpError(409, msg);
1403
- }
1404
- throw e;
1405
- }
1406
- return;
1407
- }
1408
- if (method === 'GET' && path === '/v1/decisions') {
1409
- const status = query.get('status') ?? 'all';
1410
- const limit = parseListLimit(query.get('limit'));
1411
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1412
- let decisions;
1413
- if (status === 'all') {
1414
- decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
1415
- }
1416
- else {
1417
- if (!isSetMember(VALID_DECISION_STATES, status)) {
1418
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1419
- }
1420
- decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
1421
- status,
1422
- limit,
1423
- });
1424
- }
1425
- sendJson(res, 200, { decisions });
1426
- return;
1427
- }
1428
- const decisionSupersedeMatch = path.match(/^\/v1\/decisions\/(\d+)\/supersede$/);
1429
- if (method === 'POST' && decisionSupersedeMatch) {
1430
- const oldId = parseInt(decisionSupersedeMatch[1], 10);
1431
- const body = await parseJsonBody(req);
1432
- const text = body['text'];
1433
- if (!isJsonString(text) || text.length === 0) {
1434
- throw new HttpError(400, 'text is required (non-empty string)');
1435
- }
1436
- if (text.length > 4096) {
1437
- throw new HttpError(400, 'text exceeds 4096-character cap');
1438
- }
1439
- const contextRaw = body['context'];
1440
- let context;
1441
- if (contextRaw !== undefined && contextRaw !== null) {
1442
- if (!isJsonString(contextRaw)) {
1443
- throw new HttpError(400, 'context must be a string');
1444
- }
1445
- if (contextRaw.length > 4096) {
1446
- throw new HttpError(400, 'context exceeds 4096-character cap');
1447
- }
1448
- context = contextRaw;
1449
- }
1450
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1451
- try {
1452
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1453
- decisionText: text,
1454
- context,
1455
- supersedesDecisionId: oldId,
1456
- }, ctx.actor.subject);
1457
- sendJson(res, 201, { decision });
1458
- }
1459
- catch (e) {
1460
- const msg = e instanceof Error ? e.message : String(e);
1461
- if (msg.includes('not found')) {
1462
- throw new HttpError(404, msg);
1463
- }
1464
- if (msg.includes('not active')) {
1465
- throw new HttpError(409, msg);
1466
- }
1467
- throw e;
1468
- }
1469
- return;
1470
- }
1471
- const decisionCloseMatch = path.match(/^\/v1\/decisions\/(\d+)\/close$/);
1472
- if (method === 'POST' && decisionCloseMatch) {
1473
- const id = parseInt(decisionCloseMatch[1], 10);
1474
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1475
- try {
1476
- const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1477
- sendJson(res, 200, { decision });
1478
- }
1479
- catch (e) {
1480
- const msg = e instanceof Error ? e.message : String(e);
1481
- if (msg.includes('not found')) {
1482
- throw new HttpError(404, msg);
1483
- }
1484
- if (msg.includes('not active')) {
1485
- throw new HttpError(409, msg);
1486
- }
1487
- throw e;
1488
- }
1489
- return;
1490
- }
1491
- const decisionByIdMatch = path.match(/^\/v1\/decisions\/(\d+)$/);
1492
- if (method === 'GET' && decisionByIdMatch) {
1493
- const id = parseInt(decisionByIdMatch[1], 10);
1494
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1495
- const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
1496
- if (!decision) {
1497
- throw new HttpError(404, `decision ${id} not found`);
1498
- }
1499
- sendJson(res, 200, { decision });
1500
- return;
1501
- }
1502
- // ── incidents (E2 first-class object) ──
1503
- //
1504
- // 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
1505
- // GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
1506
- // POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
1507
- // POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
1508
- // tenant-scoped via buildContextWithAuth. status validated against
1509
- // VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
1510
- // 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
1511
- // open->resolved->closed (no supersede), so linkedMemoryIds replaces
1512
- // supersedesDecisionId on create.
1513
- if (method === 'POST' && path === '/v1/incidents') {
1514
- const body = await parseJsonBody(req);
1515
- const text = body['text'];
1516
- if (!isJsonString(text) || text.length === 0) {
1517
- throw new HttpError(400, 'text is required (non-empty string)');
1518
- }
1519
- if (text.length > 4096) {
1520
- throw new HttpError(400, 'text exceeds 4096-character cap');
1521
- }
1522
- const contextRaw = body['context'];
1523
- let context;
1524
- if (contextRaw !== undefined && contextRaw !== null) {
1525
- if (!isJsonString(contextRaw)) {
1526
- throw new HttpError(400, 'context must be a string');
1527
- }
1528
- if (contextRaw.length > 4096) {
1529
- throw new HttpError(400, 'context exceeds 4096-character cap');
1530
- }
1531
- context = contextRaw;
1532
- }
1533
- const linkedRaw = body['linkedMemoryIds'];
1534
- let linkedMemoryIds;
1535
- if (linkedRaw !== undefined && linkedRaw !== null) {
1536
- if (!Array.isArray(linkedRaw)) {
1537
- throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
1538
- }
1539
- if (linkedRaw.length > 256) {
1540
- throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
1541
- }
1542
- const isValidMemoryId = (item) => isJsonString(item) && item.length > 0 && item.length <= 4096;
1543
- if (!linkedRaw.every(isValidMemoryId)) {
1544
- throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
1545
- }
1546
- linkedMemoryIds = linkedRaw;
1547
- }
1548
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1549
- try {
1550
- const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
1551
- incidentText: text,
1552
- context,
1553
- linkedMemoryIds,
1554
- }, ctx.actor.subject);
1555
- sendJson(res, 201, { incident });
1556
- }
1557
- catch (e) {
1558
- const msg = e instanceof Error ? e.message : String(e);
1559
- if (msg.includes('not found')) {
1560
- throw new HttpError(409, msg);
1561
- }
1562
- throw e;
1563
- }
1564
- return;
1565
- }
1566
- if (method === 'GET' && path === '/v1/incidents') {
1567
- const status = query.get('status') ?? 'all';
1568
- const limit = parseListLimit(query.get('limit'));
1569
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1570
- let incidents;
1571
- if (status === 'all') {
1572
- incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
1573
- }
1574
- else {
1575
- if (!isSetMember(VALID_INCIDENT_STATES, status)) {
1576
- throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
1577
- }
1578
- incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
1579
- status,
1580
- limit,
1581
- });
1582
- }
1583
- sendJson(res, 200, { incidents });
1584
- return;
1585
- }
1586
- const incidentResolveMatch = path.match(/^\/v1\/incidents\/(\d+)\/resolve$/);
1587
- if (method === 'POST' && incidentResolveMatch) {
1588
- const id = parseInt(incidentResolveMatch[1], 10);
1589
- const body = await parseJsonBody(req);
1590
- const resolutionText = body['resolutionText'];
1591
- if (!isJsonString(resolutionText) || resolutionText.trim().length === 0) {
1592
- throw new HttpError(400, 'resolutionText is required (non-empty string)');
1593
- }
1594
- if (resolutionText.length > 4096) {
1595
- throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
1596
- }
1597
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1598
- try {
1599
- const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1600
- sendJson(res, 200, { incident });
1601
- }
1602
- catch (e) {
1603
- const msg = e instanceof Error ? e.message : String(e);
1604
- if (msg.includes('not found')) {
1605
- throw new HttpError(404, msg);
1606
- }
1607
- if (msg.includes('not open')) {
1608
- throw new HttpError(409, msg);
1609
- }
1610
- throw e;
1611
- }
1612
- return;
1613
- }
1614
- const incidentCloseMatch = path.match(/^\/v1\/incidents\/(\d+)\/close$/);
1615
- if (method === 'POST' && incidentCloseMatch) {
1616
- const id = parseInt(incidentCloseMatch[1], 10);
1617
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1618
- try {
1619
- const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1620
- sendJson(res, 200, { incident });
1621
- }
1622
- catch (e) {
1623
- const msg = e instanceof Error ? e.message : String(e);
1624
- if (msg.includes('not found')) {
1625
- throw new HttpError(404, msg);
1626
- }
1627
- if (msg.includes('already closed')) {
1628
- throw new HttpError(409, msg);
1629
- }
1630
- throw e;
1631
- }
1632
- return;
1633
- }
1634
- const incidentByIdMatch = path.match(/^\/v1\/incidents\/(\d+)$/);
1635
- if (method === 'GET' && incidentByIdMatch) {
1636
- const id = parseInt(incidentByIdMatch[1], 10);
1637
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1638
- const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
1639
- if (!incident) {
1640
- throw new HttpError(404, `incident ${id} not found`);
1641
- }
1642
- sendJson(res, 200, { incident });
1643
- return;
1644
- }
1645
- // ── processes (E2 first-class object) ──
1646
- //
1647
- // 5 routes: POST /v1/processes (new; body processName + steps[] + description),
1648
- // GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
1649
- // POST /v1/processes/:id/supersede (active -> superseded by a new version; body
1650
- // steps[] + changeSummary + description; reuses the predecessor's name),
1651
- // POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
1652
- // via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
1653
- // caps: processName/description/changeSummary 4096, steps 200x2000
1654
- // (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
1655
- // decision supersede path.
1656
- if (method === 'POST' && path === '/v1/processes') {
1657
- const body = await parseJsonBody(req);
1658
- const processName = body['processName'];
1659
- if (!isJsonString(processName) || processName.trim().length === 0) {
1660
- throw new HttpError(400, 'processName is required (non-empty string)');
1661
- }
1662
- if (processName.length > 4096) {
1663
- throw new HttpError(400, 'processName exceeds 4096-character cap');
1664
- }
1665
- const steps = validateProcessStepsBody(body['steps']);
1666
- const descriptionRaw = body['description'];
1667
- let description;
1668
- if (descriptionRaw !== undefined && descriptionRaw !== null) {
1669
- if (!isJsonString(descriptionRaw)) {
1670
- throw new HttpError(400, 'description must be a string');
1671
- }
1672
- if (descriptionRaw.length > 4096) {
1673
- throw new HttpError(400, 'description exceeds 4096-character cap');
1674
- }
1675
- description = descriptionRaw;
1676
- }
1677
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1678
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1679
- processName,
1680
- steps,
1681
- description,
1682
- }, ctx.actor.subject);
1683
- sendJson(res, 201, { process });
1684
- return;
1685
- }
1686
- if (method === 'GET' && path === '/v1/processes') {
1687
- const status = query.get('status') ?? 'all';
1688
- const limit = parseListLimit(query.get('limit'));
1689
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1690
- let processes;
1691
- if (status === 'all') {
1692
- processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
1693
- }
1694
- else {
1695
- if (!isSetMember(VALID_PROCESS_STATES, status)) {
1696
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1697
- }
1698
- processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
1699
- status,
1700
- limit,
1701
- });
1702
- }
1703
- sendJson(res, 200, { processes });
1704
- return;
1705
- }
1706
- const processSupersedeMatch = path.match(/^\/v1\/processes\/(\d+)\/supersede$/);
1707
- if (method === 'POST' && processSupersedeMatch) {
1708
- const id = parseInt(processSupersedeMatch[1], 10);
1709
- const body = await parseJsonBody(req);
1710
- const steps = validateProcessStepsBody(body['steps']);
1711
- if (steps.length === 0) {
1712
- throw new HttpError(400, 'steps is required (at least one step) for a supersession');
1713
- }
1714
- const changeRaw = body['changeSummary'];
1715
- let changeSummary;
1716
- if (changeRaw !== undefined && changeRaw !== null) {
1717
- if (!isJsonString(changeRaw)) {
1718
- throw new HttpError(400, 'changeSummary must be a string');
1719
- }
1720
- if (changeRaw.length > 4096) {
1721
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1722
- }
1723
- changeSummary = changeRaw;
1724
- }
1725
- const descRaw = body['description'];
1726
- let description;
1727
- if (descRaw !== undefined && descRaw !== null) {
1728
- if (!isJsonString(descRaw)) {
1729
- throw new HttpError(400, 'description must be a string');
1730
- }
1731
- if (descRaw.length > 4096) {
1732
- throw new HttpError(400, 'description exceeds 4096-character cap');
1733
- }
1734
- description = descRaw;
1735
- }
1736
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1737
- // A supersession is a new version of the SAME process: reuse the
1738
- // predecessor's name. 404 if the target does not exist; saveProcess's
1739
- // in-SAVEPOINT preflight is the authoritative active-state check (409).
1740
- const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1741
- if (!existing) {
1742
- throw new HttpError(404, `process ${id} not found`);
1743
- }
1744
- try {
1745
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1746
- processName: existing.processName,
1747
- steps,
1748
- description,
1749
- changeSummary,
1750
- supersedesProcessId: id,
1751
- }, ctx.actor.subject);
1752
- sendJson(res, 200, { process });
1753
- }
1754
- catch (e) {
1755
- const msg = e instanceof Error ? e.message : String(e);
1756
- if (msg.includes('not found')) {
1757
- throw new HttpError(404, msg);
1758
- }
1759
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
1760
- throw new HttpError(409, msg);
1761
- }
1762
- throw e;
1763
- }
1764
- return;
1765
- }
1766
- const processCloseMatch = path.match(/^\/v1\/processes\/(\d+)\/close$/);
1767
- if (method === 'POST' && processCloseMatch) {
1768
- const id = parseInt(processCloseMatch[1], 10);
1769
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1770
- try {
1771
- const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1772
- sendJson(res, 200, { process });
1773
- }
1774
- catch (e) {
1775
- const msg = e instanceof Error ? e.message : String(e);
1776
- if (msg.includes('not found')) {
1777
- throw new HttpError(404, msg);
1778
- }
1779
- if (msg.includes('not active')) {
1780
- throw new HttpError(409, msg);
1781
- }
1782
- throw e;
1783
- }
1784
- return;
1785
- }
1786
- const processByIdMatch = path.match(/^\/v1\/processes\/(\d+)$/);
1787
- if (method === 'GET' && processByIdMatch) {
1788
- const id = parseInt(processByIdMatch[1], 10);
1789
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1790
- const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1791
- if (!process) {
1792
- throw new HttpError(404, `process ${id} not found`);
1793
- }
1794
- sendJson(res, 200, { process });
1795
- return;
1796
- }
1797
- // ── policies (E2 first-class object, bi-temporal-first) ──
1798
- //
1799
- // 6 routes: POST /v1/policies (new; processName-style body policyName +
1800
- // policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
1801
- // GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
1802
- // placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
1803
- // /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
1804
- // Date inputs are normalized + range-validated in the store; an invalid/inverted
1805
- // date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
1806
- if (method === 'POST' && path === '/v1/policies') {
1807
- const body = await parseJsonBody(req);
1808
- const policyName = body['policyName'];
1809
- if (!isJsonString(policyName) || policyName.trim().length === 0) {
1810
- throw new HttpError(400, 'policyName is required (non-empty string)');
1811
- }
1812
- if (policyName.length > 4096) {
1813
- throw new HttpError(400, 'policyName exceeds 4096-character cap');
1814
- }
1815
- const policyText = body['policyText'];
1816
- if (!isJsonString(policyText) || policyText.trim().length === 0) {
1817
- throw new HttpError(400, 'policyText is required (non-empty string)');
1818
- }
1819
- if (policyText.length > 4096) {
1820
- throw new HttpError(400, 'policyText exceeds 4096-character cap');
1821
- }
1822
- const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1823
- const validTo = optionalDateField(body['validTo'], 'validTo');
1824
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1825
- try {
1826
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1827
- policyName,
1828
- policyText,
1829
- validFrom,
1830
- validTo,
1831
- }, ctx.actor.subject);
1832
- sendJson(res, 201, { policy });
1833
- }
1834
- catch (e) {
1835
- // savePolicy throws on invalid/inverted dates (validation) -> 400.
1836
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
1837
- }
1838
- return;
1839
- }
1840
- if (method === 'GET' && path === '/v1/policies') {
1841
- const status = query.get('status') ?? 'all';
1842
- const limit = parseListLimit(query.get('limit'));
1843
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1844
- let policies;
1845
- if (status === 'all') {
1846
- policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
1847
- }
1848
- else {
1849
- if (!isSetMember(VALID_POLICY_STATES, status)) {
1850
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1851
- }
1852
- policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
1853
- status,
1854
- limit,
1855
- });
1856
- }
1857
- sendJson(res, 200, { policies });
1858
- return;
1859
- }
1860
- // The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
1861
- // the /(\d+)/ route would not match it, but order it first for clarity).
1862
- if (method === 'GET' && path === '/v1/policies/asof') {
1863
- const date = query.get('date');
1864
- if (date === null || date.length === 0) {
1865
- throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
1866
- }
1867
- const name = query.get('name') ?? undefined;
1868
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1869
- try {
1870
- const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1871
- sendJson(res, 200, { policies });
1872
- }
1873
- catch (e) {
1874
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
1875
- }
1876
- return;
1877
- }
1878
- const policySupersedeMatch = path.match(/^\/v1\/policies\/(\d+)\/supersede$/);
1879
- if (method === 'POST' && policySupersedeMatch) {
1880
- const id = parseInt(policySupersedeMatch[1], 10);
1881
- const body = await parseJsonBody(req);
1882
- const policyText = body['policyText'];
1883
- if (!isJsonString(policyText) || policyText.trim().length === 0) {
1884
- throw new HttpError(400, 'policyText is required (non-empty string)');
1885
- }
1886
- if (policyText.length > 4096) {
1887
- throw new HttpError(400, 'policyText exceeds 4096-character cap');
1888
- }
1889
- const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1890
- const validTo = optionalDateField(body['validTo'], 'validTo');
1891
- const changeRaw = body['changeSummary'];
1892
- let changeSummary;
1893
- if (changeRaw !== undefined && changeRaw !== null) {
1894
- if (!isJsonString(changeRaw)) {
1895
- throw new HttpError(400, 'changeSummary must be a string');
1896
- }
1897
- if (changeRaw.length > 4096) {
1898
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1899
- }
1900
- changeSummary = changeRaw;
1901
- }
1902
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1903
- const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1904
- if (!existing) {
1905
- throw new HttpError(404, `policy ${id} not found`);
1906
- }
1907
- try {
1908
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1909
- policyName: existing.policyName,
1910
- policyText,
1911
- validFrom,
1912
- validTo,
1913
- changeSummary,
1914
- supersedesPolicyId: id,
1915
- }, ctx.actor.subject);
1916
- sendJson(res, 200, { policy });
1917
- }
1918
- catch (e) {
1919
- const msg = e instanceof Error ? e.message : String(e);
1920
- if (msg.includes('not found')) {
1921
- throw new HttpError(404, msg);
1922
- }
1923
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
1924
- throw new HttpError(409, msg);
1925
- }
1926
- // invalid/inverted date or missing field -> validation.
1927
- throw new HttpError(400, msg);
1928
- }
1929
- return;
1930
- }
1931
- const policyCloseMatch = path.match(/^\/v1\/policies\/(\d+)\/close$/);
1932
- if (method === 'POST' && policyCloseMatch) {
1933
- const id = parseInt(policyCloseMatch[1], 10);
1934
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1935
- try {
1936
- const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1937
- sendJson(res, 200, { policy });
1938
- }
1939
- catch (e) {
1940
- const msg = e instanceof Error ? e.message : String(e);
1941
- if (msg.includes('not found')) {
1942
- throw new HttpError(404, msg);
1943
- }
1944
- if (msg.includes('not active')) {
1945
- throw new HttpError(409, msg);
1946
- }
1947
- throw e;
1948
- }
1949
- return;
1950
- }
1951
- const policyByIdMatch = path.match(/^\/v1\/policies\/(\d+)$/);
1952
- if (method === 'GET' && policyByIdMatch) {
1953
- const id = parseInt(policyByIdMatch[1], 10);
1954
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1955
- const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1956
- if (!policy) {
1957
- throw new HttpError(404, `policy ${id} not found`);
1958
- }
1959
- sendJson(res, 200, { policy });
1960
- return;
1961
- }
1962
- // ── skills (E2 first-class object, executable/exportable) ──
1963
- //
1964
- // 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
1965
- // GET /v1/skills (list, status filter; shared parseListLimit), GET
1966
- // /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
1967
- // block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
1968
- // cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
1969
- // POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
1970
- // skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
1971
- // validates + throws; the boundary maps validation -> 400, not-found -> 404,
1972
- // not-active -> 409. Mirrors /v1/processes; "executable" = exportable
1973
- // instruction (no code exec).
1974
- if (method === 'POST' && path === '/v1/skills') {
1975
- const body = await parseJsonBody(req);
1976
- const skillName = body['skillName'];
1977
- if (!isJsonString(skillName) || skillName.trim().length === 0) {
1978
- throw new HttpError(400, 'skillName is required (non-empty string)');
1979
- }
1980
- if (skillName.length > 256) {
1981
- throw new HttpError(400, 'skillName exceeds 256-character cap');
1982
- }
1983
- const instructions = body['instructions'];
1984
- if (!isJsonString(instructions) || instructions.trim().length === 0) {
1985
- throw new HttpError(400, 'instructions are required (non-empty string)');
1986
- }
1987
- if (instructions.length > 8192) {
1988
- throw new HttpError(400, 'instructions exceed 8192-character cap');
1989
- }
1990
- const triggerRaw = body['trigger'];
1991
- let trigger;
1992
- if (triggerRaw !== undefined && triggerRaw !== null) {
1993
- if (!isJsonString(triggerRaw)) {
1994
- throw new HttpError(400, 'trigger must be a string');
1995
- }
1996
- if (triggerRaw.length > 1024) {
1997
- throw new HttpError(400, 'trigger exceeds 1024-character cap');
1998
- }
1999
- trigger = triggerRaw;
2000
- }
2001
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2002
- try {
2003
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2004
- skillName,
2005
- instructions,
2006
- trigger,
2007
- }, ctx.actor.subject);
2008
- sendJson(res, 201, { skill });
2009
- }
2010
- catch (e) {
2011
- // saveSkill throws on validation (single-line name etc.) -> 400.
2012
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2013
- }
2014
- return;
2015
- }
2016
- if (method === 'GET' && path === '/v1/skills') {
2017
- const status = query.get('status') ?? 'all';
2018
- const limit = parseListLimit(query.get('limit'));
2019
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2020
- let skills;
2021
- if (status === 'all') {
2022
- skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
2023
- }
2024
- else {
2025
- if (!isSetMember(VALID_SKILL_STATES, status)) {
2026
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2027
- }
2028
- skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
2029
- status,
2030
- limit,
2031
- });
2032
- }
2033
- sendJson(res, 200, { skills });
2034
- return;
2035
- }
2036
- // The export renderer: must precede the /:id GET (literal 'export' is
2037
- // non-numeric so the /(\d+)/ route would not match it, but order it first).
2038
- if (method === 'GET' && path === '/v1/skills/export') {
2039
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2040
- const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
2041
- sendJson(res, 200, { markdown });
2042
- return;
2043
- }
2044
- const skillSupersedeMatch = path.match(/^\/v1\/skills\/(\d+)\/supersede$/);
2045
- if (method === 'POST' && skillSupersedeMatch) {
2046
- const id = parseInt(skillSupersedeMatch[1], 10);
2047
- const body = await parseJsonBody(req);
2048
- const instructions = body['instructions'];
2049
- if (!isJsonString(instructions) || instructions.trim().length === 0) {
2050
- throw new HttpError(400, 'instructions are required (non-empty string)');
2051
- }
2052
- if (instructions.length > 8192) {
2053
- throw new HttpError(400, 'instructions exceed 8192-character cap');
2054
- }
2055
- const triggerRaw = body['trigger'];
2056
- let trigger;
2057
- if (triggerRaw !== undefined && triggerRaw !== null) {
2058
- if (!isJsonString(triggerRaw)) {
2059
- throw new HttpError(400, 'trigger must be a string');
2060
- }
2061
- if (triggerRaw.length > 1024) {
2062
- throw new HttpError(400, 'trigger exceeds 1024-character cap');
2063
- }
2064
- trigger = triggerRaw;
2065
- }
2066
- const changeRaw = body['changeSummary'];
2067
- let changeSummary;
2068
- if (changeRaw !== undefined && changeRaw !== null) {
2069
- if (!isJsonString(changeRaw)) {
2070
- throw new HttpError(400, 'changeSummary must be a string');
2071
- }
2072
- if (changeRaw.length > 4096) {
2073
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2074
- }
2075
- changeSummary = changeRaw;
2076
- }
2077
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2078
- const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2079
- if (!existing) {
2080
- throw new HttpError(404, `skill ${id} not found`);
2081
- }
2082
- try {
2083
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2084
- skillName: existing.skillName,
2085
- instructions,
2086
- trigger,
2087
- changeSummary,
2088
- supersedesSkillId: id,
2089
- }, ctx.actor.subject);
2090
- sendJson(res, 200, { skill });
2091
- }
2092
- catch (e) {
2093
- const msg = e instanceof Error ? e.message : String(e);
2094
- if (msg.includes('not found')) {
2095
- throw new HttpError(404, msg);
2096
- }
2097
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2098
- throw new HttpError(409, msg);
2099
- }
2100
- throw new HttpError(400, msg);
2101
- }
2102
- return;
2103
- }
2104
- const skillCloseMatch = path.match(/^\/v1\/skills\/(\d+)\/close$/);
2105
- if (method === 'POST' && skillCloseMatch) {
2106
- const id = parseInt(skillCloseMatch[1], 10);
2107
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2108
- try {
2109
- const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2110
- sendJson(res, 200, { skill });
2111
- }
2112
- catch (e) {
2113
- const msg = e instanceof Error ? e.message : String(e);
2114
- if (msg.includes('not found')) {
2115
- throw new HttpError(404, msg);
2116
- }
2117
- if (msg.includes('not active')) {
2118
- throw new HttpError(409, msg);
2119
- }
2120
- throw e;
2121
- }
2122
- return;
2123
- }
2124
- const skillByIdMatch = path.match(/^\/v1\/skills\/(\d+)$/);
2125
- if (method === 'GET' && skillByIdMatch) {
2126
- const id = parseInt(skillByIdMatch[1], 10);
2127
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2128
- const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2129
- if (!skill) {
2130
- throw new HttpError(404, `skill ${id} not found`);
2131
- }
2132
- sendJson(res, 200, { skill });
2133
- return;
2134
- }
2135
- // ── E2 project_brief routes ──
2136
- //
2137
- // 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
2138
- // /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
2139
- // /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
2140
- // from the repo's receipts; dryRun returns {markdown} without writing; ordered
2141
- // before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
2142
- // POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
2143
- // changeSummary 4096. The store validates + throws; the boundary maps validation
2144
- // -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
2145
- if (method === 'POST' && path === '/v1/project-briefs') {
2146
- const body = await parseJsonBody(req);
2147
- const repo = body['repo'];
2148
- if (!isJsonString(repo) || repo.trim().length === 0) {
2149
- throw new HttpError(400, 'repo is required (non-empty string)');
2150
- }
2151
- if (repo.length > 256) {
2152
- throw new HttpError(400, 'repo exceeds 256-character cap');
2153
- }
2154
- const summary = body['summary'];
2155
- if (!isJsonString(summary) || summary.trim().length === 0) {
2156
- throw new HttpError(400, 'summary is required (non-empty string)');
2157
- }
2158
- if (summary.length > 8192) {
2159
- throw new HttpError(400, 'summary exceeds 8192-character cap');
2160
- }
2161
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2162
- try {
2163
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2164
- repo,
2165
- summary,
2166
- }, ctx.actor.subject);
2167
- sendJson(res, 201, { brief });
2168
- }
2169
- catch (e) {
2170
- // saveProjectBrief throws on validation (single-line repo etc.) -> 400.
2171
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2172
- }
2173
- return;
2174
- }
2175
- if (method === 'GET' && path === '/v1/project-briefs') {
2176
- const status = query.get('status') ?? 'all';
2177
- const repoFilter = query.get('repo');
2178
- const limit = parseListLimit(query.get('limit'));
2179
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2180
- const listOpts = { limit };
2181
- if (repoFilter !== null && repoFilter.trim().length > 0) {
2182
- listOpts.repo = repoFilter.trim();
2183
- }
2184
- if (status !== 'all') {
2185
- if (!isSetMember(VALID_BRIEF_STATES, status)) {
2186
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2187
- }
2188
- listOpts.status = status;
2189
- }
2190
- const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
2191
- sendJson(res, 200, { briefs });
2192
- return;
2193
- }
2194
- // The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
2195
- // so the /(\d+)/ routes would not match it, but order it first).
2196
- if (method === 'POST' && path === '/v1/project-briefs/refresh') {
2197
- const body = await parseJsonBody(req);
2198
- const repo = body['repo'];
2199
- if (!isJsonString(repo) || repo.trim().length === 0) {
2200
- throw new HttpError(400, 'repo is required (non-empty string)');
2201
- }
2202
- if (repo.length > 256) {
2203
- throw new HttpError(400, 'repo exceeds 256-character cap');
2204
- }
2205
- const dryRun = body['dryRun'] === true;
2206
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2207
- try {
2208
- if (dryRun) {
2209
- const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2210
- sendJson(res, 200, { markdown, receiptCount });
2211
- return;
2212
- }
2213
- const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2214
- sendJson(res, 200, { brief });
2215
- }
2216
- catch (e) {
2217
- // A refresh race (the active brief is closed/superseded between
2218
- // loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
2219
- // validation error — map it to 409 like the explicit supersede route
2220
- // (codex-review 2026-05-30, P3).
2221
- const msg = e instanceof Error ? e.message : String(e);
2222
- if (msg.includes('not found')) {
2223
- throw new HttpError(404, msg);
2224
- }
2225
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2226
- throw new HttpError(409, msg);
2227
- }
2228
- throw new HttpError(400, msg);
2229
- }
2230
- return;
2231
- }
2232
- const briefSupersedeMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/supersede$/);
2233
- if (method === 'POST' && briefSupersedeMatch) {
2234
- const id = parseInt(briefSupersedeMatch[1], 10);
2235
- const body = await parseJsonBody(req);
2236
- const summary = body['summary'];
2237
- if (!isJsonString(summary) || summary.trim().length === 0) {
2238
- throw new HttpError(400, 'summary is required (non-empty string)');
2239
- }
2240
- if (summary.length > 8192) {
2241
- throw new HttpError(400, 'summary exceeds 8192-character cap');
2242
- }
2243
- const changeRaw = body['changeSummary'];
2244
- let changeSummary;
2245
- if (changeRaw !== undefined && changeRaw !== null) {
2246
- if (!isJsonString(changeRaw)) {
2247
- throw new HttpError(400, 'changeSummary must be a string');
2248
- }
2249
- if (changeRaw.length > 4096) {
2250
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2251
- }
2252
- changeSummary = changeRaw;
2253
- }
2254
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2255
- const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2256
- if (!existing) {
2257
- throw new HttpError(404, `project brief ${id} not found`);
2258
- }
2259
- try {
2260
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2261
- repo: existing.repo,
2262
- summary,
2263
- changeSummary,
2264
- supersedesBriefId: id,
2265
- }, ctx.actor.subject);
2266
- sendJson(res, 200, { brief });
2267
- }
2268
- catch (e) {
2269
- const msg = e instanceof Error ? e.message : String(e);
2270
- if (msg.includes('not found')) {
2271
- throw new HttpError(404, msg);
2272
- }
2273
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2274
- throw new HttpError(409, msg);
2275
- }
2276
- throw new HttpError(400, msg);
2277
- }
2278
- return;
2279
- }
2280
- const briefCloseMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/close$/);
2281
- if (method === 'POST' && briefCloseMatch) {
2282
- const id = parseInt(briefCloseMatch[1], 10);
2283
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2284
- try {
2285
- const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2286
- sendJson(res, 200, { brief });
2287
- }
2288
- catch (e) {
2289
- const msg = e instanceof Error ? e.message : String(e);
2290
- if (msg.includes('not found')) {
2291
- throw new HttpError(404, msg);
2292
- }
2293
- if (msg.includes('not active')) {
2294
- throw new HttpError(409, msg);
2295
- }
2296
- throw e;
2297
- }
2298
- return;
2299
- }
2300
- const briefByIdMatch = path.match(/^\/v1\/project-briefs\/(\d+)$/);
2301
- if (method === 'GET' && briefByIdMatch) {
2302
- const id = parseInt(briefByIdMatch[1], 10);
2303
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2304
- const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2305
- if (!brief) {
2306
- throw new HttpError(404, `project brief ${id} not found`);
2307
- }
2308
- sendJson(res, 200, { brief });
2309
- return;
2310
- }
2311
- // ── E2 customer_note routes ──
2312
- //
2313
- // 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
2314
- // note), GET /v1/customer-notes (list; status + customer filter; shared
2315
- // parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
2316
- // POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
2317
- // changeSummary 4096. The store validates + throws; the boundary maps validation ->
2318
- // 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
2319
- if (method === 'POST' && path === '/v1/customer-notes') {
2320
- const body = await parseJsonBody(req);
2321
- const customer = body['customer'];
2322
- if (!isJsonString(customer) || customer.trim().length === 0) {
2323
- throw new HttpError(400, 'customer is required (non-empty string)');
2324
- }
2325
- if (customer.length > 256) {
2326
- throw new HttpError(400, 'customer exceeds 256-character cap');
2327
- }
2328
- const note = body['note'];
2329
- if (!isJsonString(note) || note.trim().length === 0) {
2330
- throw new HttpError(400, 'note is required (non-empty string)');
2331
- }
2332
- if (note.length > 8192) {
2333
- throw new HttpError(400, 'note exceeds 8192-character cap');
2334
- }
2335
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2336
- try {
2337
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2338
- customer,
2339
- note,
2340
- }, ctx.actor.subject);
2341
- sendJson(res, 201, { note: customerNote });
2342
- }
2343
- catch (e) {
2344
- // saveCustomerNote throws on validation (single-line customer etc.) -> 400.
2345
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2346
- }
2347
- return;
2348
- }
2349
- if (method === 'GET' && path === '/v1/customer-notes') {
2350
- const status = query.get('status') ?? 'all';
2351
- const customerFilter = query.get('customer');
2352
- const limit = parseListLimit(query.get('limit'));
2353
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2354
- const listOpts = { limit };
2355
- if (customerFilter !== null && customerFilter.trim().length > 0) {
2356
- listOpts.customer = customerFilter.trim();
2357
- }
2358
- if (status !== 'all') {
2359
- if (!isSetMember(VALID_NOTE_STATES, status)) {
2360
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2361
- }
2362
- listOpts.status = status;
2363
- }
2364
- const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
2365
- sendJson(res, 200, { notes });
2366
- return;
2367
- }
2368
- const noteSupersedeMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/supersede$/);
2369
- if (method === 'POST' && noteSupersedeMatch) {
2370
- const id = parseInt(noteSupersedeMatch[1], 10);
2371
- const body = await parseJsonBody(req);
2372
- const note = body['note'];
2373
- if (!isJsonString(note) || note.trim().length === 0) {
2374
- throw new HttpError(400, 'note is required (non-empty string)');
2375
- }
2376
- if (note.length > 8192) {
2377
- throw new HttpError(400, 'note exceeds 8192-character cap');
2378
- }
2379
- const changeRaw = body['changeSummary'];
2380
- let changeSummary;
2381
- if (changeRaw !== undefined && changeRaw !== null) {
2382
- if (!isJsonString(changeRaw)) {
2383
- throw new HttpError(400, 'changeSummary must be a string');
2384
- }
2385
- if (changeRaw.length > 4096) {
2386
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2387
- }
2388
- changeSummary = changeRaw;
2389
- }
2390
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2391
- const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2392
- if (!existing) {
2393
- throw new HttpError(404, `customer note ${id} not found`);
2394
- }
2395
- try {
2396
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2397
- customer: existing.customer,
2398
- note,
2399
- changeSummary,
2400
- supersedesNoteId: id,
2401
- }, ctx.actor.subject);
2402
- sendJson(res, 200, { note: customerNote });
2403
- }
2404
- catch (e) {
2405
- const msg = e instanceof Error ? e.message : String(e);
2406
- if (msg.includes('not found')) {
2407
- throw new HttpError(404, msg);
2408
- }
2409
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2410
- throw new HttpError(409, msg);
2411
- }
2412
- throw new HttpError(400, msg);
2413
- }
2414
- return;
2415
- }
2416
- const noteCloseMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/close$/);
2417
- if (method === 'POST' && noteCloseMatch) {
2418
- const id = parseInt(noteCloseMatch[1], 10);
2419
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2420
- try {
2421
- const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2422
- sendJson(res, 200, { note: customerNote });
2423
- }
2424
- catch (e) {
2425
- const msg = e instanceof Error ? e.message : String(e);
2426
- if (msg.includes('not found')) {
2427
- throw new HttpError(404, msg);
2428
- }
2429
- if (msg.includes('not active')) {
2430
- throw new HttpError(409, msg);
2431
- }
2432
- throw e;
2433
- }
2434
- return;
2435
- }
2436
- const noteByIdMatch = path.match(/^\/v1\/customer-notes\/(\d+)$/);
2437
- if (method === 'GET' && noteByIdMatch) {
2438
- const id = parseInt(noteByIdMatch[1], 10);
2439
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2440
- const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2441
- if (!customerNote) {
2442
- throw new HttpError(404, `customer note ${id} not found`);
2443
- }
2444
- sendJson(res, 200, { note: customerNote });
2445
- return;
2446
- }
2447
- // ── POST /v1/connectors/slack/events ──
2448
- //
2449
- // Slack Events API webhook. Auth is signature-based (HMAC over the raw
2450
- // body with SLACK_SIGNING_SECRET); Bearer is NOT required, which is why
2451
- // this route is in PUBLIC_ROUTES. The route is responsible for:
2452
- // 1. Echoing the one-time url_verification challenge.
2453
- // 2. Verifying the HMAC on every other inbound payload.
2454
- // 3. Resolving body.team_id → tenantId via slack_workspaces, falling
2455
- // back to HIPPO_TENANT then 'default'.
2456
- // 4. Dispatching event_callback envelopes to ingestMessage /
2457
- // handleMessageDeleted.
2458
- // 5. Parking malformed or unhandled payloads in slack_dlq and STILL
2459
- // ACKing 200 — Slack retries forever otherwise.
2460
- //
2461
- // Review patch #7: when SLACK_SIGNING_SECRET is unset we return 404, not
2462
- // 503, so an external probe cannot distinguish "route gated off by config"
2463
- // from "route does not exist on this build".
2464
- if (method === 'POST' && path === '/v1/connectors/slack/events') {
2465
- // Bearer auth deliberately skipped — this route is in PUBLIC_ROUTES
2466
- // and authenticates via the Slack HMAC signature instead.
2467
- if (!isPublicRoute(method, path)) {
2468
- // Defensive: PUBLIC_ROUTES drift would land here. Fail closed.
2469
- throw new HttpError(401, 'auth required');
2470
- }
2471
- const rawBody = await readBody(req);
2472
- const secret = process.env.SLACK_SIGNING_SECRET;
2473
- if (!secret) {
2474
- res.writeHead(404, JSON_HEADERS);
2475
- res.end(JSON.stringify({ error: 'not found' }));
2476
- return;
2477
- }
2478
- const previousSecret = process.env.SLACK_SIGNING_SECRET_PREVIOUS;
2479
- const sig = req.headers['x-slack-signature'];
2480
- const tsHdr = req.headers['x-slack-request-timestamp'];
2481
- const sigStr = isHeaderString(sig) ? sig : null;
2482
- const tsStr = isHeaderString(tsHdr) ? tsHdr : null;
2483
- if (sigStr === null ||
2484
- tsStr === null ||
2485
- !verifySlackSignature({
2486
- rawBody,
2487
- timestamp: tsStr,
2488
- signature: sigStr,
2489
- signingSecret: secret,
2490
- previousSecret,
2491
- })) {
2492
- throw new HttpError(401, 'invalid Slack signature');
2493
- }
2494
- // Cheap regex extracts team_id from a (possibly malformed) raw body so the
2495
- // DLQ row carries it for triage even when JSON.parse fails.
2496
- const teamIdFromRaw = (() => {
2497
- const m = rawBody.match(/"team_id"\s*:\s*"([^"]+)"/);
2498
- return m ? m[1] : null;
2499
- })();
2500
- let body;
2501
- try {
2502
- body = JSON.parse(rawBody);
2503
- }
2504
- catch {
2505
- // v1.12.6 (B4): parse-failure tenant attribution. Pre-fix this path
2506
- // wrote tenant_id=HIPPO_TENANT regardless of the originating workspace,
2507
- // silently routing parse failures from workspace A into the deployment's
2508
- // tenant DLQ. Fix: use the regex-extracted teamIdFromRaw to resolve
2509
- // tenant via the same slack_workspaces table the happy path uses
2510
- // (resolveTenantForTeam at line ~1044). When teamIdFromRaw is null
2511
- // (totally unparseable body) OR the team is unknown, write with
2512
- // tenantId=null so the row lands as '__unroutable__' (matching the
2513
- // existing unroutable bucket convention).
2514
- const db = openHippoDb(opts.hippoRoot);
2515
- try {
2516
- const parseFailTenant = teamIdFromRaw !== null ? resolveTenantForTeam(db, teamIdFromRaw) : null;
2517
- writeToDlq(db, {
2518
- tenantId: parseFailTenant, // null → '__unroutable__' sentinel
2519
- teamId: teamIdFromRaw,
2520
- rawPayload: rawBody,
2521
- error: 'invalid JSON',
2522
- bucket: 'parse_error',
2523
- signature: sigStr,
2524
- slackTimestamp: tsStr,
2525
- });
2526
- }
2527
- finally {
2528
- closeHippoDb(db);
2529
- }
2530
- sendJson(res, 200, { ok: true, status: 'dlq' });
2531
- return;
2532
- }
2533
- if (isJsonObjectRecord(body)) {
2534
- const bodyRecord = body;
2535
- if (bodyRecord.type === 'url_verification') {
2536
- sendJson(res, 200, {
2537
- challenge: String(bodyRecord.challenge ?? ''),
2538
- });
2539
- return;
2540
- }
2541
- }
2542
- // Resolve tenant. v0.39 fail-closed: when slack_workspaces is non-empty
2543
- // and the team_id is unknown, resolveTenantForTeam returns null and we
2544
- // park the envelope in slack_dlq with bucket='unroutable'. Mandatory ACK
2545
- // 200 so Slack stops retrying; do NOT call ingest.
2546
- let resolvedTenant = null;
2547
- if (body !== undefined && isSlackEventEnvelope(body)) {
2548
- const db = openHippoDb(opts.hippoRoot);
2549
- try {
2550
- resolvedTenant = resolveTenantForTeam(db, body.team_id);
2551
- }
2552
- finally {
2553
- closeHippoDb(db);
2554
- }
2555
- if (resolvedTenant === null) {
2556
- const db2 = openHippoDb(opts.hippoRoot);
2557
- try {
2558
- writeToDlq(db2, {
2559
- tenantId: null, // unroutable — stored as '__unroutable__'
2560
- teamId: body.team_id,
2561
- rawPayload: rawBody,
2562
- error: `unroutable team_id: ${body.team_id}`,
2563
- bucket: 'unroutable',
2564
- signature: sigStr,
2565
- slackTimestamp: tsStr,
2566
- });
2567
- }
2568
- finally {
2569
- closeHippoDb(db2);
2570
- }
2571
- sendJson(res, 200, { ok: true, status: 'dlq' });
2572
- return;
2573
- }
2574
- }
2575
- else {
2576
- // Non-envelope payload: use env tenant for the DLQ row's bookkeeping.
2577
- resolvedTenant = process.env.HIPPO_TENANT ?? 'default';
2578
- }
2579
- const ctx = {
2580
- hippoRoot: opts.hippoRoot,
2581
- tenantId: resolvedTenant,
2582
- actor: adminActor('connector:slack'),
2583
- };
2584
- if (body === undefined || !isSlackEventEnvelope(body)) {
2585
- const db = openHippoDb(ctx.hippoRoot);
2586
- try {
2587
- writeToDlq(db, {
2588
- tenantId: ctx.tenantId,
2589
- teamId: teamIdFromRaw,
2590
- rawPayload: rawBody,
2591
- error: 'not an event_callback envelope',
2592
- bucket: 'parse_error',
2593
- signature: sigStr,
2594
- slackTimestamp: tsStr,
2595
- });
2596
- }
2597
- finally {
2598
- closeHippoDb(db);
2599
- }
2600
- sendJson(res, 200, { ok: true, status: 'dlq' });
2601
- return;
2602
- }
2603
- const inner = body.event;
2604
- if (isSlackMessageEvent(inner)) {
2605
- if (inner.subtype === 'message_deleted' && inner.deleted_ts) {
2606
- const r = handleMessageDeleted(ctx, {
2607
- teamId: body.team_id,
2608
- channelId: inner.channel,
2609
- deletedTs: inner.deleted_ts,
2610
- eventId: body.event_id,
2611
- });
2612
- sendJson(res, 200, { ok: true, status: r.status });
2613
- return;
2614
- }
2615
- const r = ingestMessage(ctx, {
2616
- teamId: body.team_id,
2617
- // channel privacy isn't on the inner event; use channel_type as a
2618
- // proxy. 'group'|'im'|'mpim' → private. 'channel' → public. Unknown
2619
- // → private (fail closed).
2620
- channel: {
2621
- id: inner.channel,
2622
- is_private: inner.channel_type !== 'channel',
2623
- is_im: inner.channel_type === 'im',
2624
- is_mpim: inner.channel_type === 'mpim',
2625
- },
2626
- message: inner,
2627
- eventId: body.event_id,
2628
- });
2629
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
2630
- return;
2631
- }
2632
- const db = openHippoDb(ctx.hippoRoot);
2633
- try {
2634
- writeToDlq(db, {
2635
- tenantId: ctx.tenantId,
2636
- teamId: body.team_id,
2637
- rawPayload: rawBody,
2638
- error: `unhandled event type: ${inner.type ?? 'unknown'}`,
2639
- bucket: 'parse_error',
2640
- signature: sigStr,
2641
- slackTimestamp: tsStr,
2642
- });
2643
- }
2644
- finally {
2645
- closeHippoDb(db);
2646
- }
2647
- sendJson(res, 200, { ok: true, status: 'dlq' });
2648
- return;
2649
- }
2650
- // ── POST /v1/connectors/github/events ──
2651
- //
2652
- // GitHub webhook receiver. Mirrors the Slack route shape but with
2653
- // GitHub-specific idioms:
2654
- // 1. HMAC SHA-256 over the raw body (X-Hub-Signature-256), no timestamp.
2655
- // 2. Event type discriminated by the X-GitHub-Event header (not body.type).
2656
- // 3. X-GitHub-Delivery is required audit metadata (NOT the dedupe seam — see
2657
- // computeIdempotencyKey, which folds the signed body into the key so a
2658
- // replayed body with a fresh delivery UUID still dedupes).
2659
- // 4. Tenant resolved by installation.id → github_installations, then by
2660
- // repository.full_name → github_repositories (PAT-mode multi-tenant).
2661
- // 5. ALWAYS ACK 200 on signed envelopes (DLQ included). 401 only on bad
2662
- // signature; 404 only when GITHUB_WEBHOOK_SECRET is unset (don't expose
2663
- // the route's existence on builds where it's gated off).
2664
- if (method === 'POST' && path === '/v1/connectors/github/events') {
2665
- if (!isPublicRoute(method, path)) {
2666
- throw new HttpError(401, 'auth required');
2667
- }
2668
- const rawBody = await readBody(req);
2669
- const secret = process.env.GITHUB_WEBHOOK_SECRET;
2670
- if (!secret) {
2671
- res.writeHead(404, JSON_HEADERS);
2672
- res.end(JSON.stringify({ error: 'not found' }));
2673
- return;
2674
- }
2675
- const previousSecret = process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS;
2676
- const sigHdr = req.headers['x-hub-signature-256'];
2677
- const eventHdr = req.headers['x-github-event'];
2678
- const deliveryHdr = req.headers['x-github-delivery'];
2679
- const sigStr = isHeaderString(sigHdr) ? sigHdr : null;
2680
- const eventName = isHeaderString(eventHdr) ? eventHdr : null;
2681
- const deliveryId = isHeaderString(deliveryHdr) ? deliveryHdr : null;
2682
- if (sigStr === null ||
2683
- !verifyGitHubSignature({
2684
- rawBody,
2685
- signature: sigStr,
2686
- webhookSecret: secret,
2687
- previousSecret,
2688
- })) {
2689
- throw new HttpError(401, 'invalid GitHub signature');
2690
- }
2691
- // Signature OK from here on. Everything else is ACK-200; bad envelopes go
2692
- // to the DLQ and a human can replay later.
2693
- // Cheap regex extraction of installation_id / repo for DLQ rows that fail
2694
- // to JSON.parse — gives operators something to triage.
2695
- const installationFromRaw = (() => {
2696
- const m = rawBody.match(/"installation"\s*:\s*\{[^}]*"id"\s*:\s*(\d+)/);
2697
- return m ? m[1] : null;
2698
- })();
2699
- const repoFromRaw = (() => {
2700
- const m = rawBody.match(/"full_name"\s*:\s*"([^"]+)"/);
2701
- return m ? m[1] : null;
2702
- })();
2703
- if (deliveryId === null) {
2704
- // Body was signed but caller omitted the audit header. Park.
2705
- const db = openHippoDb(opts.hippoRoot);
2706
- try {
2707
- writeToGitHubDlq(db, {
2708
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2709
- rawPayload: rawBody,
2710
- error: 'missing X-GitHub-Delivery header',
2711
- bucket: 'parse_error',
2712
- eventName,
2713
- deliveryId: null,
2714
- signature: sigStr,
2715
- installationId: installationFromRaw,
2716
- repoFullName: repoFromRaw,
2717
- });
2718
- }
2719
- finally {
2720
- closeHippoDb(db);
2721
- }
2722
- sendJson(res, 200, { ok: true, status: 'dlq' });
2723
- return;
2724
- }
2725
- // Ping fires once at hook creation. Don't ingest, don't DLQ — just pong.
2726
- if (eventName === 'ping') {
2727
- sendJson(res, 200, { pong: true });
2728
- return;
2729
- }
2730
- const ALLOWED_EVENTS = new Set([
2731
- 'issues',
2732
- 'issue_comment',
2733
- 'pull_request',
2734
- 'pull_request_review_comment',
2735
- ]);
2736
- if (eventName === null || !ALLOWED_EVENTS.has(eventName)) {
2737
- const db = openHippoDb(opts.hippoRoot);
2738
- try {
2739
- writeToGitHubDlq(db, {
2740
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2741
- rawPayload: rawBody,
2742
- error: `unhandled event: ${eventName ?? '(missing X-GitHub-Event)'}`,
2743
- bucket: 'unhandled',
2744
- eventName,
2745
- deliveryId,
2746
- signature: sigStr,
2747
- installationId: installationFromRaw,
2748
- repoFullName: repoFromRaw,
2749
- });
2750
- }
2751
- finally {
2752
- closeHippoDb(db);
2753
- }
2754
- sendJson(res, 200, { ok: true, status: 'dlq' });
2755
- return;
2756
- }
2757
- let body;
2758
- try {
2759
- body = JSON.parse(rawBody);
2760
- }
2761
- catch {
2762
- const db = openHippoDb(opts.hippoRoot);
2763
- try {
2764
- writeToGitHubDlq(db, {
2765
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2766
- rawPayload: rawBody,
2767
- error: 'invalid JSON',
2768
- bucket: 'parse_error',
2769
- eventName,
2770
- deliveryId,
2771
- signature: sigStr,
2772
- installationId: installationFromRaw,
2773
- repoFullName: repoFromRaw,
2774
- });
2775
- }
2776
- finally {
2777
- closeHippoDb(db);
2778
- }
2779
- sendJson(res, 200, { ok: true, status: 'dlq' });
2780
- return;
2781
- }
2782
- if (body === undefined || !isGitHubWebhookEnvelope(body)) {
2783
- const db = openHippoDb(opts.hippoRoot);
2784
- try {
2785
- writeToGitHubDlq(db, {
2786
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2787
- rawPayload: rawBody,
2788
- error: 'not a GitHub webhook envelope',
2789
- bucket: 'parse_error',
2790
- eventName,
2791
- deliveryId,
2792
- signature: sigStr,
2793
- installationId: installationFromRaw,
2794
- repoFullName: repoFromRaw,
2795
- });
2796
- }
2797
- finally {
2798
- closeHippoDb(db);
2799
- }
2800
- sendJson(res, 200, { ok: true, status: 'dlq' });
2801
- return;
2802
- }
2803
- const installationId = body.installation?.id != null ? String(body.installation.id) : null;
2804
- const repoFullName = body.repository?.full_name ?? null;
2805
- // Tenant resolution. Fail closed on multi-tenant installs with unknown
2806
- // routing — same policy as Slack.
2807
- let resolvedTenant;
2808
- {
2809
- const db = openHippoDb(opts.hippoRoot);
2810
- try {
2811
- resolvedTenant = resolveTenantForGitHub(db, {
2812
- installationId,
2813
- repoFullName,
2814
- });
2815
- }
2816
- finally {
2817
- closeHippoDb(db);
2818
- }
2819
- }
2820
- if (resolvedTenant === null) {
2821
- const db = openHippoDb(opts.hippoRoot);
2822
- try {
2823
- writeToGitHubDlq(db, {
2824
- tenantId: null,
2825
- rawPayload: rawBody,
2826
- error: `unroutable: installation_id=${installationId ?? '(none)'} repo=${repoFullName ?? '(none)'}`,
2827
- bucket: 'unroutable',
2828
- eventName,
2829
- deliveryId,
2830
- signature: sigStr,
2831
- installationId,
2832
- repoFullName,
2833
- });
2834
- }
2835
- finally {
2836
- closeHippoDb(db);
2837
- }
2838
- sendJson(res, 200, { ok: true, status: 'dlq' });
2839
- return;
2840
- }
2841
- const ctx = {
2842
- hippoRoot: opts.hippoRoot,
2843
- tenantId: resolvedTenant,
2844
- actor: adminActor('connector:github'),
2845
- };
2846
- // Dispatch by event header. Type guards cross-check the body shape against
2847
- // the header so a payload of one event type cannot satisfy another's guard.
2848
- if (eventName === 'issues' && isGitHubIssueEvent(body, 'issues')) {
2849
- if (body.action === 'deleted') {
2850
- // GitHub does fire issues.deleted (admin-initiated). Don't archive — V1
2851
- // policy is to log and let an operator decide. Archive could lose the
2852
- // memory if the issue is being moved between accounts.
2853
- const db = openHippoDb(opts.hippoRoot);
2854
- try {
2855
- writeToGitHubDlq(db, {
2856
- tenantId: resolvedTenant,
2857
- rawPayload: rawBody,
2858
- error: 'issues.deleted requires manual review',
2859
- bucket: 'unhandled',
2860
- eventName,
2861
- deliveryId,
2862
- signature: sigStr,
2863
- installationId,
2864
- repoFullName,
2865
- });
2866
- }
2867
- finally {
2868
- closeHippoDb(db);
2869
- }
2870
- sendJson(res, 200, { ok: true, status: 'dlq' });
2871
- return;
2872
- }
2873
- const ingestInput = { eventName: 'issues', payload: body };
2874
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
2875
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
2876
- return;
2877
- }
2878
- if (eventName === 'issue_comment' && isGitHubIssueCommentEvent(body, 'issue_comment')) {
2879
- if (body.action === 'deleted') {
2880
- const repo = body.repository?.full_name ?? '';
2881
- const artifactRef = `github://${repo}/issue/${body.issue.number}/comment/${body.comment.id}`;
2882
- // v1.3.2: deletion key uses a 'deleted:' namespace so it doesn't collide
2883
- // with the ingest path's key for the same artifact. Without the prefix,
2884
- // a previously-ingested comment's log row would make hasSeenKey return
2885
- // true on the first deletion, short-circuiting archive. Codex round 3
2886
- // P0 fix evolved through two iterations to land here.
2887
- const idempotencyKey = computeGitHubDeletionKey(artifactRef, body.comment.updated_at ?? null);
2888
- const r = handleGitHubCommentDeleted(ctx, {
2889
- artifactRef,
2890
- idempotencyKey,
2891
- deliveryId,
2892
- eventName,
2893
- });
2894
- sendJson(res, 200, { ok: true, status: r.status, archivedCount: r.archivedCount });
2895
- return;
2896
- }
2897
- const ingestInput = { eventName: 'issue_comment', payload: body };
2898
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
2899
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
2900
- return;
2901
- }
2902
- if (eventName === 'pull_request' && isGitHubPullRequestEvent(body, 'pull_request')) {
2903
- const ingestInput = { eventName: 'pull_request', payload: body };
2904
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
2905
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
2906
- return;
2907
- }
2908
- if (eventName === 'pull_request_review_comment' &&
2909
- isGitHubPullRequestReviewCommentEvent(body, 'pull_request_review_comment')) {
2910
- if (body.action === 'deleted') {
2911
- const repo = body.repository?.full_name ?? '';
2912
- const artifactRef = `github://${repo}/pull/${body.pull_request.number}/review_comment/${body.comment.id}`;
2913
- // v1.3.2: see issue_comment branch comment above for the namespace rationale.
2914
- const idempotencyKey = computeGitHubDeletionKey(artifactRef, body.comment.updated_at ?? null);
2915
- const r = handleGitHubCommentDeleted(ctx, {
2916
- artifactRef,
2917
- idempotencyKey,
2918
- deliveryId,
2919
- eventName,
2920
- });
2921
- sendJson(res, 200, { ok: true, status: r.status, archivedCount: r.archivedCount });
2922
- return;
2923
- }
2924
- const ingestInput = {
2925
- eventName: 'pull_request_review_comment',
2926
- payload: body,
2927
- };
2928
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
2929
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
2930
- return;
2931
- }
2932
- // Header allow-listed but body shape didn't satisfy the matching guard.
2933
- {
2934
- const db = openHippoDb(opts.hippoRoot);
2935
- try {
2936
- writeToGitHubDlq(db, {
2937
- tenantId: resolvedTenant,
2938
- rawPayload: rawBody,
2939
- error: `body shape did not match X-GitHub-Event=${eventName}`,
2940
- bucket: 'parse_error',
2941
- eventName,
2942
- deliveryId,
2943
- signature: sigStr,
2944
- installationId,
2945
- repoFullName,
2946
- });
2947
- }
2948
- finally {
2949
- closeHippoDb(db);
2950
- }
2951
- sendJson(res, 200, { ok: true, status: 'dlq' });
2952
- return;
2953
- }
2954
- }
2955
- // ── MCP-over-HTTP/SSE transport (Task 11) ──
2956
- //
2957
- // Two routes implement an MCP HTTP transport alongside the stdio one. Both
2958
- // dispatch to the same `handleMcpRequest` as the stdio loop in src/mcp/server.ts.
2959
- //
2960
- // POST /mcp — Send a JSON-RPC request, get a JSON-RPC response synchronously
2961
- // in the body. Content-type: application/json both ways.
2962
- // GET /mcp/stream — Open an SSE stream for server-initiated messages.
2963
- // v1 simplification: this stream is keepalive-only. Clients
2964
- // that need server-pushed notifications/progress will see
2965
- // only `: ping` comments every 30s. All real responses come
2966
- // back synchronously on POST /mcp. This matches the
2967
- // "synchronous JSON in body" leg of the MCP HTTP spec and
2968
- // is enough for `tools/list` / `tools/call` round-trips.
2969
- // Server-initiated SSE messages will be wired in a later task.
2970
- //
2971
- // Auth: same as /v1/* — Bearer token validated via `requireAuth`, with the
2972
- // loopback no-auth fallback. SSE check runs once at stream-open.
2973
- if (method === 'POST' && path === '/mcp') {
2974
- // Build the same Context the /v1/* routes use so MCP tool calls inherit
2975
- // the server's bound hippoRoot and the auth-resolved tenantId / actor.
2976
- // Without this, executeTool would walk from cwd via findHippoRoot() and
2977
- // pull tenant from HIPPO_TENANT, dropping a valid Bearer for tenant B
2978
- // back to whatever the env says.
2979
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2980
- const raw = await readBody(req);
2981
- let mcpReq;
2982
- try {
2983
- mcpReq = JSON.parse(raw);
2984
- }
2985
- catch {
2986
- throw new HttpError(400, 'invalid JSON-RPC body');
2987
- }
2988
- if (!isJsonObjectRecord(mcpReq) || !isJsonString(mcpReq.method)) {
2989
- throw new HttpError(400, 'JSON-RPC body must include a method string');
2990
- }
2991
- // SAFETY: validated above as a plain JSON object carrying a string method;
2992
- // the remaining McpRequest wire fields (jsonrpc, id, params) are checked or
2993
- // safely defaulted inside handleMcpRequest's JSON-RPC dispatch.
2994
- const rpcReq = mcpReq;
2995
- let mcpRes;
2996
- try {
2997
- mcpRes = await handleMcpRequest(rpcReq, {
2998
- hippoRoot: ctx.hippoRoot,
2999
- tenantId: ctx.tenantId,
3000
- // v1.12.0: McpContext.actor stays string; extract subject at the boundary.
3001
- actor: ctx.actor.subject,
3002
- clientKey: buildMcpClientKey(req),
3003
- });
3004
- }
3005
- catch (err) {
3006
- mcpRes = {
3007
- jsonrpc: '2.0',
3008
- id: mcpReq.id,
3009
- error: { code: -32603, message: err instanceof Error ? err.message : 'internal error' },
3010
- };
3011
- }
3012
- if (mcpRes === null) {
3013
- // Notification — no body, 202 Accepted.
3014
- res.writeHead(202);
3015
- res.end();
3016
- return;
3017
- }
3018
- sendJson(res, 200, mcpRes);
3019
- return;
3020
- }
3021
- if (method === 'GET' && path === '/mcp/stream') {
3022
- requireAuth(req, opts.hippoRoot);
3023
- res.writeHead(200, {
3024
- 'content-type': 'text/event-stream',
3025
- 'cache-control': 'no-cache',
3026
- connection: 'keep-alive',
3027
- });
3028
- // Initial ping so smoke tests can confirm the stream is live without
3029
- // waiting for the first keepalive interval.
3030
- res.write(': ping\n\n');
3031
- // v0.39 SSE hardening:
3032
- // - Heartbeat re-validates the bearer (default 60s). If the key was
3033
- // revoked or rotated, close the stream with reason='auth_revoked'.
3034
- // - MCP_SSE_MAX_AGE_SEC (default 3600) caps stream lifetime; close
3035
- // with reason='max_age_exceeded' when reached.
3036
- // - MCP_SSE_HEARTBEAT_MS (default 60000) lets tests run with a short
3037
- // interval without waiting a full minute.
3038
- const heartbeatMs = parseInt(process.env.MCP_SSE_HEARTBEAT_MS ?? '60000', 10) || 60000;
3039
- const maxAgeMs = (parseInt(process.env.MCP_SSE_MAX_AGE_SEC ?? '3600', 10) || 3600) * 1000;
3040
- const startedAt = Date.now();
3041
- let closed = false;
3042
- const closeWith = (reason) => {
3043
- if (closed)
3044
- return;
3045
- closed = true;
3046
- try {
3047
- res.write(`event: closed\ndata: ${JSON.stringify({ reason })}\n\n`);
3048
- }
3049
- catch { /* socket already gone */ }
3050
- try {
3051
- res.end();
3052
- }
3053
- catch { /* socket already gone */ }
3054
- };
3055
- const ping = setInterval(() => {
3056
- if (closed) {
3057
- clearInterval(ping);
3058
- return;
3059
- }
3060
- if (Date.now() - startedAt >= maxAgeMs) {
3061
- closeWith('max_age_exceeded');
3062
- clearInterval(ping);
3063
- return;
3064
- }
3065
- try {
3066
- requireAuth(req, opts.hippoRoot);
3067
- }
3068
- catch {
3069
- closeWith('auth_revoked');
3070
- clearInterval(ping);
3071
- return;
3072
- }
3073
- try {
3074
- res.write(': ping\n\n');
3075
- }
3076
- catch {
3077
- clearInterval(ping);
3078
- }
3079
- }, heartbeatMs);
3080
- // Don't keep the event loop alive just for this timer — the server's
3081
- // listener already does that, and tests want the process to exit cleanly.
3082
- if (ping.unref instanceof Function)
3083
- ping.unref();
3084
- req.on('close', () => clearInterval(ping));
3085
- return;
3086
- }
3087
- res.writeHead(404, JSON_HEADERS);
3088
- res.end(JSON.stringify({ error: 'not found' }));
3089
- }
3090
- /**
3091
- * Boot the HTTP daemon on host:port and write the pidfile under hippoRoot.
3092
- *
3093
- * Refuses non-loopback hosts at boot (Footgun #3 from the A1 plan) unless
3094
- * HIPPO_REQUIRE_AUTH=1 is set. The A5 v2 auth middleware (buildContextWithAuth /
3095
- * requireAuth) has shipped and every route checks it except GET /health
3096
- * (public by design for platform health checks) and the two connector
3097
- * webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
3098
- * secrets and 404 when those secrets are unset. But the loopback
3099
- * no-auth fallback inside buildContextWithAuth still admits unauthenticated
3100
- * requests from a loopback remote address, so binding to a non-loopback host
3101
- * is only safe once that fallback is disabled with HIPPO_REQUIRE_AUTH=1,
3102
- * which forces every request (loopback or not) through Bearer-token
3103
- * validation. Without that env var set, a non-loopback bind would expose the
3104
- * DB to the network with no auth, so we fail fast instead.
3105
- *
3106
- * Use port: 0 in tests to bind to an ephemeral port and read the actual
3107
- * port back via server.address() after listen.
3108
- */
3109
- export async function serve(opts) {
3110
- const host = opts.host ?? '127.0.0.1';
3111
- const requestedPort = opts.port ?? Number(process.env.HIPPO_PORT ?? 6789);
3112
- if (!LOOPBACK_HOSTS.has(host) && process.env.HIPPO_REQUIRE_AUTH !== '1') {
3113
- throw new Error(`Refusing to bind hippo serve to non-loopback host '${host}' without auth. ` +
3114
- `Set HIPPO_REQUIRE_AUTH=1 to bind non-loopback; every request then requires ` +
3115
- `a valid API key. Bind to 127.0.0.1 / ::1 / localhost otherwise.`);
3116
- }
3117
- // H3: refuse to start if a live hippo server already serves this hippoRoot.
3118
- // detectServer probes the recorded /health — a stale pidfile is unlinked and
3119
- // ignored, but a live peer means a concurrent `hippo serve` would race for
3120
- // the port and clobber the pidfile.
3121
- const existing = await detectServer(opts.hippoRoot);
3122
- if (existing) {
3123
- throw new Error(`hippo serve: already running on port ${existing.port} (pid ${existing.pid}). ` +
3124
- `Stop that server before starting another on the same hippoRoot.`);
3125
- }
3126
- // The server's start time. Single source of truth: it is returned by every
3127
- // GET /health response and (below) written into the pidfile, so detectServer
3128
- // can match the two and prove a pid-reusing impostor is not the real server.
3129
- const startedAt = new Date().toISOString();
3130
- // E3: per-IP rate limiter for /v1/*. Built here (not at module scope) so
3131
- // HIPPO_V1_RPS is read at boot, matching HIPPO_PORT above and letting a test
3132
- // set the rate before serve(). A non-positive or non-finite value disables
3133
- // limiting (the opt-out knob).
3134
- const v1Rps = Number(process.env.HIPPO_V1_RPS ?? 20);
3135
- const limiter = Number.isFinite(v1Rps) && v1Rps > 0
3136
- ? createRateLimiter({ ratePerSec: v1Rps, burst: v1Rps * 2, idleEvictMs: 60000, maxKeys: 10000 })
3137
- : undefined;
3138
- const server = createServer((req, res) => {
3139
- handleRequest(req, res, opts, startedAt, limiter).catch((err) => {
3140
- if (res.headersSent) {
3141
- try {
3142
- res.end();
3143
- }
3144
- catch { /* socket already gone */ }
3145
- return;
3146
- }
3147
- if (err instanceof BodyTooLargeError) {
3148
- sendError(res, 413, err.message);
3149
- // M3: readBody hit the 1 MB cap mid-stream, so the request body is
3150
- // only partially consumed. Destroy the socket rather than let the
3151
- // client's remaining (unbounded) bytes drain into an exchange we have
3152
- // already answered.
3153
- req.destroy();
3154
- return;
3155
- }
3156
- if (err instanceof HttpError) {
3157
- sendError(res, err.status, err.message);
3158
- return;
3159
- }
3160
- // F5 (v1.6.5) + v1.7.0 api-contract review: RecallContractError lands
3161
- // at 400 with {error: <message>, code: <code>}. The `error` field
3162
- // matches `sendError`'s shape (human message, used by HttpError /
3163
- // BodyTooLargeError / mapApiError). The `code` field is the typed
3164
- // discriminator — clients can branch on `body.code` without parsing
3165
- // prose. Earlier draft used {error: code, message: text} but that
3166
- // diverged from the rest of v1/* and forced clients to special-case
3167
- // the error path.
3168
- if (err instanceof RecallContractError) {
3169
- sendJson(res, 400, { error: err.message, code: err.code });
3170
- return;
3171
- }
3172
- const mapped = mapApiError(err);
3173
- sendError(res, mapped.status, mapped.message);
3174
- });
3175
- });
3176
- // T3b capture (v1.26.2): tests/server-concurrency.test.ts's ECONNRESET flake
3177
- // traced to a chunk-boundary reuse race — a kept-alive socket idled through
3178
- // a prior response chunk gets closed by the server's default 5s
3179
- // keepAliveTimeout just as a client reuses it for the next request. Raising
3180
- // both timeouts shrinks that idle-close/reuse window ~13x. Keep
3181
- // headersTimeout ABOVE the EFFECTIVE keep-alive expiry, which is
3182
- // keepAliveTimeout + keepAliveTimeoutBuffer (the buffer defaults to
3183
- // 1,000ms on Node 22.19+/24.6+ — verified 1,000 on node 24.13, so the
3184
- // effective expiry here is 66s; codex review caught that a 66s
3185
- // headersTimeout would sit exactly ON that boundary and recreate the
3186
- // race). The headers timer also runs while a kept-alive socket waits for
3187
- // its next request, so a value at or below the effective expiry would
3188
- // itself close idle reused sockets, and Node would not flag it (no error
3189
- // or warning at listen time — verified empirically).
3190
- server.keepAliveTimeout = 65_000;
3191
- server.headersTimeout = 70_000;
3192
- await new Promise((resolve, reject) => {
3193
- const onError = (err) => {
3194
- server.removeListener('listening', onListening);
3195
- reject(err);
3196
- };
3197
- const onListening = () => {
3198
- server.removeListener('error', onError);
3199
- resolve();
3200
- };
3201
- server.once('error', onError);
3202
- server.once('listening', onListening);
3203
- server.listen(requestedPort, host);
3204
- });
3205
- const address = server.address();
3206
- if (!isAddressInfo(address)) {
3207
- throw new Error('server.address() returned unexpected shape');
3208
- }
3209
- const addressInfo = address;
3210
- const actualPort = addressInfo.port;
3211
- const url = `http://${host}:${actualPort}`;
3212
- writePidfile(opts.hippoRoot, { port: actualPort, url, startedAt });
3213
- let stopping = false;
3214
- const stop = async () => {
3215
- if (stopping)
3216
- return;
3217
- stopping = true;
3218
- // Remove the pidfile only if it still names this server. A newer server
3219
- // may have started on this hippoRoot and rewritten the pidfile; an
3220
- // unconditional unlink here would orphan it. (v0.37.0 server-hardening.)
3221
- removePidfileIfOwned(opts.hippoRoot, { pid: process.pid, startedAt });
3222
- // Force-close any long-lived idle connections (e.g. SSE keepalive streams
3223
- // on /mcp/stream) so server.close() can resolve. Without this, SIGTERM
3224
- // would hang the process until the SSE client cancels. Available on
3225
- // Node 18.2+; gate via optional chaining to avoid crashing on older runtimes.
3226
- server.closeAllConnections?.();
3227
- await new Promise((resolve) => {
3228
- server.close(() => resolve());
3229
- });
3230
- };
3231
- // Skip signal handlers under vitest so each test run does not register a
3232
- // stray SIGTERM/SIGINT listener that survives until the runner exits.
3233
- if (!process.env.VITEST) {
3234
- let shuttingDown = false;
3235
- const gracefulShutdown = async (signal) => {
3236
- if (shuttingDown)
3237
- return;
3238
- shuttingDown = true;
3239
- console.error(`Received ${signal}, shutting down...`);
3240
- try {
3241
- await stop();
3242
- }
3243
- catch (err) {
3244
- console.error('Error during stop:', err);
3245
- }
3246
- finally {
3247
- process.exit(0);
3248
- }
3249
- };
3250
- process.once('SIGTERM', () => { void gracefulShutdown('SIGTERM'); });
3251
- process.once('SIGINT', () => { void gracefulShutdown('SIGINT'); });
3252
- }
3253
- return { port: actualPort, url, stop, server };
3254
- }
3255
- //# sourceMappingURL=server.js.map