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.
- package/README.md +25 -0
- package/dist/api.d.ts +183 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +343 -0
- package/dist/api.js.map +1 -0
- package/dist/benchmarks/e1.3/incident-recall-eval.js +74 -0
- package/dist/benchmarks/e1.3/incident-recall-eval.js.map +1 -0
- package/dist/benchmarks/e1.3/scenarios.json +2587 -0
- package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +102 -0
- package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +1 -0
- package/dist/cli.js +222 -34
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +54 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +181 -0
- package/dist/client.js.map +1 -0
- package/dist/connectors/slack/backfill.d.ts +42 -0
- package/dist/connectors/slack/backfill.d.ts.map +1 -0
- package/dist/connectors/slack/backfill.js +76 -0
- package/dist/connectors/slack/backfill.js.map +1 -0
- package/dist/connectors/slack/deletion.d.ts +14 -0
- package/dist/connectors/slack/deletion.d.ts.map +1 -0
- package/dist/connectors/slack/deletion.js +46 -0
- package/dist/connectors/slack/deletion.js.map +1 -0
- package/dist/connectors/slack/dlq.d.ts +21 -0
- package/dist/connectors/slack/dlq.d.ts.map +1 -0
- package/dist/connectors/slack/dlq.js +23 -0
- package/dist/connectors/slack/dlq.js.map +1 -0
- package/dist/connectors/slack/idempotency.d.ts +5 -0
- package/dist/connectors/slack/idempotency.d.ts.map +1 -0
- package/dist/connectors/slack/idempotency.js +13 -0
- package/dist/connectors/slack/idempotency.js.map +1 -0
- package/dist/connectors/slack/ingest.d.ts +27 -0
- package/dist/connectors/slack/ingest.d.ts.map +1 -0
- package/dist/connectors/slack/ingest.js +48 -0
- package/dist/connectors/slack/ingest.js.map +1 -0
- package/dist/connectors/slack/ratelimit.d.ts +9 -0
- package/dist/connectors/slack/ratelimit.d.ts.map +1 -0
- package/dist/connectors/slack/ratelimit.js +18 -0
- package/dist/connectors/slack/ratelimit.js.map +1 -0
- package/dist/connectors/slack/scope.d.ts +16 -0
- package/dist/connectors/slack/scope.d.ts.map +1 -0
- package/dist/connectors/slack/scope.js +13 -0
- package/dist/connectors/slack/scope.js.map +1 -0
- package/dist/connectors/slack/signature.d.ts +12 -0
- package/dist/connectors/slack/signature.d.ts.map +1 -0
- package/dist/connectors/slack/signature.js +20 -0
- package/dist/connectors/slack/signature.js.map +1 -0
- package/dist/connectors/slack/tenant-routing.d.ts +13 -0
- package/dist/connectors/slack/tenant-routing.d.ts.map +1 -0
- package/dist/connectors/slack/tenant-routing.js +17 -0
- package/dist/connectors/slack/tenant-routing.js.map +1 -0
- package/dist/connectors/slack/transform.d.ts +20 -0
- package/dist/connectors/slack/transform.d.ts.map +1 -0
- package/dist/connectors/slack/transform.js +31 -0
- package/dist/connectors/slack/transform.js.map +1 -0
- package/dist/connectors/slack/types.d.ts +35 -0
- package/dist/connectors/slack/types.d.ts.map +1 -0
- package/dist/connectors/slack/types.js +23 -0
- package/dist/connectors/slack/types.js.map +1 -0
- package/dist/connectors/slack/web-client.d.ts +12 -0
- package/dist/connectors/slack/web-client.d.ts.map +1 -0
- package/dist/connectors/slack/web-client.js +43 -0
- package/dist/connectors/slack/web-client.js.map +1 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +46 -1
- package/dist/db.js.map +1 -1
- package/dist/importers.js +3 -3
- package/dist/importers.js.map +1 -1
- package/dist/mcp/server.d.ts +46 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +74 -26
- package/dist/mcp/server.js.map +1 -1
- package/dist/server-detect.d.ts +26 -0
- package/dist/server-detect.d.ts.map +1 -0
- package/dist/server-detect.js +70 -0
- package/dist/server-detect.js.map +1 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +784 -0
- package/dist/server.js.map +1 -0
- package/dist/shared.d.ts +3 -1
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +2 -2
- package/dist/shared.js.map +1 -1
- package/dist/src/ambient.js +147 -0
- package/dist/src/ambient.js.map +1 -0
- package/dist/src/api.js +343 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/audit.js +152 -0
- package/dist/src/audit.js.map +1 -0
- package/dist/src/auth.js +65 -0
- package/dist/src/auth.js.map +1 -0
- package/dist/src/autolearn.js +143 -0
- package/dist/src/autolearn.js.map +1 -0
- package/dist/src/capture.js +512 -0
- package/dist/src/capture.js.map +1 -0
- package/dist/src/cli.js +4971 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/client.js +181 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/config.js +108 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/connectors/slack/backfill.js +76 -0
- package/dist/src/connectors/slack/backfill.js.map +1 -0
- package/dist/src/connectors/slack/deletion.js +46 -0
- package/dist/src/connectors/slack/deletion.js.map +1 -0
- package/dist/src/connectors/slack/dlq.js +23 -0
- package/dist/src/connectors/slack/dlq.js.map +1 -0
- package/dist/src/connectors/slack/idempotency.js +13 -0
- package/dist/src/connectors/slack/idempotency.js.map +1 -0
- package/dist/src/connectors/slack/ingest.js +48 -0
- package/dist/src/connectors/slack/ingest.js.map +1 -0
- package/dist/src/connectors/slack/ratelimit.js +18 -0
- package/dist/src/connectors/slack/ratelimit.js.map +1 -0
- package/dist/src/connectors/slack/scope.js +13 -0
- package/dist/src/connectors/slack/scope.js.map +1 -0
- package/dist/src/connectors/slack/signature.js +20 -0
- package/dist/src/connectors/slack/signature.js.map +1 -0
- package/dist/src/connectors/slack/tenant-routing.js +17 -0
- package/dist/src/connectors/slack/tenant-routing.js.map +1 -0
- package/dist/src/connectors/slack/transform.js +31 -0
- package/dist/src/connectors/slack/transform.js.map +1 -0
- package/dist/src/connectors/slack/types.js +23 -0
- package/dist/src/connectors/slack/types.js.map +1 -0
- package/dist/src/connectors/slack/web-client.js +43 -0
- package/dist/src/connectors/slack/web-client.js.map +1 -0
- package/dist/src/consolidate.js +517 -0
- package/dist/src/consolidate.js.map +1 -0
- package/dist/src/dag.js +104 -0
- package/dist/src/dag.js.map +1 -0
- package/dist/src/dashboard.js +409 -0
- package/dist/src/dashboard.js.map +1 -0
- package/dist/src/db.js +584 -0
- package/dist/src/db.js.map +1 -0
- package/dist/src/embeddings.js +344 -0
- package/dist/src/embeddings.js.map +1 -0
- package/dist/src/eval-suite.js +289 -0
- package/dist/src/eval-suite.js.map +1 -0
- package/dist/src/eval.js +187 -0
- package/dist/src/eval.js.map +1 -0
- package/dist/src/extract.js +87 -0
- package/dist/src/extract.js.map +1 -0
- package/dist/src/handoff.js +30 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/hooks.js +582 -0
- package/dist/src/hooks.js.map +1 -0
- package/dist/src/importers.js +399 -0
- package/dist/src/importers.js.map +1 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/invalidation.js +94 -0
- package/dist/src/invalidation.js.map +1 -0
- package/dist/src/mcp/framing.js +45 -0
- package/dist/src/mcp/framing.js.map +1 -0
- package/dist/src/mcp/server.js +510 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/memory.js +280 -0
- package/dist/src/memory.js.map +1 -0
- package/dist/src/multihop.js +32 -0
- package/dist/src/multihop.js.map +1 -0
- package/dist/src/path-context.js +32 -0
- package/dist/src/path-context.js.map +1 -0
- package/dist/src/physics-config.js +26 -0
- package/dist/src/physics-config.js.map +1 -0
- package/dist/src/physics-state.js +163 -0
- package/dist/src/physics-state.js.map +1 -0
- package/dist/src/physics.js +361 -0
- package/dist/src/physics.js.map +1 -0
- package/dist/src/postinstall.js +68 -0
- package/dist/src/postinstall.js.map +1 -0
- package/dist/src/raw-archive.js +72 -0
- package/dist/src/raw-archive.js.map +1 -0
- package/dist/src/refine-llm.js +147 -0
- package/dist/src/refine-llm.js.map +1 -0
- package/dist/src/replay.js +117 -0
- package/dist/src/replay.js.map +1 -0
- package/dist/src/salience.js +74 -0
- package/dist/src/salience.js.map +1 -0
- package/dist/src/scheduler.js +67 -0
- package/dist/src/scheduler.js.map +1 -0
- package/dist/src/scope.js +35 -0
- package/dist/src/scope.js.map +1 -0
- package/dist/src/search.js +801 -0
- package/dist/src/search.js.map +1 -0
- package/dist/src/server-detect.js +70 -0
- package/dist/src/server-detect.js.map +1 -0
- package/dist/src/server.js +784 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/shared.js +309 -0
- package/dist/src/shared.js.map +1 -0
- package/dist/src/sso.js +22 -0
- package/dist/src/sso.js.map +1 -0
- package/dist/src/store.js +1390 -0
- package/dist/src/store.js.map +1 -0
- package/dist/src/tenant.js +17 -0
- package/dist/src/tenant.js.map +1 -0
- package/dist/src/trace.js +64 -0
- package/dist/src/trace.js.map +1 -0
- package/dist/src/working-memory.js +149 -0
- package/dist/src/working-memory.js.map +1 -0
- package/dist/src/yaml.js +98 -0
- package/dist/src/yaml.js.map +1 -0
- package/dist/store.d.ts +25 -4
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +50 -11
- package/dist/store.js.map +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/dist/import.d.ts +0 -31
- package/dist/import.d.ts.map +0 -1
- package/dist/import.js +0 -307
- 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
|