hippo-memory 0.35.0 → 0.37.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 (215) hide show
  1. package/README.md +25 -0
  2. package/dist/api.d.ts +183 -0
  3. package/dist/api.d.ts.map +1 -0
  4. package/dist/api.js +343 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/benchmarks/e1.3/incident-recall-eval.js +74 -0
  7. package/dist/benchmarks/e1.3/incident-recall-eval.js.map +1 -0
  8. package/dist/benchmarks/e1.3/scenarios.json +2587 -0
  9. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +102 -0
  10. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +1 -0
  11. package/dist/cli.js +222 -34
  12. package/dist/cli.js.map +1 -1
  13. package/dist/client.d.ts +54 -0
  14. package/dist/client.d.ts.map +1 -0
  15. package/dist/client.js +181 -0
  16. package/dist/client.js.map +1 -0
  17. package/dist/connectors/slack/backfill.d.ts +42 -0
  18. package/dist/connectors/slack/backfill.d.ts.map +1 -0
  19. package/dist/connectors/slack/backfill.js +76 -0
  20. package/dist/connectors/slack/backfill.js.map +1 -0
  21. package/dist/connectors/slack/deletion.d.ts +14 -0
  22. package/dist/connectors/slack/deletion.d.ts.map +1 -0
  23. package/dist/connectors/slack/deletion.js +46 -0
  24. package/dist/connectors/slack/deletion.js.map +1 -0
  25. package/dist/connectors/slack/dlq.d.ts +21 -0
  26. package/dist/connectors/slack/dlq.d.ts.map +1 -0
  27. package/dist/connectors/slack/dlq.js +23 -0
  28. package/dist/connectors/slack/dlq.js.map +1 -0
  29. package/dist/connectors/slack/idempotency.d.ts +5 -0
  30. package/dist/connectors/slack/idempotency.d.ts.map +1 -0
  31. package/dist/connectors/slack/idempotency.js +13 -0
  32. package/dist/connectors/slack/idempotency.js.map +1 -0
  33. package/dist/connectors/slack/ingest.d.ts +27 -0
  34. package/dist/connectors/slack/ingest.d.ts.map +1 -0
  35. package/dist/connectors/slack/ingest.js +48 -0
  36. package/dist/connectors/slack/ingest.js.map +1 -0
  37. package/dist/connectors/slack/ratelimit.d.ts +9 -0
  38. package/dist/connectors/slack/ratelimit.d.ts.map +1 -0
  39. package/dist/connectors/slack/ratelimit.js +18 -0
  40. package/dist/connectors/slack/ratelimit.js.map +1 -0
  41. package/dist/connectors/slack/scope.d.ts +16 -0
  42. package/dist/connectors/slack/scope.d.ts.map +1 -0
  43. package/dist/connectors/slack/scope.js +13 -0
  44. package/dist/connectors/slack/scope.js.map +1 -0
  45. package/dist/connectors/slack/signature.d.ts +12 -0
  46. package/dist/connectors/slack/signature.d.ts.map +1 -0
  47. package/dist/connectors/slack/signature.js +20 -0
  48. package/dist/connectors/slack/signature.js.map +1 -0
  49. package/dist/connectors/slack/tenant-routing.d.ts +13 -0
  50. package/dist/connectors/slack/tenant-routing.d.ts.map +1 -0
  51. package/dist/connectors/slack/tenant-routing.js +17 -0
  52. package/dist/connectors/slack/tenant-routing.js.map +1 -0
  53. package/dist/connectors/slack/transform.d.ts +20 -0
  54. package/dist/connectors/slack/transform.d.ts.map +1 -0
  55. package/dist/connectors/slack/transform.js +31 -0
  56. package/dist/connectors/slack/transform.js.map +1 -0
  57. package/dist/connectors/slack/types.d.ts +35 -0
  58. package/dist/connectors/slack/types.d.ts.map +1 -0
  59. package/dist/connectors/slack/types.js +23 -0
  60. package/dist/connectors/slack/types.js.map +1 -0
  61. package/dist/connectors/slack/web-client.d.ts +12 -0
  62. package/dist/connectors/slack/web-client.d.ts.map +1 -0
  63. package/dist/connectors/slack/web-client.js +43 -0
  64. package/dist/connectors/slack/web-client.js.map +1 -0
  65. package/dist/db.d.ts.map +1 -1
  66. package/dist/db.js +46 -1
  67. package/dist/db.js.map +1 -1
  68. package/dist/importers.js +3 -3
  69. package/dist/importers.js.map +1 -1
  70. package/dist/mcp/server.d.ts +46 -1
  71. package/dist/mcp/server.d.ts.map +1 -1
  72. package/dist/mcp/server.js +74 -26
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/server-detect.d.ts +26 -0
  75. package/dist/server-detect.d.ts.map +1 -0
  76. package/dist/server-detect.js +70 -0
  77. package/dist/server-detect.js.map +1 -0
  78. package/dist/server.d.ts +29 -0
  79. package/dist/server.d.ts.map +1 -0
  80. package/dist/server.js +784 -0
  81. package/dist/server.js.map +1 -0
  82. package/dist/shared.d.ts +3 -1
  83. package/dist/shared.d.ts.map +1 -1
  84. package/dist/shared.js +2 -2
  85. package/dist/shared.js.map +1 -1
  86. package/dist/src/ambient.js +147 -0
  87. package/dist/src/ambient.js.map +1 -0
  88. package/dist/src/api.js +343 -0
  89. package/dist/src/api.js.map +1 -0
  90. package/dist/src/audit.js +152 -0
  91. package/dist/src/audit.js.map +1 -0
  92. package/dist/src/auth.js +65 -0
  93. package/dist/src/auth.js.map +1 -0
  94. package/dist/src/autolearn.js +143 -0
  95. package/dist/src/autolearn.js.map +1 -0
  96. package/dist/src/capture.js +512 -0
  97. package/dist/src/capture.js.map +1 -0
  98. package/dist/src/cli.js +4971 -0
  99. package/dist/src/cli.js.map +1 -0
  100. package/dist/src/client.js +181 -0
  101. package/dist/src/client.js.map +1 -0
  102. package/dist/src/config.js +108 -0
  103. package/dist/src/config.js.map +1 -0
  104. package/dist/src/connectors/slack/backfill.js +76 -0
  105. package/dist/src/connectors/slack/backfill.js.map +1 -0
  106. package/dist/src/connectors/slack/deletion.js +46 -0
  107. package/dist/src/connectors/slack/deletion.js.map +1 -0
  108. package/dist/src/connectors/slack/dlq.js +23 -0
  109. package/dist/src/connectors/slack/dlq.js.map +1 -0
  110. package/dist/src/connectors/slack/idempotency.js +13 -0
  111. package/dist/src/connectors/slack/idempotency.js.map +1 -0
  112. package/dist/src/connectors/slack/ingest.js +48 -0
  113. package/dist/src/connectors/slack/ingest.js.map +1 -0
  114. package/dist/src/connectors/slack/ratelimit.js +18 -0
  115. package/dist/src/connectors/slack/ratelimit.js.map +1 -0
  116. package/dist/src/connectors/slack/scope.js +13 -0
  117. package/dist/src/connectors/slack/scope.js.map +1 -0
  118. package/dist/src/connectors/slack/signature.js +20 -0
  119. package/dist/src/connectors/slack/signature.js.map +1 -0
  120. package/dist/src/connectors/slack/tenant-routing.js +17 -0
  121. package/dist/src/connectors/slack/tenant-routing.js.map +1 -0
  122. package/dist/src/connectors/slack/transform.js +31 -0
  123. package/dist/src/connectors/slack/transform.js.map +1 -0
  124. package/dist/src/connectors/slack/types.js +23 -0
  125. package/dist/src/connectors/slack/types.js.map +1 -0
  126. package/dist/src/connectors/slack/web-client.js +43 -0
  127. package/dist/src/connectors/slack/web-client.js.map +1 -0
  128. package/dist/src/consolidate.js +517 -0
  129. package/dist/src/consolidate.js.map +1 -0
  130. package/dist/src/dag.js +104 -0
  131. package/dist/src/dag.js.map +1 -0
  132. package/dist/src/dashboard.js +409 -0
  133. package/dist/src/dashboard.js.map +1 -0
  134. package/dist/src/db.js +584 -0
  135. package/dist/src/db.js.map +1 -0
  136. package/dist/src/embeddings.js +344 -0
  137. package/dist/src/embeddings.js.map +1 -0
  138. package/dist/src/eval-suite.js +289 -0
  139. package/dist/src/eval-suite.js.map +1 -0
  140. package/dist/src/eval.js +187 -0
  141. package/dist/src/eval.js.map +1 -0
  142. package/dist/src/extract.js +87 -0
  143. package/dist/src/extract.js.map +1 -0
  144. package/dist/src/handoff.js +30 -0
  145. package/dist/src/handoff.js.map +1 -0
  146. package/dist/src/hooks.js +582 -0
  147. package/dist/src/hooks.js.map +1 -0
  148. package/dist/src/importers.js +399 -0
  149. package/dist/src/importers.js.map +1 -0
  150. package/dist/src/index.js +25 -0
  151. package/dist/src/index.js.map +1 -0
  152. package/dist/src/invalidation.js +94 -0
  153. package/dist/src/invalidation.js.map +1 -0
  154. package/dist/src/mcp/framing.js +45 -0
  155. package/dist/src/mcp/framing.js.map +1 -0
  156. package/dist/src/mcp/server.js +510 -0
  157. package/dist/src/mcp/server.js.map +1 -0
  158. package/dist/src/memory.js +280 -0
  159. package/dist/src/memory.js.map +1 -0
  160. package/dist/src/multihop.js +32 -0
  161. package/dist/src/multihop.js.map +1 -0
  162. package/dist/src/path-context.js +32 -0
  163. package/dist/src/path-context.js.map +1 -0
  164. package/dist/src/physics-config.js +26 -0
  165. package/dist/src/physics-config.js.map +1 -0
  166. package/dist/src/physics-state.js +163 -0
  167. package/dist/src/physics-state.js.map +1 -0
  168. package/dist/src/physics.js +361 -0
  169. package/dist/src/physics.js.map +1 -0
  170. package/dist/src/postinstall.js +68 -0
  171. package/dist/src/postinstall.js.map +1 -0
  172. package/dist/src/raw-archive.js +72 -0
  173. package/dist/src/raw-archive.js.map +1 -0
  174. package/dist/src/refine-llm.js +147 -0
  175. package/dist/src/refine-llm.js.map +1 -0
  176. package/dist/src/replay.js +117 -0
  177. package/dist/src/replay.js.map +1 -0
  178. package/dist/src/salience.js +74 -0
  179. package/dist/src/salience.js.map +1 -0
  180. package/dist/src/scheduler.js +67 -0
  181. package/dist/src/scheduler.js.map +1 -0
  182. package/dist/src/scope.js +35 -0
  183. package/dist/src/scope.js.map +1 -0
  184. package/dist/src/search.js +801 -0
  185. package/dist/src/search.js.map +1 -0
  186. package/dist/src/server-detect.js +70 -0
  187. package/dist/src/server-detect.js.map +1 -0
  188. package/dist/src/server.js +784 -0
  189. package/dist/src/server.js.map +1 -0
  190. package/dist/src/shared.js +309 -0
  191. package/dist/src/shared.js.map +1 -0
  192. package/dist/src/sso.js +22 -0
  193. package/dist/src/sso.js.map +1 -0
  194. package/dist/src/store.js +1390 -0
  195. package/dist/src/store.js.map +1 -0
  196. package/dist/src/tenant.js +17 -0
  197. package/dist/src/tenant.js.map +1 -0
  198. package/dist/src/trace.js +64 -0
  199. package/dist/src/trace.js.map +1 -0
  200. package/dist/src/working-memory.js +149 -0
  201. package/dist/src/working-memory.js.map +1 -0
  202. package/dist/src/yaml.js +98 -0
  203. package/dist/src/yaml.js.map +1 -0
  204. package/dist/store.d.ts +25 -4
  205. package/dist/store.d.ts.map +1 -1
  206. package/dist/store.js +50 -11
  207. package/dist/store.js.map +1 -1
  208. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  209. package/extensions/openclaw-plugin/package.json +1 -1
  210. package/openclaw.plugin.json +1 -1
  211. package/package.json +2 -2
  212. package/dist/import.d.ts +0 -31
  213. package/dist/import.d.ts.map +0 -1
  214. package/dist/import.js +0 -307
  215. package/dist/import.js.map +0 -1
@@ -0,0 +1,784 @@
1
+ import { createServer } from 'node:http';
2
+ import { writePidfile, removePidfile } from './server-detect.js';
3
+ import { resolveTenantId } from './tenant.js';
4
+ import { openHippoDb, closeHippoDb } from './db.js';
5
+ import { validateApiKey } from './auth.js';
6
+ import { remember, recall, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, } from './api.js';
7
+ import { handleMcpRequest } from './mcp/server.js';
8
+ import { verifySlackSignature } from './connectors/slack/signature.js';
9
+ import { isSlackEventEnvelope, isSlackMessageEvent } from './connectors/slack/types.js';
10
+ import { ingestMessage } from './connectors/slack/ingest.js';
11
+ import { handleMessageDeleted } from './connectors/slack/deletion.js';
12
+ import { writeToDlq } from './connectors/slack/dlq.js';
13
+ import { resolveTenantForTeam } from './connectors/slack/tenant-routing.js';
14
+ // Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
15
+ // New unauth routes MUST be added here AND get a corresponding entry in
16
+ // tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
17
+ // `path.startsWith` — pattern-positional auth is bypass-by-accident.
18
+ //
19
+ // The route handlers consult `isPublicRoute` before invoking
20
+ // `buildContextWithAuth` / `requireAuth`. Adding a route here without
21
+ // adding the corresponding `isPublicRoute` short-circuit in a handler is
22
+ // a no-op (auth still applies), so the failure mode is fail-closed.
23
+ const PUBLIC_ROUTES = new Set(['POST /v1/connectors/slack/events']);
24
+ function isPublicRoute(method, path) {
25
+ return PUBLIC_ROUTES.has(`${method} ${path}`);
26
+ }
27
+ const VALID_AUDIT_OPS = new Set([
28
+ 'remember',
29
+ 'recall',
30
+ 'promote',
31
+ 'supersede',
32
+ 'forget',
33
+ 'archive_raw',
34
+ 'auth_revoke',
35
+ ]);
36
+ // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
37
+ // enough to dump a small deployment's full audit log without paginating, but
38
+ // small enough that a malicious client can't ask for the world.
39
+ const MAX_AUDIT_LIMIT = 10000;
40
+ const VALID_KINDS = new Set([
41
+ 'raw',
42
+ 'distilled',
43
+ 'superseded',
44
+ 'archived',
45
+ ]);
46
+ // Pinned at module load. Bumped alongside package.json on releases. The
47
+ // HTTP /health response uses this; reading package.json synchronously here
48
+ // would couple the daemon to its on-disk install path, which we want to
49
+ // avoid for tests that mkdtemp a hippoRoot.
50
+ const VERSION = '0.37.0';
51
+ // 1 MB body cap. The CLI never sends payloads near this; anything bigger is
52
+ // almost certainly a misconfigured client or a deliberate memory-blowup attempt.
53
+ const MAX_BODY_BYTES = 1024 * 1024;
54
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
55
+ const JSON_HEADERS = { 'content-type': 'application/json' };
56
+ class HttpError extends Error {
57
+ status;
58
+ constructor(status, message) {
59
+ super(message);
60
+ this.status = status;
61
+ }
62
+ }
63
+ class BodyTooLargeError extends Error {
64
+ }
65
+ function sendJson(res, status, body) {
66
+ res.writeHead(status, JSON_HEADERS);
67
+ res.end(JSON.stringify(body));
68
+ }
69
+ function sendError(res, status, message) {
70
+ sendJson(res, status, { error: message });
71
+ }
72
+ /**
73
+ * Read the entire request body into a Buffer. Caps at MAX_BODY_BYTES to keep
74
+ * a malicious or buggy client from exhausting memory. The cap is enforced
75
+ * mid-stream so we don't wait for an attacker to finish before erroring out.
76
+ */
77
+ async function readBody(req) {
78
+ const chunks = [];
79
+ let total = 0;
80
+ for await (const chunk of req) {
81
+ const buf = chunk;
82
+ total += buf.length;
83
+ if (total > MAX_BODY_BYTES) {
84
+ throw new BodyTooLargeError('request body exceeds 1MB');
85
+ }
86
+ chunks.push(buf);
87
+ }
88
+ return Buffer.concat(chunks).toString('utf8');
89
+ }
90
+ async function parseJsonBody(req) {
91
+ const raw = await readBody(req);
92
+ if (raw.length === 0)
93
+ return {};
94
+ try {
95
+ const parsed = JSON.parse(raw);
96
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
97
+ throw new HttpError(400, 'request body must be a JSON object');
98
+ }
99
+ return parsed;
100
+ }
101
+ catch (e) {
102
+ if (e instanceof HttpError)
103
+ throw e;
104
+ throw new HttpError(400, 'invalid JSON body');
105
+ }
106
+ }
107
+ /**
108
+ * Map an error thrown by an api.* function into an HTTP status + message.
109
+ * api.* uses plain Error, so we discriminate by message pattern. Stable
110
+ * patterns we rely on:
111
+ * - /not found/i → 404 (forget on unknown id, supersede on unknown old id, etc.)
112
+ * - /unknown/i → 404 (auth_revoke on unknown key_id)
113
+ * - /already superseded/i → 409 (chain conflict)
114
+ * - /not raw/i → 400 (archive_raw on non-raw row)
115
+ * Everything else maps to 400 (bad input).
116
+ */
117
+ function mapApiError(err) {
118
+ const message = err instanceof Error ? err.message : String(err);
119
+ const lower = message.toLowerCase();
120
+ if (/not found/.test(lower) || /^unknown /.test(lower)) {
121
+ return { status: 404, message };
122
+ }
123
+ if (/already superseded/.test(lower)) {
124
+ return { status: 409, message };
125
+ }
126
+ return { status: 400, message };
127
+ }
128
+ function parseRequest(req) {
129
+ const url = new URL(req.url ?? '/', 'http://placeholder');
130
+ return {
131
+ method: req.method ?? 'GET',
132
+ path: url.pathname,
133
+ query: url.searchParams,
134
+ };
135
+ }
136
+ /**
137
+ * Lightweight pattern matcher for /v1/memories/:id/<action>. Avoids pulling
138
+ * in a router dependency for the half-dozen patterns we actually use.
139
+ *
140
+ * Returns null if `path` does not match `pattern`. Otherwise returns an object
141
+ * mapping each :param name to its value. Path segments are exact-matched
142
+ * except for parameter slots.
143
+ */
144
+ function matchPath(pattern, path) {
145
+ const patternParts = pattern.split('/');
146
+ const pathParts = path.split('/');
147
+ if (patternParts.length !== pathParts.length)
148
+ return null;
149
+ const params = {};
150
+ for (let i = 0; i < patternParts.length; i++) {
151
+ const pp = patternParts[i];
152
+ const ap = pathParts[i];
153
+ if (pp.startsWith(':')) {
154
+ if (ap.length === 0)
155
+ return null;
156
+ params[pp.slice(1)] = decodeURIComponent(ap);
157
+ }
158
+ else if (pp !== ap) {
159
+ return null;
160
+ }
161
+ }
162
+ return params;
163
+ }
164
+ /**
165
+ * Recognise loopback remote addresses. Node reports IPv6-mapped IPv4 as
166
+ * '::ffff:127.0.0.1' on dual-stack sockets, so we accept that alongside
167
+ * the bare v4 and v6 loopbacks. Anything else is treated as remote.
168
+ */
169
+ export function isLoopback(remoteAddress) {
170
+ if (!remoteAddress)
171
+ return false;
172
+ if (remoteAddress === '127.0.0.1')
173
+ return true;
174
+ if (remoteAddress === '::1')
175
+ return true;
176
+ if (remoteAddress === '::ffff:127.0.0.1')
177
+ return true;
178
+ return false;
179
+ }
180
+ function readAuthHeader(req) {
181
+ const raw = req.headers['authorization'];
182
+ if (raw === undefined)
183
+ return { kind: 'absent' };
184
+ const value = Array.isArray(raw) ? raw[0] : raw;
185
+ if (typeof value !== 'string' || value.length === 0)
186
+ return { kind: 'malformed' };
187
+ const space = value.indexOf(' ');
188
+ if (space < 0)
189
+ return { kind: 'malformed' };
190
+ const scheme = value.slice(0, space);
191
+ const token = value.slice(space + 1).trim();
192
+ if (scheme.toLowerCase() !== 'bearer')
193
+ return { kind: 'malformed' };
194
+ if (token.length === 0)
195
+ return { kind: 'malformed' };
196
+ return { kind: 'bearer', token };
197
+ }
198
+ /**
199
+ * Build a per-request Context from the Authorization header and remote
200
+ * address. Throws HttpError(401) for invalid / missing credentials. Opens
201
+ * the DB only when a Bearer token is present so loopback no-auth requests
202
+ * stay cheap.
203
+ */
204
+ function buildContextWithAuth(req, hippoRoot) {
205
+ const auth = readAuthHeader(req);
206
+ if (auth.kind === 'malformed') {
207
+ throw new HttpError(401, 'invalid api key');
208
+ }
209
+ if (auth.kind === 'bearer') {
210
+ const db = openHippoDb(hippoRoot);
211
+ try {
212
+ const result = validateApiKey(db, auth.token);
213
+ if (!result.valid || !result.tenantId || !result.keyId) {
214
+ throw new HttpError(401, 'invalid api key');
215
+ }
216
+ return {
217
+ hippoRoot,
218
+ tenantId: result.tenantId,
219
+ actor: `api_key:${result.keyId}`,
220
+ };
221
+ }
222
+ finally {
223
+ closeHippoDb(db);
224
+ }
225
+ }
226
+ // No Authorization header. Loopback-only fallback, unless explicitly
227
+ // disabled via HIPPO_REQUIRE_AUTH=1 (used by the bearer-lockdown test
228
+ // and by deployments that want to forbid the local-CLI escape hatch).
229
+ if (process.env.HIPPO_REQUIRE_AUTH === '1') {
230
+ throw new HttpError(401, 'auth required');
231
+ }
232
+ if (!isLoopback(req.socket.remoteAddress)) {
233
+ throw new HttpError(401, 'auth required');
234
+ }
235
+ return {
236
+ hippoRoot,
237
+ tenantId: resolveTenantId({}),
238
+ actor: 'localhost:cli',
239
+ };
240
+ }
241
+ /**
242
+ * Auth check for routes that do not need a tenant Context (e.g. MCP transport,
243
+ * which builds its own root resolution via findHippoRoot). Throws HttpError
244
+ * 401 the same way buildContextWithAuth does, but skips building the Context
245
+ * envelope. Loopback no-auth still passes.
246
+ */
247
+ function requireAuth(req, hippoRoot) {
248
+ const auth = readAuthHeader(req);
249
+ if (auth.kind === 'malformed') {
250
+ throw new HttpError(401, 'invalid api key');
251
+ }
252
+ if (auth.kind === 'bearer') {
253
+ const db = openHippoDb(hippoRoot);
254
+ try {
255
+ const result = validateApiKey(db, auth.token);
256
+ if (!result.valid) {
257
+ throw new HttpError(401, 'invalid api key');
258
+ }
259
+ }
260
+ finally {
261
+ closeHippoDb(db);
262
+ }
263
+ return;
264
+ }
265
+ if (process.env.HIPPO_REQUIRE_AUTH === '1') {
266
+ throw new HttpError(401, 'auth required');
267
+ }
268
+ if (!isLoopback(req.socket.remoteAddress)) {
269
+ throw new HttpError(401, 'auth required');
270
+ }
271
+ }
272
+ function getString(obj, key) {
273
+ const v = obj[key];
274
+ return typeof v === 'string' ? v : undefined;
275
+ }
276
+ function getStringArray(obj, key) {
277
+ const v = obj[key];
278
+ if (!Array.isArray(v))
279
+ return undefined;
280
+ if (!v.every((x) => typeof x === 'string'))
281
+ return undefined;
282
+ return v;
283
+ }
284
+ async function handleRequest(req, res, opts, startedAt) {
285
+ const { method, path, query } = parseRequest(req);
286
+ if (method === 'GET' && path === '/health') {
287
+ sendJson(res, 200, {
288
+ ok: true,
289
+ version: VERSION,
290
+ started_at: startedAt,
291
+ pid: process.pid,
292
+ });
293
+ return;
294
+ }
295
+ // POST /v1/memories
296
+ if (method === 'POST' && path === '/v1/memories') {
297
+ const body = await parseJsonBody(req);
298
+ const content = getString(body, 'content');
299
+ if (!content) {
300
+ throw new HttpError(400, 'content is required');
301
+ }
302
+ const kindRaw = getString(body, 'kind');
303
+ if (kindRaw !== undefined && !VALID_KINDS.has(kindRaw)) {
304
+ throw new HttpError(400, `invalid kind: ${kindRaw}`);
305
+ }
306
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
307
+ const result = remember(ctx, {
308
+ content,
309
+ kind: kindRaw,
310
+ scope: getString(body, 'scope'),
311
+ owner: getString(body, 'owner'),
312
+ artifactRef: getString(body, 'artifactRef'),
313
+ tags: getStringArray(body, 'tags'),
314
+ });
315
+ sendJson(res, 200, result);
316
+ return;
317
+ }
318
+ // GET /v1/memories?q=...&limit=...&mode=...
319
+ if (method === 'GET' && path === '/v1/memories') {
320
+ const q = query.get('q');
321
+ if (!q) {
322
+ throw new HttpError(400, 'q is required');
323
+ }
324
+ const limitRaw = query.get('limit');
325
+ const limit = limitRaw === null ? undefined : Number(limitRaw);
326
+ if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
327
+ throw new HttpError(400, 'limit must be a positive number');
328
+ }
329
+ const mode = query.get('mode');
330
+ if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
331
+ throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
332
+ }
333
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
334
+ const result = recall(ctx, {
335
+ query: q,
336
+ limit,
337
+ mode: (mode ?? undefined),
338
+ });
339
+ sendJson(res, 200, result);
340
+ return;
341
+ }
342
+ // /v1/memories/:id/* and DELETE /v1/memories/:id
343
+ const archiveMatch = matchPath('/v1/memories/:id/archive', path);
344
+ if (method === 'POST' && archiveMatch) {
345
+ const body = await parseJsonBody(req);
346
+ const reason = getString(body, 'reason');
347
+ if (!reason) {
348
+ throw new HttpError(400, 'reason is required');
349
+ }
350
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
351
+ const result = archiveRaw(ctx, archiveMatch.id, reason);
352
+ sendJson(res, 200, result);
353
+ return;
354
+ }
355
+ const supersedeMatch = matchPath('/v1/memories/:id/supersede', path);
356
+ if (method === 'POST' && supersedeMatch) {
357
+ const body = await parseJsonBody(req);
358
+ const content = getString(body, 'content');
359
+ if (!content) {
360
+ throw new HttpError(400, 'content is required');
361
+ }
362
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
363
+ const result = supersede(ctx, supersedeMatch.id, content);
364
+ sendJson(res, 200, result);
365
+ return;
366
+ }
367
+ const promoteMatch = matchPath('/v1/memories/:id/promote', path);
368
+ if (method === 'POST' && promoteMatch) {
369
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
370
+ const result = promote(ctx, promoteMatch.id);
371
+ sendJson(res, 200, result);
372
+ return;
373
+ }
374
+ const idMatch = matchPath('/v1/memories/:id', path);
375
+ if (method === 'DELETE' && idMatch) {
376
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
377
+ const result = forget(ctx, idMatch.id);
378
+ sendJson(res, 200, result);
379
+ return;
380
+ }
381
+ // POST /v1/auth/keys — mint a new API key. Plaintext lands in the response
382
+ // body (Task 8): the HTTP layer hands it to the client; the user-facing
383
+ // "store this somewhere safe" warning belongs in the CLI client, not here.
384
+ if (method === 'POST' && path === '/v1/auth/keys') {
385
+ const body = await parseJsonBody(req);
386
+ const labelRaw = body['label'];
387
+ if (labelRaw !== undefined && typeof labelRaw !== 'string') {
388
+ throw new HttpError(400, 'label must be a string');
389
+ }
390
+ const tenantIdRaw = body['tenantId'];
391
+ if (tenantIdRaw !== undefined && typeof tenantIdRaw !== 'string') {
392
+ throw new HttpError(400, 'tenantId must be a string');
393
+ }
394
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
395
+ const result = authCreate(ctx, {
396
+ label: labelRaw,
397
+ tenantId: tenantIdRaw,
398
+ });
399
+ sendJson(res, 200, result);
400
+ return;
401
+ }
402
+ // GET /v1/auth/keys?active=true — list keys visible to ctx.tenantId.
403
+ // `active` defaults to true so the common case (show me usable keys) is
404
+ // a single GET; ?active=false includes revoked rows.
405
+ if (method === 'GET' && path === '/v1/auth/keys') {
406
+ const activeRaw = query.get('active');
407
+ let active = true;
408
+ if (activeRaw !== null) {
409
+ if (activeRaw === 'true')
410
+ active = true;
411
+ else if (activeRaw === 'false')
412
+ active = false;
413
+ else
414
+ throw new HttpError(400, "active must be 'true' or 'false'");
415
+ }
416
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
417
+ const result = authList(ctx, { active });
418
+ sendJson(res, 200, result);
419
+ return;
420
+ }
421
+ // DELETE /v1/auth/keys/:keyId — revoke. authRevoke throws "Unknown key_id"
422
+ // for missing OR cross-tenant keys (no info leak), which mapApiError
423
+ // converts to 404. We return 200 with the result body rather than 204 to
424
+ // surface revokedAt to the caller.
425
+ const keyMatch = matchPath('/v1/auth/keys/:keyId', path);
426
+ if (method === 'DELETE' && keyMatch) {
427
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
428
+ const result = authRevoke(ctx, keyMatch.keyId);
429
+ sendJson(res, 200, result);
430
+ return;
431
+ }
432
+ // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
433
+ // validated at the route boundary so an invalid value lands a 400 before
434
+ // we hit the DB.
435
+ if (method === 'GET' && path === '/v1/audit') {
436
+ const opRaw = query.get('op');
437
+ let op;
438
+ if (opRaw !== null) {
439
+ if (!VALID_AUDIT_OPS.has(opRaw)) {
440
+ throw new HttpError(400, `invalid op: ${opRaw}`);
441
+ }
442
+ op = opRaw;
443
+ }
444
+ const sinceRaw = query.get('since');
445
+ let since;
446
+ if (sinceRaw !== null) {
447
+ const parsed = Date.parse(sinceRaw);
448
+ if (!Number.isFinite(parsed)) {
449
+ throw new HttpError(400, `invalid since: ${sinceRaw}`);
450
+ }
451
+ since = sinceRaw;
452
+ }
453
+ const limitRaw = query.get('limit');
454
+ let limit;
455
+ if (limitRaw !== null) {
456
+ const parsed = Number(limitRaw);
457
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > MAX_AUDIT_LIMIT) {
458
+ throw new HttpError(400, `limit must be an integer between 1 and ${MAX_AUDIT_LIMIT}`);
459
+ }
460
+ limit = parsed;
461
+ }
462
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
463
+ const result = auditList(ctx, { op, since, limit });
464
+ sendJson(res, 200, result);
465
+ return;
466
+ }
467
+ // ── POST /v1/connectors/slack/events ──
468
+ //
469
+ // Slack Events API webhook. Auth is signature-based (HMAC over the raw
470
+ // body with SLACK_SIGNING_SECRET); Bearer is NOT required, which is why
471
+ // this route is in PUBLIC_ROUTES. The route is responsible for:
472
+ // 1. Echoing the one-time url_verification challenge.
473
+ // 2. Verifying the HMAC on every other inbound payload.
474
+ // 3. Resolving body.team_id → tenantId via slack_workspaces, falling
475
+ // back to HIPPO_TENANT then 'default'.
476
+ // 4. Dispatching event_callback envelopes to ingestMessage /
477
+ // handleMessageDeleted.
478
+ // 5. Parking malformed or unhandled payloads in slack_dlq and STILL
479
+ // ACKing 200 — Slack retries forever otherwise.
480
+ //
481
+ // Review patch #7: when SLACK_SIGNING_SECRET is unset we return 404, not
482
+ // 503, so an external probe cannot distinguish "route gated off by config"
483
+ // from "route does not exist on this build".
484
+ if (method === 'POST' && path === '/v1/connectors/slack/events') {
485
+ // Bearer auth deliberately skipped — this route is in PUBLIC_ROUTES
486
+ // and authenticates via the Slack HMAC signature instead.
487
+ if (!isPublicRoute(method, path)) {
488
+ // Defensive: PUBLIC_ROUTES drift would land here. Fail closed.
489
+ throw new HttpError(401, 'auth required');
490
+ }
491
+ const rawBody = await readBody(req);
492
+ const secret = process.env.SLACK_SIGNING_SECRET;
493
+ if (!secret) {
494
+ res.writeHead(404, JSON_HEADERS);
495
+ res.end(JSON.stringify({ error: 'not found' }));
496
+ return;
497
+ }
498
+ const sig = req.headers['x-slack-signature'];
499
+ const tsHdr = req.headers['x-slack-request-timestamp'];
500
+ if (typeof sig !== 'string' ||
501
+ typeof tsHdr !== 'string' ||
502
+ !verifySlackSignature({
503
+ rawBody,
504
+ timestamp: tsHdr,
505
+ signature: sig,
506
+ signingSecret: secret,
507
+ })) {
508
+ throw new HttpError(401, 'invalid Slack signature');
509
+ }
510
+ let body;
511
+ try {
512
+ body = JSON.parse(rawBody);
513
+ }
514
+ catch {
515
+ const db = openHippoDb(opts.hippoRoot);
516
+ try {
517
+ writeToDlq(db, {
518
+ tenantId: process.env.HIPPO_TENANT ?? 'default',
519
+ rawPayload: rawBody,
520
+ error: 'invalid JSON',
521
+ });
522
+ }
523
+ finally {
524
+ closeHippoDb(db);
525
+ }
526
+ sendJson(res, 200, { ok: true, status: 'dlq' });
527
+ return;
528
+ }
529
+ if (body &&
530
+ typeof body === 'object' &&
531
+ body.type === 'url_verification') {
532
+ sendJson(res, 200, {
533
+ challenge: String(body.challenge ?? ''),
534
+ });
535
+ return;
536
+ }
537
+ let resolvedTenant = process.env.HIPPO_TENANT ?? 'default';
538
+ if (isSlackEventEnvelope(body)) {
539
+ const db = openHippoDb(opts.hippoRoot);
540
+ try {
541
+ const mapped = resolveTenantForTeam(db, body.team_id);
542
+ if (mapped)
543
+ resolvedTenant = mapped;
544
+ }
545
+ finally {
546
+ closeHippoDb(db);
547
+ }
548
+ }
549
+ const ctx = {
550
+ hippoRoot: opts.hippoRoot,
551
+ tenantId: resolvedTenant,
552
+ actor: 'connector:slack',
553
+ };
554
+ if (!isSlackEventEnvelope(body)) {
555
+ const db = openHippoDb(ctx.hippoRoot);
556
+ try {
557
+ writeToDlq(db, {
558
+ tenantId: ctx.tenantId,
559
+ rawPayload: rawBody,
560
+ error: 'not an event_callback envelope',
561
+ });
562
+ }
563
+ finally {
564
+ closeHippoDb(db);
565
+ }
566
+ sendJson(res, 200, { ok: true, status: 'dlq' });
567
+ return;
568
+ }
569
+ const inner = body.event;
570
+ if (isSlackMessageEvent(inner)) {
571
+ if (inner.subtype === 'message_deleted' && inner.deleted_ts) {
572
+ const r = handleMessageDeleted(ctx, {
573
+ teamId: body.team_id,
574
+ channelId: inner.channel,
575
+ deletedTs: inner.deleted_ts,
576
+ eventId: body.event_id,
577
+ });
578
+ sendJson(res, 200, { ok: true, status: r.status });
579
+ return;
580
+ }
581
+ const r = ingestMessage(ctx, {
582
+ teamId: body.team_id,
583
+ // channel privacy isn't on the inner event; use channel_type as a
584
+ // proxy. 'group'|'im'|'mpim' → private. 'channel' → public. Unknown
585
+ // → private (fail closed).
586
+ channel: {
587
+ id: inner.channel,
588
+ is_private: inner.channel_type !== 'channel',
589
+ is_im: inner.channel_type === 'im',
590
+ is_mpim: inner.channel_type === 'mpim',
591
+ },
592
+ message: inner,
593
+ eventId: body.event_id,
594
+ });
595
+ sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
596
+ return;
597
+ }
598
+ const db = openHippoDb(ctx.hippoRoot);
599
+ try {
600
+ writeToDlq(db, {
601
+ tenantId: ctx.tenantId,
602
+ rawPayload: rawBody,
603
+ error: `unhandled event type: ${inner?.type ?? 'unknown'}`,
604
+ });
605
+ }
606
+ finally {
607
+ closeHippoDb(db);
608
+ }
609
+ sendJson(res, 200, { ok: true, status: 'dlq' });
610
+ return;
611
+ }
612
+ // ── MCP-over-HTTP/SSE transport (Task 11) ──
613
+ //
614
+ // Two routes implement an MCP HTTP transport alongside the stdio one. Both
615
+ // dispatch to the same `handleMcpRequest` as the stdio loop in src/mcp/server.ts.
616
+ //
617
+ // POST /mcp — Send a JSON-RPC request, get a JSON-RPC response synchronously
618
+ // in the body. Content-type: application/json both ways.
619
+ // GET /mcp/stream — Open an SSE stream for server-initiated messages.
620
+ // v1 simplification: this stream is keepalive-only. Clients
621
+ // that need server-pushed notifications/progress will see
622
+ // only `: ping` comments every 30s. All real responses come
623
+ // back synchronously on POST /mcp. This matches the
624
+ // "synchronous JSON in body" leg of the MCP HTTP spec and
625
+ // is enough for `tools/list` / `tools/call` round-trips.
626
+ // Server-initiated SSE messages will be wired in a later task.
627
+ //
628
+ // Auth: same as /v1/* — Bearer token validated via `requireAuth`, with the
629
+ // loopback no-auth fallback. SSE check runs once at stream-open.
630
+ if (method === 'POST' && path === '/mcp') {
631
+ // Build the same Context the /v1/* routes use so MCP tool calls inherit
632
+ // the server's bound hippoRoot and the auth-resolved tenantId / actor.
633
+ // Without this, executeTool would walk from cwd via findHippoRoot() and
634
+ // pull tenant from HIPPO_TENANT, dropping a valid Bearer for tenant B
635
+ // back to whatever the env says.
636
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
637
+ const raw = await readBody(req);
638
+ let mcpReq;
639
+ try {
640
+ mcpReq = JSON.parse(raw);
641
+ }
642
+ catch {
643
+ throw new HttpError(400, 'invalid JSON-RPC body');
644
+ }
645
+ if (!mcpReq || typeof mcpReq !== 'object' || typeof mcpReq.method !== 'string') {
646
+ throw new HttpError(400, 'JSON-RPC body must include a method string');
647
+ }
648
+ let mcpRes;
649
+ try {
650
+ mcpRes = await handleMcpRequest(mcpReq, {
651
+ hippoRoot: ctx.hippoRoot,
652
+ tenantId: ctx.tenantId,
653
+ actor: ctx.actor,
654
+ });
655
+ }
656
+ catch (err) {
657
+ mcpRes = {
658
+ jsonrpc: '2.0',
659
+ id: mcpReq.id,
660
+ error: { code: -32603, message: err instanceof Error ? err.message : 'internal error' },
661
+ };
662
+ }
663
+ if (mcpRes === null) {
664
+ // Notification — no body, 202 Accepted.
665
+ res.writeHead(202);
666
+ res.end();
667
+ return;
668
+ }
669
+ sendJson(res, 200, mcpRes);
670
+ return;
671
+ }
672
+ if (method === 'GET' && path === '/mcp/stream') {
673
+ requireAuth(req, opts.hippoRoot);
674
+ res.writeHead(200, {
675
+ 'content-type': 'text/event-stream',
676
+ 'cache-control': 'no-cache',
677
+ connection: 'keep-alive',
678
+ });
679
+ // Initial ping so smoke tests can confirm the stream is live without
680
+ // waiting 30s for the first keepalive interval.
681
+ res.write(': ping\n\n');
682
+ const ping = setInterval(() => {
683
+ try {
684
+ res.write(': ping\n\n');
685
+ }
686
+ catch {
687
+ clearInterval(ping);
688
+ }
689
+ }, 30000);
690
+ // Don't keep the event loop alive just for this timer — the server's
691
+ // listener already does that, and tests want the process to exit cleanly.
692
+ if (typeof ping.unref === 'function')
693
+ ping.unref();
694
+ req.on('close', () => clearInterval(ping));
695
+ return;
696
+ }
697
+ res.writeHead(404, JSON_HEADERS);
698
+ res.end(JSON.stringify({ error: 'not found' }));
699
+ }
700
+ /**
701
+ * Boot the HTTP daemon on host:port and write the pidfile under hippoRoot.
702
+ *
703
+ * Refuses non-loopback hosts at boot (Footgun #3 from the A1 plan): without
704
+ * the A5 v2 auth middleware we have no way to gate remote requests, so we
705
+ * fail fast rather than expose the DB to the network. Task 9 will lift this
706
+ * restriction once Bearer-token validation lands.
707
+ *
708
+ * Use port: 0 in tests to bind to an ephemeral port and read the actual
709
+ * port back via server.address() after listen.
710
+ */
711
+ export async function serve(opts) {
712
+ const host = opts.host ?? '127.0.0.1';
713
+ const requestedPort = opts.port ?? Number(process.env.HIPPO_PORT ?? 6789);
714
+ if (!LOOPBACK_HOSTS.has(host)) {
715
+ throw new Error(`Refusing to bind hippo serve to non-loopback host '${host}' without auth. ` +
716
+ `Remote-host serving requires the A5 v2 auth middleware (Task 9 of the A1 plan). ` +
717
+ `Bind to 127.0.0.1 / ::1 / localhost, or wait for auth support.`);
718
+ }
719
+ const startedAt = new Date().toISOString();
720
+ const server = createServer((req, res) => {
721
+ handleRequest(req, res, opts, startedAt).catch((err) => {
722
+ if (res.headersSent) {
723
+ try {
724
+ res.end();
725
+ }
726
+ catch { /* socket already gone */ }
727
+ return;
728
+ }
729
+ if (err instanceof BodyTooLargeError) {
730
+ sendError(res, 413, err.message);
731
+ return;
732
+ }
733
+ if (err instanceof HttpError) {
734
+ sendError(res, err.status, err.message);
735
+ return;
736
+ }
737
+ const mapped = mapApiError(err);
738
+ sendError(res, mapped.status, mapped.message);
739
+ });
740
+ });
741
+ await new Promise((resolve, reject) => {
742
+ const onError = (err) => {
743
+ server.removeListener('listening', onListening);
744
+ reject(err);
745
+ };
746
+ const onListening = () => {
747
+ server.removeListener('error', onError);
748
+ resolve();
749
+ };
750
+ server.once('error', onError);
751
+ server.once('listening', onListening);
752
+ server.listen(requestedPort, host);
753
+ });
754
+ const address = server.address();
755
+ if (!address || typeof address === 'string') {
756
+ throw new Error('server.address() returned unexpected shape');
757
+ }
758
+ const actualPort = address.port;
759
+ const url = `http://${host}:${actualPort}`;
760
+ writePidfile(opts.hippoRoot, { port: actualPort, url });
761
+ let stopping = false;
762
+ const stop = async () => {
763
+ if (stopping)
764
+ return;
765
+ stopping = true;
766
+ removePidfile(opts.hippoRoot);
767
+ // Force-close any long-lived idle connections (e.g. SSE keepalive streams
768
+ // on /mcp/stream) so server.close() can resolve. Without this, SIGTERM
769
+ // would hang the process until the SSE client cancels. Available on
770
+ // Node 18.2+; gate via optional chaining to avoid crashing on older runtimes.
771
+ server.closeAllConnections?.();
772
+ await new Promise((resolve) => {
773
+ server.close(() => resolve());
774
+ });
775
+ };
776
+ // Skip signal handlers under vitest so each test run does not register a
777
+ // stray SIGTERM/SIGINT listener that survives until the runner exits.
778
+ if (!process.env.VITEST) {
779
+ process.once('SIGTERM', () => { void stop(); });
780
+ process.once('SIGINT', () => { void stop(); });
781
+ }
782
+ return { port: actualPort, url, stop };
783
+ }
784
+ //# sourceMappingURL=server.js.map