hippo-memory 1.53.2 → 1.55.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/dist/server.js CHANGED
@@ -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, } from './recall-history.js';
11
- import { appendAuditEvent, AUDIT_OPS } from './audit.js';
11
+ import { appendAuditEvent, auditQueryFields, 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,7 +21,7 @@ export function __resetSessionRecallHistoryHttp() {
21
21
  sessionRecallHistoryHttp.clear();
22
22
  }
23
23
  import { PACKAGE_VERSION } from './version.js';
24
- import { validateApiKey } from './auth.js';
24
+ import { API_KEY_PREFIX, validateApiKey } from './auth.js';
25
25
  import { createRateLimiter } from './rate-limit.js';
26
26
  import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
27
27
  import { buildGraphModel } from './graph-view.js';
@@ -393,37 +393,119 @@ export function clientIpForRateLimit(req) {
393
393
  }
394
394
  return req.socket.remoteAddress ?? 'unknown';
395
395
  }
396
+ // Built-in actors are the bare names below or `<name>:<detail>`; a plain prefix would also reject `clinton@corp`.
397
+ const RESERVED_ACTOR_NAMES = [
398
+ 'api_key', 'localhost', 'cli', 'system', 'mcp', 'connector', 'sleep', 'post-compact', 'recall', 'agent-memories',
399
+ ];
400
+ function hasControlChar(s) {
401
+ for (let i = 0; i < s.length; i++) {
402
+ const c = s.charCodeAt(i);
403
+ if (c < 0x20 || c === 0x7f)
404
+ return true;
405
+ }
406
+ return false;
407
+ }
408
+ /** Core owns the checks so a resolver cannot mint an actor that collides with a built-in one. */
409
+ function sanitiseResolved(r) {
410
+ // Each field is read once: a getter on plugin code could answer differently the second time.
411
+ const { tenantId, subject, role, scopes } = r;
412
+ // A resolver is plugin code, so the runtime checks hold even though the static types say string.
413
+ if (!isJsonString(tenantId) || tenantId.trim().length === 0)
414
+ return null;
415
+ // Core reserves `__`-prefixed tenants (`__host__`, `__unroutable__`).
416
+ const tenant = tenantId.trim();
417
+ if (tenant.startsWith('__') || tenant.length > 256 || hasControlChar(tenant))
418
+ return null;
419
+ if (!isJsonString(subject) || subject.length < 1 || subject.length > 256)
420
+ return null;
421
+ // Padding would let "system " pass the reserved-name check yet read as `system` in an audit log.
422
+ if (hasControlChar(subject) || subject !== subject.trim())
423
+ return null;
424
+ const lower = subject.toLowerCase();
425
+ if (RESERVED_ACTOR_NAMES.some((n) => lower === n || lower.startsWith(`${n}:`)))
426
+ return null;
427
+ const clean = { tenantId: tenant, subject, role: role === 'admin' ? 'admin' : 'member' };
428
+ if (Array.isArray(scopes))
429
+ clean.scopes = scopes.filter((s) => isJsonString(s));
430
+ return clean;
431
+ }
432
+ function logResolverFailure(what, raw, token) {
433
+ // The plugin's message is logged, but never the token, even if the plugin echoed it.
434
+ const msg = raw.split(token).join('[token]').replace(/[\r\n]/g, ' ');
435
+ process.stderr.write(`[hippo] auth resolver ${what}: ${msg}\n`);
436
+ }
437
+ /** 503 when upstream throws or misses the deadline, so a stream heartbeat can tell an outage from a revocation. */
438
+ async function askResolver(resolver, token, deadlineMs) {
439
+ let timer;
440
+ const deadline = new Promise((_, reject) => {
441
+ timer = setTimeout(() => reject(new Error(`no answer within ${deadlineMs} ms`)), deadlineMs);
442
+ });
443
+ let resolved;
444
+ try {
445
+ resolved = await Promise.race([(async () => resolver(token))(), deadline]);
446
+ }
447
+ catch (err) {
448
+ logResolverFailure('threw', err instanceof Error ? err.message : 'unknown error', token);
449
+ throw new HttpError(503, 'auth provider unavailable');
450
+ }
451
+ finally {
452
+ clearTimeout(timer);
453
+ }
454
+ let clean = null;
455
+ try {
456
+ clean = resolved ? sanitiseResolved(resolved) : null;
457
+ }
458
+ catch (err) {
459
+ // A throwing getter is a resolver bug, not an outage, so it is a 401 like any malformed answer.
460
+ logResolverFailure('threw', err instanceof Error ? err.message : 'unknown error', token);
461
+ }
462
+ if (!clean)
463
+ throw new HttpError(401, 'invalid api key');
464
+ return clean;
465
+ }
466
+ const DEFAULT_RESOLVER_DEADLINE_MS = 5000;
467
+ /** Shared by buildContextWithAuth and requireAuth so the two cannot drift. */
468
+ async function resolveBearer(token, opts) {
469
+ // Routing by shape keeps key plaintext out of plugin code and stops a resolver overriding a key's identity.
470
+ if (opts.authResolver && !token.startsWith(API_KEY_PREFIX)) {
471
+ const t = opts.authResolverTimeoutMs;
472
+ const deadlineMs = t !== undefined && Number.isFinite(t) && t > 0 ? t : DEFAULT_RESOLVER_DEADLINE_MS;
473
+ return { ...(await askResolver(opts.authResolver, token, deadlineMs)), viaAuthResolver: true };
474
+ }
475
+ const db = openHippoDb(opts.hippoRoot);
476
+ try {
477
+ const result = validateApiKey(db, token);
478
+ if (!result.valid || !result.tenantId || !result.keyId || !result.role) {
479
+ throw new HttpError(401, 'invalid api key');
480
+ }
481
+ return {
482
+ tenantId: result.tenantId,
483
+ subject: `api_key:${result.keyId}`,
484
+ role: result.role,
485
+ scopes: result.scopes,
486
+ };
487
+ }
488
+ finally {
489
+ closeHippoDb(db);
490
+ }
491
+ }
396
492
  /**
397
493
  * Build a per-request Context from the Authorization header and remote
398
494
  * address. Throws HttpError(401) for invalid / missing credentials. Opens
399
- * the DB only when a Bearer token is present so loopback no-auth requests
400
- * stay cheap.
495
+ * the DB only for an API-key-shaped Bearer token (or any Bearer token when no
496
+ * auth resolver is registered), so loopback no-auth requests stay cheap.
401
497
  */
402
- function buildContextWithAuth(req, hippoRoot) {
498
+ async function buildContextWithAuth(req, opts) {
403
499
  const auth = readAuthHeader(req);
404
500
  if (auth.kind === 'malformed') {
405
501
  throw new HttpError(401, 'invalid api key');
406
502
  }
407
503
  if (auth.kind === 'bearer') {
408
- const db = openHippoDb(hippoRoot);
409
- try {
410
- const result = validateApiKey(db, auth.token);
411
- if (!result.valid || !result.tenantId || !result.keyId || !result.role) {
412
- throw new HttpError(401, 'invalid api key');
413
- }
414
- return {
415
- hippoRoot,
416
- tenantId: result.tenantId,
417
- actor: {
418
- subject: `api_key:${result.keyId}`,
419
- role: result.role,
420
- scopes: result.scopes,
421
- },
422
- };
423
- }
424
- finally {
425
- closeHippoDb(db);
426
- }
504
+ const id = await resolveBearer(auth.token, opts);
505
+ const actor = { subject: id.subject, role: id.role, scopes: id.scopes };
506
+ if (id.viaAuthResolver)
507
+ actor.viaAuthResolver = true;
508
+ return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor };
427
509
  }
428
510
  // No Authorization header. Loopback-only fallback, unless explicitly
429
511
  // disabled via HIPPO_REQUIRE_AUTH=1 (used by the bearer-lockdown test
@@ -434,7 +516,7 @@ function buildContextWithAuth(req, hippoRoot) {
434
516
  assertLocalCaller(req);
435
517
  // v1.12.0: loopback fallback is process-local, treat as admin.
436
518
  return {
437
- hippoRoot,
519
+ hippoRoot: opts.hippoRoot,
438
520
  tenantId: resolveTenantId({}),
439
521
  actor: { subject: 'localhost:cli', role: 'admin' },
440
522
  };
@@ -445,22 +527,13 @@ function buildContextWithAuth(req, hippoRoot) {
445
527
  * 401 the same way buildContextWithAuth does, but skips building the Context
446
528
  * envelope. Loopback no-auth still passes.
447
529
  */
448
- function requireAuth(req, hippoRoot) {
530
+ async function requireAuth(req, opts) {
449
531
  const auth = readAuthHeader(req);
450
532
  if (auth.kind === 'malformed') {
451
533
  throw new HttpError(401, 'invalid api key');
452
534
  }
453
535
  if (auth.kind === 'bearer') {
454
- const db = openHippoDb(hippoRoot);
455
- try {
456
- const result = validateApiKey(db, auth.token);
457
- if (!result.valid) {
458
- throw new HttpError(401, 'invalid api key');
459
- }
460
- }
461
- finally {
462
- closeHippoDb(db);
463
- }
536
+ await resolveBearer(auth.token, opts);
464
537
  return;
465
538
  }
466
539
  if (process.env.HIPPO_REQUIRE_AUTH === '1') {
@@ -468,6 +541,23 @@ function requireAuth(req, hippoRoot) {
468
541
  }
469
542
  assertLocalCaller(req);
470
543
  }
544
+ /** Never rejects: an outage (5xx) skips one heartbeat tick, only a definite 4xx denial closes the stream. */
545
+ async function heartbeatVerdict(req, opts) {
546
+ try {
547
+ await requireAuth(req, opts);
548
+ return 'ok';
549
+ }
550
+ catch (err) {
551
+ return err instanceof HttpError && err.status < 500 ? 'revoked' : 'unavailable';
552
+ }
553
+ }
554
+ /** Gate for any action beyond the caller's own tenant: a resolver admin is a customer's tenant admin, never a host admin. */
555
+ function assertCrossTenantAdmin(ctx, what) {
556
+ if (ctx.actor.role !== 'admin')
557
+ throw new HttpError(403, `${what} requires admin role`);
558
+ if (ctx.actor.viaAuthResolver)
559
+ throw new HttpError(403, `${what} requires an API-key admin`);
560
+ }
471
561
  function getString(obj, key) {
472
562
  const v = obj[key];
473
563
  return isJsonString(v) ? v : undefined;
@@ -542,8 +632,8 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
542
632
  }
543
633
  return;
544
634
  }
545
- // E3: per-IP rate limit on /v1/* to bound api-key-id enumeration. /health
546
- // (a liveness probe) and non-/v1 paths are never throttled. A 429 thrown
635
+ // E3: per-IP rate limit on /v1/* and /mcp* to bound api-key-id enumeration. /health
636
+ // (a liveness probe) and other paths are never throttled. A 429 thrown
547
637
  // here lands in the createServer catch like any other HttpError.
548
638
  //
549
639
  // Keyed on the socket's remote address by default. Behind a TLS-terminating
@@ -551,7 +641,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
551
641
  // buckets into one global bucket that pre-auth traffic can drain; set
552
642
  // HIPPO_CLIENT_IP_HEADER there so each real client gets its own bucket
553
643
  // (see clientIpForRateLimit).
554
- if (limiter && path.startsWith('/v1/')) {
644
+ if (limiter && (path.startsWith('/v1/') || path === '/mcp' || path === '/mcp/stream')) {
555
645
  const ip = clientIpForRateLimit(req);
556
646
  if (!limiter.check(ip)) {
557
647
  throw new HttpError(429, 'rate limit exceeded');
@@ -568,7 +658,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
568
658
  if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
569
659
  throw new HttpError(400, `invalid kind: ${kindRaw}`);
570
660
  }
571
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
661
+ const ctx = await buildContextWithAuth(req, opts);
572
662
  const result = remember(ctx, {
573
663
  content,
574
664
  kind: kindRaw,
@@ -589,7 +679,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
589
679
  throw new HttpError(400, `entity exceeds the ${MAX_ENTITY_NAME_LEN}-character cap`);
590
680
  }
591
681
  const limit = parseListLimit(query.get('limit'));
592
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
682
+ const ctx = await buildContextWithAuth(req, opts);
593
683
  const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
594
684
  entity: entityRaw ?? undefined,
595
685
  limit,
@@ -662,7 +752,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
662
752
  // serialized RecallResult. Mirrors the include_continuity convention.
663
753
  const explainRaw = query.get('explain');
664
754
  const explain = explainRaw === '1' || explainRaw === 'true';
665
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
755
+ const ctx = await buildContextWithAuth(req, opts);
666
756
  // v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
667
757
  // ring snapshot via opts.recallHistory so api.recall's own
668
758
  // anchoringHint compute path activates. Unlike CLI (which computes
@@ -701,10 +791,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
701
791
  actor: ctx.actor.subject,
702
792
  op: 'recall_anchor_skipped_no_session',
703
793
  targetId: undefined,
704
- metadata: {
705
- query_hash: createHash('sha256').update(q).digest('hex').slice(0, 16),
706
- query_length: q.length,
707
- },
794
+ metadata: auditQueryFields(q),
708
795
  });
709
796
  }
710
797
  finally {
@@ -786,7 +873,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
786
873
  : (sumOlderRaw === '1' || sumOlderRaw === 'true');
787
874
  const scopeQ = query.get('scope');
788
875
  const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
789
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
876
+ const ctx = await buildContextWithAuth(req, opts);
790
877
  const assembleExtra = {};
791
878
  if (budget !== undefined)
792
879
  assembleExtra.budget = budget;
@@ -831,7 +918,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
831
918
  }
832
919
  depth = parsed;
833
920
  }
834
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
921
+ const ctx = await buildContextWithAuth(req, opts);
835
922
  const drillExtra = {};
836
923
  if (limit !== undefined)
837
924
  drillExtra.limit = limit;
@@ -860,7 +947,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
860
947
  if (!reason) {
861
948
  throw new HttpError(400, 'reason is required');
862
949
  }
863
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
950
+ const ctx = await buildContextWithAuth(req, opts);
864
951
  const result = archiveRaw(ctx, archiveMatch.id, reason);
865
952
  sendJson(res, 200, result);
866
953
  return;
@@ -873,7 +960,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
873
960
  if (!content) {
874
961
  throw new HttpError(400, 'content is required');
875
962
  }
876
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
963
+ const ctx = await buildContextWithAuth(req, opts);
877
964
  const result = supersede(ctx, supersedeMatch.id, content);
878
965
  sendJson(res, 200, result);
879
966
  return;
@@ -881,7 +968,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
881
968
  const promoteMatch = matchPath('/v1/memories/:id/promote', path);
882
969
  if (method === 'POST' && promoteMatch) {
883
970
  validateIdSegment(promoteMatch.id, 'memory id');
884
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
971
+ const ctx = await buildContextWithAuth(req, opts);
885
972
  const result = promote(ctx, promoteMatch.id);
886
973
  sendJson(res, 200, result);
887
974
  return;
@@ -889,7 +976,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
889
976
  const idMatch = matchPath('/v1/memories/:id', path);
890
977
  if (method === 'DELETE' && idMatch) {
891
978
  validateIdSegment(idMatch.id, 'memory id');
892
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
979
+ const ctx = await buildContextWithAuth(req, opts);
893
980
  const result = forget(ctx, idMatch.id);
894
981
  sendJson(res, 200, result);
895
982
  return;
@@ -925,7 +1012,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
925
1012
  }
926
1013
  ids = idsRaw;
927
1014
  }
928
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1015
+ const ctx = await buildContextWithAuth(req, opts);
929
1016
  if (ids !== undefined) {
930
1017
  const { applied } = outcome(ctx, ids, good);
931
1018
  sendJson(res, 200, { applied });
@@ -987,7 +1074,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
987
1074
  // the project it serves.
988
1075
  const crossProjectRaw = query.get('cross_project');
989
1076
  const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
990
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1077
+ const ctx = await buildContextWithAuth(req, opts);
991
1078
  const result = await getContext(ctx, {
992
1079
  q,
993
1080
  budget,
@@ -1027,10 +1114,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1027
1114
  // any Bearer-authed caller now carries an explicit role from the api_keys
1028
1115
  // row). When non-loopback serving lands, this gate is the actual auth
1029
1116
  // boundary on host-wide sleep.
1030
- const sleepCtx = buildContextWithAuth(req, opts.hippoRoot);
1031
- if (sleepCtx.actor.role !== 'admin') {
1032
- throw new HttpError(403, '/v1/sleep requires admin role');
1033
- }
1117
+ const sleepCtx = await buildContextWithAuth(req, opts);
1118
+ // Sleep consolidates every tenant under hippoRoot, so it is a cross-tenant action.
1119
+ assertCrossTenantAdmin(sleepCtx, '/v1/sleep');
1034
1120
  const body = await parseJsonBody(req);
1035
1121
  const dryRunRaw = body['dry_run'];
1036
1122
  if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
@@ -1072,7 +1158,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1072
1158
  // bound to the caller's authenticated tenant (ctx.tenantId, resolved
1073
1159
  // from the Bearer token). Forwarding body.tenantId here would let
1074
1160
  // tenant A mint a key for tenant B — see authCreate doc comment.
1075
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1161
+ const ctx = await buildContextWithAuth(req, opts);
1076
1162
  const result = authCreate(ctx, {
1077
1163
  label: labelRaw,
1078
1164
  role,
@@ -1094,7 +1180,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1094
1180
  else
1095
1181
  throw new HttpError(400, "active must be 'true' or 'false'");
1096
1182
  }
1097
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1183
+ const ctx = await buildContextWithAuth(req, opts);
1098
1184
  const result = authList(ctx, { active });
1099
1185
  sendJson(res, 200, result);
1100
1186
  return;
@@ -1105,14 +1191,14 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1105
1191
  const keyMatch = matchPath('/v1/auth/keys/:keyId', path);
1106
1192
  if (method === 'DELETE' && keyMatch) {
1107
1193
  validateIdSegment(keyMatch.keyId, 'key id');
1108
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1194
+ const ctx = await buildContextWithAuth(req, opts);
1109
1195
  const result = authRevoke(ctx, keyMatch.keyId);
1110
1196
  sendJson(res, 200, result);
1111
1197
  return;
1112
1198
  }
1113
1199
  // GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
1114
1200
  if (method === 'GET' && path === '/v1/quarantine') {
1115
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1201
+ const ctx = await buildContextWithAuth(req, opts);
1116
1202
  if (ctx.actor.role !== 'admin') {
1117
1203
  throw new HttpError(403, '/v1/quarantine requires admin role');
1118
1204
  }
@@ -1131,7 +1217,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1131
1217
  const quarantineApproveMatch = matchPath('/v1/quarantine/:id/approve', path);
1132
1218
  if (method === 'POST' && quarantineApproveMatch) {
1133
1219
  validateIdSegment(quarantineApproveMatch.id, 'memory id');
1134
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1220
+ const ctx = await buildContextWithAuth(req, opts);
1135
1221
  try {
1136
1222
  quarantineApprove(ctx, quarantineApproveMatch.id);
1137
1223
  sendJson(res, 200, { approved: quarantineApproveMatch.id });
@@ -1150,7 +1236,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1150
1236
  const quarantineRejectMatch = matchPath('/v1/quarantine/:id/reject', path);
1151
1237
  if (method === 'POST' && quarantineRejectMatch) {
1152
1238
  validateIdSegment(quarantineRejectMatch.id, 'memory id');
1153
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1239
+ const ctx = await buildContextWithAuth(req, opts);
1154
1240
  try {
1155
1241
  quarantineReject(ctx, quarantineRejectMatch.id);
1156
1242
  sendJson(res, 200, { rejected: quarantineRejectMatch.id });
@@ -1195,13 +1281,12 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1195
1281
  }
1196
1282
  limit = parsed;
1197
1283
  }
1198
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1284
+ const ctx = await buildContextWithAuth(req, opts);
1199
1285
  // ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
1200
1286
  const tenantOverride = query.get('tenant');
1201
1287
  const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
1202
- if (crossTenant && ctx.actor.role !== 'admin') {
1203
- throw new HttpError(403, '/v1/audit?tenant= for another tenant requires admin role');
1204
- }
1288
+ if (crossTenant)
1289
+ assertCrossTenantAdmin(ctx, '/v1/audit?tenant= for another tenant');
1205
1290
  const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
1206
1291
  const result = auditList(effectiveCtx, { op, since, limit });
1207
1292
  sendJson(res, 200, result);
@@ -1252,7 +1337,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1252
1337
  }
1253
1338
  targetDateValue = targetDate;
1254
1339
  }
1255
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1340
+ const ctx = await buildContextWithAuth(req, opts);
1256
1341
  const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
1257
1342
  classTag,
1258
1343
  claimText: claim,
@@ -1267,7 +1352,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1267
1352
  const classTag = query.get('class') ?? undefined;
1268
1353
  const status = query.get('status') ?? 'all';
1269
1354
  const limit = parseListLimit(query.get('limit'));
1270
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1355
+ const ctx = await buildContextWithAuth(req, opts);
1271
1356
  let predictions;
1272
1357
  if (status === 'all') {
1273
1358
  if (classTag) {
@@ -1310,7 +1395,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1310
1395
  if (classTag.length > 256) {
1311
1396
  throw new HttpError(400, 'class exceeds 256-character cap');
1312
1397
  }
1313
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1398
+ const ctx = await buildContextWithAuth(req, opts);
1314
1399
  const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
1315
1400
  sendJson(res, 200, { baserate });
1316
1401
  return;
@@ -1318,7 +1403,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1318
1403
  const predictionByIdMatch = path.match(/^\/v1\/predictions\/(\d+)$/);
1319
1404
  if (method === 'GET' && predictionByIdMatch) {
1320
1405
  const id = parseInt(predictionByIdMatch[1], 10);
1321
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1406
+ const ctx = await buildContextWithAuth(req, opts);
1322
1407
  const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
1323
1408
  if (!prediction) {
1324
1409
  throw new HttpError(404, `prediction ${id} not found`);
@@ -1353,7 +1438,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1353
1438
  }
1354
1439
  closureNote = note;
1355
1440
  }
1356
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1441
+ const ctx = await buildContextWithAuth(req, opts);
1357
1442
  try {
1358
1443
  const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1359
1444
  closureState: state,
@@ -1409,7 +1494,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1409
1494
  }
1410
1495
  supersedesDecisionId = supRaw;
1411
1496
  }
1412
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1497
+ const ctx = await buildContextWithAuth(req, opts);
1413
1498
  try {
1414
1499
  const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1415
1500
  decisionText: text,
@@ -1430,7 +1515,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1430
1515
  if (method === 'GET' && path === '/v1/decisions') {
1431
1516
  const status = query.get('status') ?? 'all';
1432
1517
  const limit = parseListLimit(query.get('limit'));
1433
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1518
+ const ctx = await buildContextWithAuth(req, opts);
1434
1519
  let decisions;
1435
1520
  if (status === 'all') {
1436
1521
  decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
@@ -1469,7 +1554,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1469
1554
  }
1470
1555
  context = contextRaw;
1471
1556
  }
1472
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1557
+ const ctx = await buildContextWithAuth(req, opts);
1473
1558
  try {
1474
1559
  const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1475
1560
  decisionText: text,
@@ -1493,7 +1578,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1493
1578
  const decisionCloseMatch = path.match(/^\/v1\/decisions\/(\d+)\/close$/);
1494
1579
  if (method === 'POST' && decisionCloseMatch) {
1495
1580
  const id = parseInt(decisionCloseMatch[1], 10);
1496
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1581
+ const ctx = await buildContextWithAuth(req, opts);
1497
1582
  try {
1498
1583
  const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1499
1584
  sendJson(res, 200, { decision });
@@ -1513,7 +1598,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1513
1598
  const decisionByIdMatch = path.match(/^\/v1\/decisions\/(\d+)$/);
1514
1599
  if (method === 'GET' && decisionByIdMatch) {
1515
1600
  const id = parseInt(decisionByIdMatch[1], 10);
1516
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1601
+ const ctx = await buildContextWithAuth(req, opts);
1517
1602
  const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
1518
1603
  if (!decision) {
1519
1604
  throw new HttpError(404, `decision ${id} not found`);
@@ -1567,7 +1652,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1567
1652
  }
1568
1653
  linkedMemoryIds = linkedRaw;
1569
1654
  }
1570
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1655
+ const ctx = await buildContextWithAuth(req, opts);
1571
1656
  try {
1572
1657
  const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
1573
1658
  incidentText: text,
@@ -1588,7 +1673,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1588
1673
  if (method === 'GET' && path === '/v1/incidents') {
1589
1674
  const status = query.get('status') ?? 'all';
1590
1675
  const limit = parseListLimit(query.get('limit'));
1591
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1676
+ const ctx = await buildContextWithAuth(req, opts);
1592
1677
  let incidents;
1593
1678
  if (status === 'all') {
1594
1679
  incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
@@ -1616,7 +1701,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1616
1701
  if (resolutionText.length > 4096) {
1617
1702
  throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
1618
1703
  }
1619
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1704
+ const ctx = await buildContextWithAuth(req, opts);
1620
1705
  try {
1621
1706
  const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1622
1707
  sendJson(res, 200, { incident });
@@ -1636,7 +1721,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1636
1721
  const incidentCloseMatch = path.match(/^\/v1\/incidents\/(\d+)\/close$/);
1637
1722
  if (method === 'POST' && incidentCloseMatch) {
1638
1723
  const id = parseInt(incidentCloseMatch[1], 10);
1639
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1724
+ const ctx = await buildContextWithAuth(req, opts);
1640
1725
  try {
1641
1726
  const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1642
1727
  sendJson(res, 200, { incident });
@@ -1656,7 +1741,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1656
1741
  const incidentByIdMatch = path.match(/^\/v1\/incidents\/(\d+)$/);
1657
1742
  if (method === 'GET' && incidentByIdMatch) {
1658
1743
  const id = parseInt(incidentByIdMatch[1], 10);
1659
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1744
+ const ctx = await buildContextWithAuth(req, opts);
1660
1745
  const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
1661
1746
  if (!incident) {
1662
1747
  throw new HttpError(404, `incident ${id} not found`);
@@ -1696,7 +1781,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1696
1781
  }
1697
1782
  description = descriptionRaw;
1698
1783
  }
1699
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1784
+ const ctx = await buildContextWithAuth(req, opts);
1700
1785
  const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1701
1786
  processName,
1702
1787
  steps,
@@ -1708,7 +1793,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1708
1793
  if (method === 'GET' && path === '/v1/processes') {
1709
1794
  const status = query.get('status') ?? 'all';
1710
1795
  const limit = parseListLimit(query.get('limit'));
1711
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1796
+ const ctx = await buildContextWithAuth(req, opts);
1712
1797
  let processes;
1713
1798
  if (status === 'all') {
1714
1799
  processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
@@ -1755,7 +1840,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1755
1840
  }
1756
1841
  description = descRaw;
1757
1842
  }
1758
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1843
+ const ctx = await buildContextWithAuth(req, opts);
1759
1844
  // A supersession is a new version of the SAME process: reuse the
1760
1845
  // predecessor's name. 404 if the target does not exist; saveProcess's
1761
1846
  // in-SAVEPOINT preflight is the authoritative active-state check (409).
@@ -1788,7 +1873,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1788
1873
  const processCloseMatch = path.match(/^\/v1\/processes\/(\d+)\/close$/);
1789
1874
  if (method === 'POST' && processCloseMatch) {
1790
1875
  const id = parseInt(processCloseMatch[1], 10);
1791
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1876
+ const ctx = await buildContextWithAuth(req, opts);
1792
1877
  try {
1793
1878
  const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1794
1879
  sendJson(res, 200, { process });
@@ -1808,7 +1893,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1808
1893
  const processByIdMatch = path.match(/^\/v1\/processes\/(\d+)$/);
1809
1894
  if (method === 'GET' && processByIdMatch) {
1810
1895
  const id = parseInt(processByIdMatch[1], 10);
1811
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1896
+ const ctx = await buildContextWithAuth(req, opts);
1812
1897
  const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1813
1898
  if (!process) {
1814
1899
  throw new HttpError(404, `process ${id} not found`);
@@ -1843,7 +1928,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1843
1928
  }
1844
1929
  const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1845
1930
  const validTo = optionalDateField(body['validTo'], 'validTo');
1846
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1931
+ const ctx = await buildContextWithAuth(req, opts);
1847
1932
  try {
1848
1933
  const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1849
1934
  policyName,
@@ -1862,7 +1947,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1862
1947
  if (method === 'GET' && path === '/v1/policies') {
1863
1948
  const status = query.get('status') ?? 'all';
1864
1949
  const limit = parseListLimit(query.get('limit'));
1865
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1950
+ const ctx = await buildContextWithAuth(req, opts);
1866
1951
  let policies;
1867
1952
  if (status === 'all') {
1868
1953
  policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
@@ -1887,7 +1972,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1887
1972
  throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
1888
1973
  }
1889
1974
  const name = query.get('name') ?? undefined;
1890
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
1975
+ const ctx = await buildContextWithAuth(req, opts);
1891
1976
  try {
1892
1977
  const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1893
1978
  sendJson(res, 200, { policies });
@@ -1921,7 +2006,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1921
2006
  }
1922
2007
  changeSummary = changeRaw;
1923
2008
  }
1924
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2009
+ const ctx = await buildContextWithAuth(req, opts);
1925
2010
  const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1926
2011
  if (!existing) {
1927
2012
  throw new HttpError(404, `policy ${id} not found`);
@@ -1953,7 +2038,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1953
2038
  const policyCloseMatch = path.match(/^\/v1\/policies\/(\d+)\/close$/);
1954
2039
  if (method === 'POST' && policyCloseMatch) {
1955
2040
  const id = parseInt(policyCloseMatch[1], 10);
1956
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2041
+ const ctx = await buildContextWithAuth(req, opts);
1957
2042
  try {
1958
2043
  const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1959
2044
  sendJson(res, 200, { policy });
@@ -1973,7 +2058,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1973
2058
  const policyByIdMatch = path.match(/^\/v1\/policies\/(\d+)$/);
1974
2059
  if (method === 'GET' && policyByIdMatch) {
1975
2060
  const id = parseInt(policyByIdMatch[1], 10);
1976
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2061
+ const ctx = await buildContextWithAuth(req, opts);
1977
2062
  const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1978
2063
  if (!policy) {
1979
2064
  throw new HttpError(404, `policy ${id} not found`);
@@ -2020,7 +2105,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2020
2105
  }
2021
2106
  trigger = triggerRaw;
2022
2107
  }
2023
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2108
+ const ctx = await buildContextWithAuth(req, opts);
2024
2109
  try {
2025
2110
  const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2026
2111
  skillName,
@@ -2038,7 +2123,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2038
2123
  if (method === 'GET' && path === '/v1/skills') {
2039
2124
  const status = query.get('status') ?? 'all';
2040
2125
  const limit = parseListLimit(query.get('limit'));
2041
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2126
+ const ctx = await buildContextWithAuth(req, opts);
2042
2127
  let skills;
2043
2128
  if (status === 'all') {
2044
2129
  skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
@@ -2058,7 +2143,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2058
2143
  // The export renderer: must precede the /:id GET (literal 'export' is
2059
2144
  // non-numeric so the /(\d+)/ route would not match it, but order it first).
2060
2145
  if (method === 'GET' && path === '/v1/skills/export') {
2061
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2146
+ const ctx = await buildContextWithAuth(req, opts);
2062
2147
  const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
2063
2148
  sendJson(res, 200, { markdown });
2064
2149
  return;
@@ -2096,7 +2181,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2096
2181
  }
2097
2182
  changeSummary = changeRaw;
2098
2183
  }
2099
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2184
+ const ctx = await buildContextWithAuth(req, opts);
2100
2185
  const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2101
2186
  if (!existing) {
2102
2187
  throw new HttpError(404, `skill ${id} not found`);
@@ -2126,7 +2211,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2126
2211
  const skillCloseMatch = path.match(/^\/v1\/skills\/(\d+)\/close$/);
2127
2212
  if (method === 'POST' && skillCloseMatch) {
2128
2213
  const id = parseInt(skillCloseMatch[1], 10);
2129
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2214
+ const ctx = await buildContextWithAuth(req, opts);
2130
2215
  try {
2131
2216
  const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2132
2217
  sendJson(res, 200, { skill });
@@ -2146,7 +2231,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2146
2231
  const skillByIdMatch = path.match(/^\/v1\/skills\/(\d+)$/);
2147
2232
  if (method === 'GET' && skillByIdMatch) {
2148
2233
  const id = parseInt(skillByIdMatch[1], 10);
2149
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2234
+ const ctx = await buildContextWithAuth(req, opts);
2150
2235
  const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2151
2236
  if (!skill) {
2152
2237
  throw new HttpError(404, `skill ${id} not found`);
@@ -2180,7 +2265,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2180
2265
  if (summary.length > 8192) {
2181
2266
  throw new HttpError(400, 'summary exceeds 8192-character cap');
2182
2267
  }
2183
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2268
+ const ctx = await buildContextWithAuth(req, opts);
2184
2269
  try {
2185
2270
  const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2186
2271
  repo,
@@ -2198,7 +2283,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2198
2283
  const status = query.get('status') ?? 'all';
2199
2284
  const repoFilter = query.get('repo');
2200
2285
  const limit = parseListLimit(query.get('limit'));
2201
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2286
+ const ctx = await buildContextWithAuth(req, opts);
2202
2287
  const listOpts = { limit };
2203
2288
  if (repoFilter !== null && repoFilter.trim().length > 0) {
2204
2289
  listOpts.repo = repoFilter.trim();
@@ -2225,7 +2310,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2225
2310
  throw new HttpError(400, 'repo exceeds 256-character cap');
2226
2311
  }
2227
2312
  const dryRun = body['dryRun'] === true;
2228
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2313
+ const ctx = await buildContextWithAuth(req, opts);
2229
2314
  try {
2230
2315
  if (dryRun) {
2231
2316
  const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
@@ -2273,7 +2358,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2273
2358
  }
2274
2359
  changeSummary = changeRaw;
2275
2360
  }
2276
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2361
+ const ctx = await buildContextWithAuth(req, opts);
2277
2362
  const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2278
2363
  if (!existing) {
2279
2364
  throw new HttpError(404, `project brief ${id} not found`);
@@ -2302,7 +2387,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2302
2387
  const briefCloseMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/close$/);
2303
2388
  if (method === 'POST' && briefCloseMatch) {
2304
2389
  const id = parseInt(briefCloseMatch[1], 10);
2305
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2390
+ const ctx = await buildContextWithAuth(req, opts);
2306
2391
  try {
2307
2392
  const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2308
2393
  sendJson(res, 200, { brief });
@@ -2322,7 +2407,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2322
2407
  const briefByIdMatch = path.match(/^\/v1\/project-briefs\/(\d+)$/);
2323
2408
  if (method === 'GET' && briefByIdMatch) {
2324
2409
  const id = parseInt(briefByIdMatch[1], 10);
2325
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2410
+ const ctx = await buildContextWithAuth(req, opts);
2326
2411
  const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2327
2412
  if (!brief) {
2328
2413
  throw new HttpError(404, `project brief ${id} not found`);
@@ -2354,7 +2439,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2354
2439
  if (note.length > 8192) {
2355
2440
  throw new HttpError(400, 'note exceeds 8192-character cap');
2356
2441
  }
2357
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2442
+ const ctx = await buildContextWithAuth(req, opts);
2358
2443
  try {
2359
2444
  const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2360
2445
  customer,
@@ -2372,7 +2457,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2372
2457
  const status = query.get('status') ?? 'all';
2373
2458
  const customerFilter = query.get('customer');
2374
2459
  const limit = parseListLimit(query.get('limit'));
2375
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2460
+ const ctx = await buildContextWithAuth(req, opts);
2376
2461
  const listOpts = { limit };
2377
2462
  if (customerFilter !== null && customerFilter.trim().length > 0) {
2378
2463
  listOpts.customer = customerFilter.trim();
@@ -2409,7 +2494,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2409
2494
  }
2410
2495
  changeSummary = changeRaw;
2411
2496
  }
2412
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2497
+ const ctx = await buildContextWithAuth(req, opts);
2413
2498
  const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2414
2499
  if (!existing) {
2415
2500
  throw new HttpError(404, `customer note ${id} not found`);
@@ -2438,7 +2523,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2438
2523
  const noteCloseMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/close$/);
2439
2524
  if (method === 'POST' && noteCloseMatch) {
2440
2525
  const id = parseInt(noteCloseMatch[1], 10);
2441
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2526
+ const ctx = await buildContextWithAuth(req, opts);
2442
2527
  try {
2443
2528
  const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2444
2529
  sendJson(res, 200, { note: customerNote });
@@ -2458,7 +2543,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2458
2543
  const noteByIdMatch = path.match(/^\/v1\/customer-notes\/(\d+)$/);
2459
2544
  if (method === 'GET' && noteByIdMatch) {
2460
2545
  const id = parseInt(noteByIdMatch[1], 10);
2461
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
2546
+ const ctx = await buildContextWithAuth(req, opts);
2462
2547
  const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2463
2548
  if (!customerNote) {
2464
2549
  throw new HttpError(404, `customer note ${id} not found`);
@@ -2998,7 +3083,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2998
3083
  // Without this, executeTool would walk from cwd via findHippoRoot() and
2999
3084
  // pull tenant from HIPPO_TENANT, dropping a valid Bearer for tenant B
3000
3085
  // back to whatever the env says.
3001
- const ctx = buildContextWithAuth(req, opts.hippoRoot);
3086
+ const ctx = await buildContextWithAuth(req, opts);
3002
3087
  const raw = await readBody(req);
3003
3088
  let mcpReq;
3004
3089
  try {
@@ -3024,6 +3109,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
3024
3109
  // The caller's real role: MCP tools must not run a member key as admin.
3025
3110
  role: ctx.actor.role,
3026
3111
  scopes: ctx.actor.scopes,
3112
+ viaAuthResolver: ctx.actor.viaAuthResolver,
3027
3113
  clientKey: buildMcpClientKey(req),
3028
3114
  });
3029
3115
  }
@@ -3044,7 +3130,10 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
3044
3130
  return;
3045
3131
  }
3046
3132
  if (method === 'GET' && path === '/mcp/stream') {
3047
- requireAuth(req, opts.hippoRoot);
3133
+ await requireAuth(req, opts);
3134
+ // An async resolver can outlive the client; 'close' has already fired, so no timer may start.
3135
+ if (req.destroyed || res.destroyed || req.socket.destroyed)
3136
+ return;
3048
3137
  res.writeHead(200, {
3049
3138
  'content-type': 'text/event-stream',
3050
3139
  'cache-control': 'no-cache',
@@ -3064,6 +3153,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
3064
3153
  const maxAgeMs = (parseInt(process.env.MCP_SSE_MAX_AGE_SEC ?? '3600', 10) || 3600) * 1000;
3065
3154
  const startedAt = Date.now();
3066
3155
  let closed = false;
3156
+ let checking = false;
3067
3157
  const closeWith = (reason) => {
3068
3158
  if (closed)
3069
3159
  return;
@@ -3087,26 +3177,35 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
3087
3177
  clearInterval(ping);
3088
3178
  return;
3089
3179
  }
3090
- try {
3091
- requireAuth(req, opts.hippoRoot);
3092
- }
3093
- catch {
3094
- closeWith('auth_revoked');
3095
- clearInterval(ping);
3180
+ if (checking)
3096
3181
  return;
3097
- }
3098
- try {
3099
- res.write(': ping\n\n');
3100
- }
3101
- catch {
3102
- clearInterval(ping);
3103
- }
3182
+ checking = true;
3183
+ void heartbeatVerdict(req, opts).then((verdict) => {
3184
+ checking = false;
3185
+ if (closed || verdict === 'unavailable')
3186
+ return;
3187
+ if (verdict === 'revoked') {
3188
+ closeWith('auth_revoked');
3189
+ clearInterval(ping);
3190
+ return;
3191
+ }
3192
+ try {
3193
+ res.write(': ping\n\n');
3194
+ }
3195
+ catch {
3196
+ clearInterval(ping);
3197
+ }
3198
+ });
3104
3199
  }, heartbeatMs);
3105
3200
  // Don't keep the event loop alive just for this timer — the server's
3106
3201
  // listener already does that, and tests want the process to exit cleanly.
3107
3202
  if (ping.unref instanceof Function)
3108
3203
  ping.unref();
3109
- req.on('close', () => clearInterval(ping));
3204
+ // res 'close' covers an early socket drop that req 'close' can miss.
3205
+ res.on('close', () => {
3206
+ closed = true;
3207
+ clearInterval(ping);
3208
+ });
3110
3209
  return;
3111
3210
  }
3112
3211
  res.writeHead(404, JSON_HEADERS);
@@ -3152,7 +3251,7 @@ export async function serve(opts) {
3152
3251
  // GET /health response and (below) written into the pidfile, so detectServer
3153
3252
  // can match the two and prove a pid-reusing impostor is not the real server.
3154
3253
  const startedAt = new Date().toISOString();
3155
- // E3: per-IP rate limiter for /v1/*. Built here (not at module scope) so
3254
+ // E3: per-IP rate limiter for /v1/* and /mcp*. Built here (not at module scope) so
3156
3255
  // HIPPO_V1_RPS is read at boot, matching HIPPO_PORT above and letting a test
3157
3256
  // set the rate before serve(). A non-positive or non-finite value disables
3158
3257
  // limiting (the opt-out knob).