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