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/README.md +5 -5
- package/dist/api.d.ts +4 -2
- package/dist/api.js +14 -10
- package/dist/audit.d.ts +19 -0
- package/dist/audit.js +40 -6
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +3 -2
- package/dist/cli.js +4 -7
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +6 -7
- package/dist/server.d.ts +14 -0
- package/dist/server.js +227 -128
- package/dist/session-digest.js +1 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +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 +3 -2
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
|
|
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,
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1203
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3091
|
-
requireAuth(req, opts.hippoRoot);
|
|
3092
|
-
}
|
|
3093
|
-
catch {
|
|
3094
|
-
closeWith('auth_revoked');
|
|
3095
|
-
clearInterval(ping);
|
|
3180
|
+
if (checking)
|
|
3096
3181
|
return;
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
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
|
|
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
|
|
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).
|