hippo-memory 1.57.0 → 1.59.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 (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
- import { createHash } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { resolveProjectIdentity } from './project-identity.js';
5
5
  import { assembleCost, contextCost, drillCost } from './context-render.js';
@@ -8,7 +8,7 @@ import { resolveTenantId } from './tenant.js';
8
8
  import { openHippoDb, closeHippoDb } from './db.js';
9
9
  import { updateStats } from './store.js';
10
10
  import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, biasHintEnabled, } from './recall-history.js';
11
- import { appendAuditEvent, auditQueryFields, AUDIT_OPS } from './audit.js';
11
+ import { appendAuditEvent, auditQueryFields, auditWriteFailureCount, AUDIT_OPS } from './audit.js';
12
12
  // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
13
13
  // for the HTTP pipeline. Separate from CLI/MCP rings per plan v3 (per-
14
14
  // pipeline rings; no IPC). HTTP is the only caller that threads its
@@ -21,9 +21,10 @@ export function __resetSessionRecallHistoryHttp() {
21
21
  sessionRecallHistoryHttp.clear();
22
22
  }
23
23
  import { PACKAGE_VERSION } from './version.js';
24
+ import { log } from './log.js';
24
25
  import { API_KEY_PREFIX, validateApiKey } from './auth.js';
25
26
  import { createRateLimiter } from './rate-limit.js';
26
- import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
27
+ import { remember, retrieve, RecallContractError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
27
28
  import { buildGraphModel } from './graph-view.js';
28
29
  import { MAX_ENTITY_NAME_LEN } from './graph.js';
29
30
  import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
@@ -37,7 +38,10 @@ import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomer
37
38
  import { handleMcpRequest } from './mcp/server.js';
38
39
  import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
39
40
  import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
40
- import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, readBody, sendJson, } from './http-util.js';
41
+ import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, mapApiError, readBody, sendJson, } from './http-util.js';
42
+ import { ForbiddenError, NotFoundError } from './api-errors.js';
43
+ // Add-on packages revoke keys through these without importing the whole api surface.
44
+ export { authRevoke, ForbiddenError };
41
45
  // Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
42
46
  // New unauth routes MUST be added here AND get a corresponding entry in
43
47
  // tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
@@ -156,6 +160,22 @@ const VALID_KINDS = new Set([
156
160
  // v1.3.1: source from src/version.ts so /health no longer reports stale 0.39.0.
157
161
  const VERSION = PACKAGE_VERSION;
158
162
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
163
+ // The caller's id lands in a response header and in logs, so only a short plain token is echoed back.
164
+ const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
165
+ /** The caller's `X-Request-Id` when it is a short plain token, else a fresh UUID. */
166
+ function resolveRequestId(header) {
167
+ const value = Array.isArray(header) ? undefined : header;
168
+ return value && REQUEST_ID_RE.test(value) ? value : randomUUID();
169
+ }
170
+ /** One line per failed request; 4xx is the caller's mistake, so it stays below the default level. */
171
+ function logRequestFailure(req, err, requestId, status) {
172
+ const message = err instanceof Error ? err.message : String(err);
173
+ const line = `${req.method ?? 'GET'} ${(req.url ?? '/').split('?')[0]} failed: ${message}`;
174
+ if (status >= 500)
175
+ log.error(line, { requestId, status });
176
+ else
177
+ log.info(line, { requestId, status });
178
+ }
159
179
  function sendError(res, status, message) {
160
180
  sendJson(res, status, { error: message });
161
181
  }
@@ -176,35 +196,17 @@ async function parseJsonBody(req) {
176
196
  throw new HttpError(400, 'invalid JSON body');
177
197
  }
178
198
  }
179
- /**
180
- * Map an error thrown by an api.* function into an HTTP status + message.
181
- * api.* uses plain Error, so we discriminate by message pattern. Stable
182
- * patterns we rely on:
183
- * - /not found/i → 404 (forget on unknown id, supersede on unknown old id, etc.)
184
- * - /unknown/i → 404 (auth_revoke on unknown key_id)
185
- * - /already superseded/i → 409 (chain conflict)
186
- * - /not raw/i → 400 (archive_raw on non-raw row)
187
- * ForbiddenError maps to 403; everything else to 400 (bad input).
188
- */
189
- function mapApiError(err) {
190
- const message = err instanceof Error ? err.message : String(err);
191
- if (err instanceof ForbiddenError) {
192
- return { status: 403, message };
193
- }
194
- const lower = message.toLowerCase();
195
- if (/not found/.test(lower) || /^unknown /.test(lower)) {
196
- return { status: 404, message };
197
- }
198
- if (/already superseded/.test(lower)) {
199
- return { status: 409, message };
199
+ function parseRequest(req) {
200
+ let url;
201
+ try {
202
+ url = new URL(req.url ?? '/', 'http://placeholder');
200
203
  }
201
- if (/requires admin role/.test(lower)) {
202
- return { status: 403, message };
204
+ catch (e) {
205
+ // A request target the URL parser rejects is the caller's fault, not a server failure.
206
+ if (e instanceof TypeError)
207
+ throw new HttpError(400, e.message);
208
+ throw e;
203
209
  }
204
- return { status: 400, message };
205
- }
206
- function parseRequest(req) {
207
- const url = new URL(req.url ?? '/', 'http://placeholder');
208
210
  return {
209
211
  method: req.method ?? 'GET',
210
212
  path: url.pathname,
@@ -219,6 +221,16 @@ function parseRequest(req) {
219
221
  * mapping each :param name to its value. Path segments are exact-matched
220
222
  * except for parameter slots.
221
223
  */
224
+ function decodePathSegment(segment) {
225
+ try {
226
+ return decodeURIComponent(segment);
227
+ }
228
+ catch (e) {
229
+ if (e instanceof URIError)
230
+ throw new HttpError(400, e.message);
231
+ throw e;
232
+ }
233
+ }
222
234
  function matchPath(pattern, path) {
223
235
  const patternParts = pattern.split('/');
224
236
  const pathParts = path.split('/');
@@ -231,7 +243,7 @@ function matchPath(pattern, path) {
231
243
  if (pp.startsWith(':')) {
232
244
  if (ap.length === 0)
233
245
  return null;
234
- params[pp.slice(1)] = decodeURIComponent(ap);
246
+ params[pp.slice(1)] = decodePathSegment(ap);
235
247
  }
236
248
  else if (pp !== ap) {
237
249
  return null;
@@ -265,10 +277,22 @@ export function isCrossSite(req) {
265
277
  const origin = req.headers.origin;
266
278
  return origin !== undefined && origin !== `http://${req.headers.host}`;
267
279
  }
280
+ // A proxy on this host (nginx, Caddy, cloudflared) connects from loopback, so these headers mean the caller is not local.
281
+ const PROXY_HEADERS = [
282
+ 'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip',
283
+ ];
284
+ // The auth helpers only see the request, so its id rides here for their log lines.
285
+ const requestIds = new WeakMap();
268
286
  // A browser on this machine is loopback too, so the no-key fallback also needs a local Host and a same-site caller.
269
287
  function assertLocalCaller(req) {
270
288
  if (!isLoopback(req.socket.remoteAddress))
271
289
  throw new HttpError(401, 'auth required');
290
+ const proxyHeader = PROXY_HEADERS.find((name) => req.headers[name] !== undefined);
291
+ if (proxyHeader !== undefined) {
292
+ log.warn(`proxied loopback request refused: it carries ${proxyHeader}, so the no-key local fallback does not apply. ` +
293
+ 'Send an API key (hippo auth create, then Authorization: Bearer hk_...).', { requestId: requestIds.get(req) });
294
+ throw new HttpError(401, 'auth required');
295
+ }
272
296
  const host = req.headers.host;
273
297
  if ((host !== undefined && !LOOPBACK_HOST_HEADER.test(host)) || isCrossSite(req)) {
274
298
  throw new HttpError(403, 'cross-site or non-local request refused; send an API key');
@@ -342,6 +366,11 @@ export function clientIpForRateLimit(req) {
342
366
  const RESERVED_ACTOR_NAMES = [
343
367
  'api_key', 'localhost', 'cli', 'system', 'mcp', 'connector', 'sleep', 'post-compact', 'recall', 'agent-memories',
344
368
  ];
369
+ /** Add-ons call this to refuse a subject that would collide with a built-in actor. */
370
+ export function isReservedActor(subject) {
371
+ const lower = subject.toLowerCase();
372
+ return RESERVED_ACTOR_NAMES.some((n) => lower === n || lower.startsWith(`${n}:`));
373
+ }
345
374
  function hasControlChar(s) {
346
375
  for (let i = 0; i < s.length; i++) {
347
376
  const c = s.charCodeAt(i);
@@ -366,8 +395,7 @@ function sanitiseResolved(r) {
366
395
  // Padding would let "system " pass the reserved-name check yet read as `system` in an audit log.
367
396
  if (hasControlChar(subject) || subject !== subject.trim())
368
397
  return null;
369
- const lower = subject.toLowerCase();
370
- if (RESERVED_ACTOR_NAMES.some((n) => lower === n || lower.startsWith(`${n}:`)))
398
+ if (isReservedActor(subject))
371
399
  return null;
372
400
  const clean = { tenantId: tenant, subject, role: role === 'admin' ? 'admin' : 'member' };
373
401
  if (Array.isArray(scopes))
@@ -452,9 +480,8 @@ async function buildContextWithAuth(req, opts) {
452
480
  actor.viaAuthResolver = true;
453
481
  return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor };
454
482
  }
455
- // No Authorization header. Loopback-only fallback, unless explicitly
456
- // disabled via HIPPO_REQUIRE_AUTH=1 (used by the bearer-lockdown test
457
- // and by deployments that want to forbid the local-CLI escape hatch).
483
+ // No Authorization header. Loopback-only fallback for a direct local caller (no proxy headers),
484
+ // unless HIPPO_REQUIRE_AUTH=1 forbids the local-CLI escape hatch.
458
485
  if (process.env.HIPPO_REQUIRE_AUTH === '1') {
459
486
  throw new HttpError(401, 'auth required');
460
487
  }
@@ -1117,36 +1144,16 @@ async function handleListQuarantine({ req, res, opts, query }) {
1117
1144
  async function handleApproveQuarantine({ req, res, opts }, quarantineApproveMatch) {
1118
1145
  validateIdSegment(quarantineApproveMatch.id, 'memory id');
1119
1146
  const ctx = await buildContextWithAuth(req, opts);
1120
- try {
1121
- quarantineApprove(ctx, quarantineApproveMatch.id);
1122
- sendJson(res, 200, { approved: quarantineApproveMatch.id });
1123
- }
1124
- catch (e) {
1125
- const msg = e instanceof Error ? e.message : String(e);
1126
- if (msg.includes('not quarantined'))
1127
- throw new HttpError(404, msg);
1128
- if (msg.includes('is already') || msg.includes('scope changed'))
1129
- throw new HttpError(409, msg);
1130
- throw e;
1131
- }
1147
+ quarantineApprove(ctx, quarantineApproveMatch.id);
1148
+ sendJson(res, 200, { approved: quarantineApproveMatch.id });
1132
1149
  return;
1133
1150
  }
1134
1151
  // POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
1135
1152
  async function handleRejectQuarantine({ req, res, opts }, quarantineRejectMatch) {
1136
1153
  validateIdSegment(quarantineRejectMatch.id, 'memory id');
1137
1154
  const ctx = await buildContextWithAuth(req, opts);
1138
- try {
1139
- quarantineReject(ctx, quarantineRejectMatch.id);
1140
- sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1141
- }
1142
- catch (e) {
1143
- const msg = e instanceof Error ? e.message : String(e);
1144
- if (msg.includes('not quarantined'))
1145
- throw new HttpError(404, msg);
1146
- if (msg.includes('is already'))
1147
- throw new HttpError(409, msg);
1148
- throw e;
1149
- }
1155
+ quarantineReject(ctx, quarantineRejectMatch.id);
1156
+ sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1150
1157
  return;
1151
1158
  }
1152
1159
  // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
@@ -1335,21 +1342,12 @@ async function handleClosePrediction({ req, res, opts }, predictionCloseMatch) {
1335
1342
  closureNote = note;
1336
1343
  }
1337
1344
  const ctx = await buildContextWithAuth(req, opts);
1338
- try {
1339
- const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1340
- closureState: state,
1341
- actualValue,
1342
- closureNote,
1343
- }, ctx.actor.subject);
1344
- sendJson(res, 200, { prediction });
1345
- }
1346
- catch (e) {
1347
- const msg = e instanceof Error ? e.message : String(e);
1348
- if (msg.includes('not found')) {
1349
- throw new HttpError(404, msg);
1350
- }
1351
- throw e;
1352
- }
1345
+ const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1346
+ closureState: state,
1347
+ actualValue,
1348
+ closureNote,
1349
+ }, ctx.actor.subject);
1350
+ sendJson(res, 200, { prediction });
1353
1351
  return;
1354
1352
  }
1355
1353
  // ── decisions (E2 first-class object) ──
@@ -1400,10 +1398,9 @@ async function handleCreateDecision({ req, res, opts }) {
1400
1398
  sendJson(res, 201, { decision });
1401
1399
  }
1402
1400
  catch (e) {
1403
- const msg = e instanceof Error ? e.message : String(e);
1404
- if (msg.includes('not found') || msg.includes('not active')) {
1405
- throw new HttpError(409, msg);
1406
- }
1401
+ // A missing referenced row is a conflict with the create, not a missing target.
1402
+ if (e instanceof NotFoundError)
1403
+ throw new HttpError(409, e.message);
1407
1404
  throw e;
1408
1405
  }
1409
1406
  return;
@@ -1450,43 +1447,19 @@ async function handleSupersedeDecision({ req, res, opts }, decisionSupersedeMatc
1450
1447
  context = contextRaw;
1451
1448
  }
1452
1449
  const ctx = await buildContextWithAuth(req, opts);
1453
- try {
1454
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1455
- decisionText: text,
1456
- context,
1457
- supersedesDecisionId: oldId,
1458
- }, ctx.actor.subject);
1459
- sendJson(res, 201, { decision });
1460
- }
1461
- catch (e) {
1462
- const msg = e instanceof Error ? e.message : String(e);
1463
- if (msg.includes('not found')) {
1464
- throw new HttpError(404, msg);
1465
- }
1466
- if (msg.includes('not active')) {
1467
- throw new HttpError(409, msg);
1468
- }
1469
- throw e;
1470
- }
1450
+ const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1451
+ decisionText: text,
1452
+ context,
1453
+ supersedesDecisionId: oldId,
1454
+ }, ctx.actor.subject);
1455
+ sendJson(res, 201, { decision });
1471
1456
  return;
1472
1457
  }
1473
1458
  async function handleCloseDecision({ req, res, opts }, decisionCloseMatch) {
1474
1459
  const id = parseInt(decisionCloseMatch[1], 10);
1475
1460
  const ctx = await buildContextWithAuth(req, opts);
1476
- try {
1477
- const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1478
- sendJson(res, 200, { decision });
1479
- }
1480
- catch (e) {
1481
- const msg = e instanceof Error ? e.message : String(e);
1482
- if (msg.includes('not found')) {
1483
- throw new HttpError(404, msg);
1484
- }
1485
- if (msg.includes('not active')) {
1486
- throw new HttpError(409, msg);
1487
- }
1488
- throw e;
1489
- }
1461
+ const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1462
+ sendJson(res, 200, { decision });
1490
1463
  return;
1491
1464
  }
1492
1465
  async function handleGetDecision({ req, res, opts }, decisionByIdMatch) {
@@ -1555,10 +1528,9 @@ async function handleCreateIncident({ req, res, opts }) {
1555
1528
  sendJson(res, 201, { incident });
1556
1529
  }
1557
1530
  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
- }
1531
+ // A missing referenced row is a conflict with the create, not a missing target.
1532
+ if (e instanceof NotFoundError)
1533
+ throw new HttpError(409, e.message);
1562
1534
  throw e;
1563
1535
  }
1564
1536
  return;
@@ -1594,39 +1566,15 @@ async function handleResolveIncident({ req, res, opts }, incidentResolveMatch) {
1594
1566
  throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
1595
1567
  }
1596
1568
  const ctx = await buildContextWithAuth(req, opts);
1597
- try {
1598
- const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1599
- sendJson(res, 200, { incident });
1600
- }
1601
- catch (e) {
1602
- const msg = e instanceof Error ? e.message : String(e);
1603
- if (msg.includes('not found')) {
1604
- throw new HttpError(404, msg);
1605
- }
1606
- if (msg.includes('not open')) {
1607
- throw new HttpError(409, msg);
1608
- }
1609
- throw e;
1610
- }
1569
+ const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1570
+ sendJson(res, 200, { incident });
1611
1571
  return;
1612
1572
  }
1613
1573
  async function handleCloseIncident({ req, res, opts }, incidentCloseMatch) {
1614
1574
  const id = parseInt(incidentCloseMatch[1], 10);
1615
1575
  const ctx = await buildContextWithAuth(req, opts);
1616
- try {
1617
- const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1618
- sendJson(res, 200, { incident });
1619
- }
1620
- catch (e) {
1621
- const msg = e instanceof Error ? e.message : String(e);
1622
- if (msg.includes('not found')) {
1623
- throw new HttpError(404, msg);
1624
- }
1625
- if (msg.includes('already closed')) {
1626
- throw new HttpError(409, msg);
1627
- }
1628
- throw e;
1629
- }
1576
+ const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1577
+ sendJson(res, 200, { incident });
1630
1578
  return;
1631
1579
  }
1632
1580
  async function handleGetIncident({ req, res, opts }, incidentByIdMatch) {
@@ -1737,45 +1685,21 @@ async function handleSupersedeProcess({ req, res, opts }, processSupersedeMatch)
1737
1685
  if (!existing) {
1738
1686
  throw new HttpError(404, `process ${id} not found`);
1739
1687
  }
1740
- try {
1741
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1742
- processName: existing.processName,
1743
- steps,
1744
- description,
1745
- changeSummary,
1746
- supersedesProcessId: id,
1747
- }, ctx.actor.subject);
1748
- sendJson(res, 200, { process });
1749
- }
1750
- catch (e) {
1751
- const msg = e instanceof Error ? e.message : String(e);
1752
- if (msg.includes('not found')) {
1753
- throw new HttpError(404, msg);
1754
- }
1755
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
1756
- throw new HttpError(409, msg);
1757
- }
1758
- throw e;
1759
- }
1688
+ const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1689
+ processName: existing.processName,
1690
+ steps,
1691
+ description,
1692
+ changeSummary,
1693
+ supersedesProcessId: id,
1694
+ }, ctx.actor.subject);
1695
+ sendJson(res, 200, { process });
1760
1696
  return;
1761
1697
  }
1762
1698
  async function handleCloseProcess({ req, res, opts }, processCloseMatch) {
1763
1699
  const id = parseInt(processCloseMatch[1], 10);
1764
1700
  const ctx = await buildContextWithAuth(req, opts);
1765
- try {
1766
- const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1767
- sendJson(res, 200, { process });
1768
- }
1769
- catch (e) {
1770
- const msg = e instanceof Error ? e.message : String(e);
1771
- if (msg.includes('not found')) {
1772
- throw new HttpError(404, msg);
1773
- }
1774
- if (msg.includes('not active')) {
1775
- throw new HttpError(409, msg);
1776
- }
1777
- throw e;
1778
- }
1701
+ const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1702
+ sendJson(res, 200, { process });
1779
1703
  return;
1780
1704
  }
1781
1705
  async function handleGetProcess({ req, res, opts }, processByIdMatch) {
@@ -1816,19 +1740,13 @@ async function handleCreatePolicy({ req, res, opts }) {
1816
1740
  const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1817
1741
  const validTo = optionalDateField(body['validTo'], 'validTo');
1818
1742
  const ctx = await buildContextWithAuth(req, opts);
1819
- try {
1820
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1821
- policyName,
1822
- policyText,
1823
- validFrom,
1824
- validTo,
1825
- }, ctx.actor.subject);
1826
- sendJson(res, 201, { policy });
1827
- }
1828
- catch (e) {
1829
- // savePolicy throws on invalid/inverted dates (validation) -> 400.
1830
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
1831
- }
1743
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1744
+ policyName,
1745
+ policyText,
1746
+ validFrom,
1747
+ validTo,
1748
+ }, ctx.actor.subject);
1749
+ sendJson(res, 201, { policy });
1832
1750
  return;
1833
1751
  }
1834
1752
  async function handleListPolicies({ req, res, opts, query }) {
@@ -1860,13 +1778,8 @@ async function handlePoliciesAsOf({ req, res, opts, query }) {
1860
1778
  }
1861
1779
  const name = query.get('name') ?? undefined;
1862
1780
  const ctx = await buildContextWithAuth(req, opts);
1863
- try {
1864
- const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1865
- sendJson(res, 200, { policies });
1866
- }
1867
- catch (e) {
1868
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
1869
- }
1781
+ const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1782
+ sendJson(res, 200, { policies });
1870
1783
  return;
1871
1784
  }
1872
1785
  async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
@@ -1897,47 +1810,22 @@ async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
1897
1810
  if (!existing) {
1898
1811
  throw new HttpError(404, `policy ${id} not found`);
1899
1812
  }
1900
- try {
1901
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1902
- policyName: existing.policyName,
1903
- policyText,
1904
- validFrom,
1905
- validTo,
1906
- changeSummary,
1907
- supersedesPolicyId: id,
1908
- }, ctx.actor.subject);
1909
- sendJson(res, 200, { policy });
1910
- }
1911
- catch (e) {
1912
- const msg = e instanceof Error ? e.message : String(e);
1913
- if (msg.includes('not found')) {
1914
- throw new HttpError(404, msg);
1915
- }
1916
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
1917
- throw new HttpError(409, msg);
1918
- }
1919
- // invalid/inverted date or missing field -> validation.
1920
- throw new HttpError(400, msg);
1921
- }
1813
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1814
+ policyName: existing.policyName,
1815
+ policyText,
1816
+ validFrom,
1817
+ validTo,
1818
+ changeSummary,
1819
+ supersedesPolicyId: id,
1820
+ }, ctx.actor.subject);
1821
+ sendJson(res, 200, { policy });
1922
1822
  return;
1923
1823
  }
1924
1824
  async function handleClosePolicy({ req, res, opts }, policyCloseMatch) {
1925
1825
  const id = parseInt(policyCloseMatch[1], 10);
1926
1826
  const ctx = await buildContextWithAuth(req, opts);
1927
- try {
1928
- const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1929
- sendJson(res, 200, { policy });
1930
- }
1931
- catch (e) {
1932
- const msg = e instanceof Error ? e.message : String(e);
1933
- if (msg.includes('not found')) {
1934
- throw new HttpError(404, msg);
1935
- }
1936
- if (msg.includes('not active')) {
1937
- throw new HttpError(409, msg);
1938
- }
1939
- throw e;
1940
- }
1827
+ const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1828
+ sendJson(res, 200, { policy });
1941
1829
  return;
1942
1830
  }
1943
1831
  async function handleGetPolicy({ req, res, opts }, policyByIdMatch) {
@@ -1990,18 +1878,12 @@ async function handleCreateSkill({ req, res, opts }) {
1990
1878
  trigger = triggerRaw;
1991
1879
  }
1992
1880
  const ctx = await buildContextWithAuth(req, opts);
1993
- try {
1994
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1995
- skillName,
1996
- instructions,
1997
- trigger,
1998
- }, ctx.actor.subject);
1999
- sendJson(res, 201, { skill });
2000
- }
2001
- catch (e) {
2002
- // saveSkill throws on validation (single-line name etc.) -> 400.
2003
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2004
- }
1881
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1882
+ skillName,
1883
+ instructions,
1884
+ trigger,
1885
+ }, ctx.actor.subject);
1886
+ sendJson(res, 201, { skill });
2005
1887
  return;
2006
1888
  }
2007
1889
  async function handleListSkills({ req, res, opts, query }) {
@@ -2069,45 +1951,21 @@ async function handleSupersedeSkill({ req, res, opts }, skillSupersedeMatch) {
2069
1951
  if (!existing) {
2070
1952
  throw new HttpError(404, `skill ${id} not found`);
2071
1953
  }
2072
- try {
2073
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2074
- skillName: existing.skillName,
2075
- instructions,
2076
- trigger,
2077
- changeSummary,
2078
- supersedesSkillId: id,
2079
- }, ctx.actor.subject);
2080
- sendJson(res, 200, { skill });
2081
- }
2082
- catch (e) {
2083
- const msg = e instanceof Error ? e.message : String(e);
2084
- if (msg.includes('not found')) {
2085
- throw new HttpError(404, msg);
2086
- }
2087
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2088
- throw new HttpError(409, msg);
2089
- }
2090
- throw new HttpError(400, msg);
2091
- }
1954
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1955
+ skillName: existing.skillName,
1956
+ instructions,
1957
+ trigger,
1958
+ changeSummary,
1959
+ supersedesSkillId: id,
1960
+ }, ctx.actor.subject);
1961
+ sendJson(res, 200, { skill });
2092
1962
  return;
2093
1963
  }
2094
1964
  async function handleCloseSkill({ req, res, opts }, skillCloseMatch) {
2095
1965
  const id = parseInt(skillCloseMatch[1], 10);
2096
1966
  const ctx = await buildContextWithAuth(req, opts);
2097
- try {
2098
- const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2099
- sendJson(res, 200, { skill });
2100
- }
2101
- catch (e) {
2102
- const msg = e instanceof Error ? e.message : String(e);
2103
- if (msg.includes('not found')) {
2104
- throw new HttpError(404, msg);
2105
- }
2106
- if (msg.includes('not active')) {
2107
- throw new HttpError(409, msg);
2108
- }
2109
- throw e;
2110
- }
1967
+ const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1968
+ sendJson(res, 200, { skill });
2111
1969
  return;
2112
1970
  }
2113
1971
  async function handleGetSkill({ req, res, opts }, skillByIdMatch) {
@@ -2147,17 +2005,11 @@ async function handleCreateProjectBrief({ req, res, opts }) {
2147
2005
  throw new HttpError(400, 'summary exceeds 8192-character cap');
2148
2006
  }
2149
2007
  const ctx = await buildContextWithAuth(req, opts);
2150
- try {
2151
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2152
- repo,
2153
- summary,
2154
- }, ctx.actor.subject);
2155
- sendJson(res, 201, { brief });
2156
- }
2157
- catch (e) {
2158
- // saveProjectBrief throws on validation (single-line repo etc.) -> 400.
2159
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2160
- }
2008
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2009
+ repo,
2010
+ summary,
2011
+ }, ctx.actor.subject);
2012
+ sendJson(res, 201, { brief });
2161
2013
  return;
2162
2014
  }
2163
2015
  async function handleListProjectBriefs({ req, res, opts, query }) {
@@ -2192,29 +2044,13 @@ async function handleRefreshProjectBrief({ req, res, opts }) {
2192
2044
  }
2193
2045
  const dryRun = body['dryRun'] === true;
2194
2046
  const ctx = await buildContextWithAuth(req, opts);
2195
- try {
2196
- if (dryRun) {
2197
- const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2198
- sendJson(res, 200, { markdown, receiptCount });
2199
- return;
2200
- }
2201
- const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2202
- sendJson(res, 200, { brief });
2203
- }
2204
- catch (e) {
2205
- // A refresh race (the active brief is closed/superseded between
2206
- // loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
2207
- // validation error — map it to 409 like the explicit supersede route
2208
- // (codex-review 2026-05-30, P3).
2209
- const msg = e instanceof Error ? e.message : String(e);
2210
- if (msg.includes('not found')) {
2211
- throw new HttpError(404, msg);
2212
- }
2213
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2214
- throw new HttpError(409, msg);
2215
- }
2216
- throw new HttpError(400, msg);
2047
+ if (dryRun) {
2048
+ const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2049
+ sendJson(res, 200, { markdown, receiptCount });
2050
+ return;
2217
2051
  }
2052
+ const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2053
+ sendJson(res, 200, { brief });
2218
2054
  return;
2219
2055
  }
2220
2056
  async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMatch) {
@@ -2243,44 +2079,20 @@ async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMat
2243
2079
  if (!existing) {
2244
2080
  throw new HttpError(404, `project brief ${id} not found`);
2245
2081
  }
2246
- try {
2247
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2248
- repo: existing.repo,
2249
- summary,
2250
- changeSummary,
2251
- supersedesBriefId: id,
2252
- }, ctx.actor.subject);
2253
- sendJson(res, 200, { brief });
2254
- }
2255
- catch (e) {
2256
- const msg = e instanceof Error ? e.message : String(e);
2257
- if (msg.includes('not found')) {
2258
- throw new HttpError(404, msg);
2259
- }
2260
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2261
- throw new HttpError(409, msg);
2262
- }
2263
- throw new HttpError(400, msg);
2264
- }
2082
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2083
+ repo: existing.repo,
2084
+ summary,
2085
+ changeSummary,
2086
+ supersedesBriefId: id,
2087
+ }, ctx.actor.subject);
2088
+ sendJson(res, 200, { brief });
2265
2089
  return;
2266
2090
  }
2267
2091
  async function handleCloseProjectBrief({ req, res, opts }, briefCloseMatch) {
2268
2092
  const id = parseInt(briefCloseMatch[1], 10);
2269
2093
  const ctx = await buildContextWithAuth(req, opts);
2270
- try {
2271
- const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2272
- sendJson(res, 200, { brief });
2273
- }
2274
- catch (e) {
2275
- const msg = e instanceof Error ? e.message : String(e);
2276
- if (msg.includes('not found')) {
2277
- throw new HttpError(404, msg);
2278
- }
2279
- if (msg.includes('not active')) {
2280
- throw new HttpError(409, msg);
2281
- }
2282
- throw e;
2283
- }
2094
+ const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2095
+ sendJson(res, 200, { brief });
2284
2096
  return;
2285
2097
  }
2286
2098
  async function handleGetProjectBrief({ req, res, opts }, briefByIdMatch) {
@@ -2318,17 +2130,11 @@ async function handleCreateCustomerNote({ req, res, opts }) {
2318
2130
  throw new HttpError(400, 'note exceeds 8192-character cap');
2319
2131
  }
2320
2132
  const ctx = await buildContextWithAuth(req, opts);
2321
- try {
2322
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2323
- customer,
2324
- note,
2325
- }, ctx.actor.subject);
2326
- sendJson(res, 201, { note: customerNote });
2327
- }
2328
- catch (e) {
2329
- // saveCustomerNote throws on validation (single-line customer etc.) -> 400.
2330
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2331
- }
2133
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2134
+ customer,
2135
+ note,
2136
+ }, ctx.actor.subject);
2137
+ sendJson(res, 201, { note: customerNote });
2332
2138
  return;
2333
2139
  }
2334
2140
  async function handleListCustomerNotes({ req, res, opts, query }) {
@@ -2376,44 +2182,20 @@ async function handleSupersedeCustomerNote({ req, res, opts }, noteSupersedeMatc
2376
2182
  if (!existing) {
2377
2183
  throw new HttpError(404, `customer note ${id} not found`);
2378
2184
  }
2379
- try {
2380
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2381
- customer: existing.customer,
2382
- note,
2383
- changeSummary,
2384
- supersedesNoteId: id,
2385
- }, ctx.actor.subject);
2386
- sendJson(res, 200, { note: customerNote });
2387
- }
2388
- catch (e) {
2389
- const msg = e instanceof Error ? e.message : String(e);
2390
- if (msg.includes('not found')) {
2391
- throw new HttpError(404, msg);
2392
- }
2393
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2394
- throw new HttpError(409, msg);
2395
- }
2396
- throw new HttpError(400, msg);
2397
- }
2185
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2186
+ customer: existing.customer,
2187
+ note,
2188
+ changeSummary,
2189
+ supersedesNoteId: id,
2190
+ }, ctx.actor.subject);
2191
+ sendJson(res, 200, { note: customerNote });
2398
2192
  return;
2399
2193
  }
2400
2194
  async function handleCloseCustomerNote({ req, res, opts }, noteCloseMatch) {
2401
2195
  const id = parseInt(noteCloseMatch[1], 10);
2402
2196
  const ctx = await buildContextWithAuth(req, opts);
2403
- try {
2404
- const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2405
- sendJson(res, 200, { note: customerNote });
2406
- }
2407
- catch (e) {
2408
- const msg = e instanceof Error ? e.message : String(e);
2409
- if (msg.includes('not found')) {
2410
- throw new HttpError(404, msg);
2411
- }
2412
- if (msg.includes('not active')) {
2413
- throw new HttpError(409, msg);
2414
- }
2415
- throw e;
2416
- }
2197
+ const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2198
+ sendJson(res, 200, { note: customerNote });
2417
2199
  return;
2418
2200
  }
2419
2201
  async function handleGetCustomerNote({ req, res, opts }, noteByIdMatch) {
@@ -2536,6 +2318,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2536
2318
  version: VERSION,
2537
2319
  started_at: startedAt,
2538
2320
  pid: process.pid,
2321
+ audit_write_failures: auditWriteFailureCount(),
2539
2322
  });
2540
2323
  }
2541
2324
  else {
@@ -2738,7 +2521,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2738
2521
  * webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
2739
2522
  * secrets and 404 when those secrets are unset. But the loopback
2740
2523
  * no-auth fallback inside buildContextWithAuth still admits unauthenticated
2741
- * requests from a loopback remote address, so binding to a non-loopback host
2524
+ * requests from a loopback remote address (unless they carry Forwarded,
2525
+ * X-Forwarded-For/-Host/-Proto, X-Real-IP, Cf-Connecting-Ip or True-Client-Ip, which mark a same-host proxy and get
2526
+ * a 401 like any keyless remote request), so binding to a non-loopback host
2742
2527
  * is only safe once that fallback is disabled with HIPPO_REQUIRE_AUTH=1,
2743
2528
  * which forces every request (loopback or not) through Bearer-token
2744
2529
  * validation. Without that env var set, a non-loopback bind would expose the
@@ -2777,7 +2562,12 @@ export async function serve(opts) {
2777
2562
  ? createRateLimiter({ ratePerSec: v1Rps, burst: v1Rps * 2, idleEvictMs: 60000, maxKeys: 10000 })
2778
2563
  : undefined;
2779
2564
  const server = createServer((req, res) => {
2565
+ const requestId = resolveRequestId(req.headers['x-request-id']);
2566
+ requestIds.set(req, requestId);
2567
+ res.setHeader('X-Request-Id', requestId);
2780
2568
  handleRequest(req, res, opts, startedAt, limiter).catch((err) => {
2569
+ const mapped = mapApiError(err);
2570
+ logRequestFailure(req, err, requestId, mapped.status);
2781
2571
  if (res.headersSent) {
2782
2572
  try {
2783
2573
  res.end();
@@ -2785,33 +2575,20 @@ export async function serve(opts) {
2785
2575
  catch { /* socket already gone */ }
2786
2576
  return;
2787
2577
  }
2788
- if (err instanceof BodyTooLargeError) {
2789
- sendError(res, 413, err.message);
2790
- // M3: readBody hit the 1 MB cap mid-stream, so the request body is
2791
- // only partially consumed. Destroy the socket rather than let the
2792
- // client's remaining (unbounded) bytes drain into an exchange we have
2793
- // already answered.
2794
- req.destroy();
2795
- return;
2796
- }
2797
- if (err instanceof HttpError) {
2798
- sendError(res, err.status, err.message);
2578
+ if (mapped.status === 500) {
2579
+ // The id lets an operator find the logged cause without the client seeing internal text.
2580
+ sendJson(res, 500, { error: mapped.message, requestId });
2799
2581
  return;
2800
2582
  }
2801
- // F5 (v1.6.5) + v1.7.0 api-contract review: RecallContractError lands
2802
- // at 400 with {error: <message>, code: <code>}. The `error` field
2803
- // matches `sendError`'s shape (human message, used by HttpError /
2804
- // BodyTooLargeError / mapApiError). The `code` field is the typed
2805
- // discriminator — clients can branch on `body.code` without parsing
2806
- // prose. Earlier draft used {error: code, message: text} but that
2807
- // diverged from the rest of v1/* and forced clients to special-case
2808
- // the error path.
2583
+ // RecallContractError keeps the shared {error} shape and adds `code` so clients branch without parsing prose.
2809
2584
  if (err instanceof RecallContractError) {
2810
2585
  sendJson(res, 400, { error: err.message, code: err.code });
2811
2586
  return;
2812
2587
  }
2813
- const mapped = mapApiError(err);
2814
2588
  sendError(res, mapped.status, mapped.message);
2589
+ // M3: readBody hit the 1 MB cap mid-stream, so drop the socket rather than drain unbounded bytes.
2590
+ if (err instanceof BodyTooLargeError)
2591
+ req.destroy();
2815
2592
  });
2816
2593
  });
2817
2594
  // T3b capture (v1.26.2): tests/server-concurrency.test.ts's ECONNRESET flake