hippo-memory 1.57.0 → 1.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/agent-memories/legacy.js +4 -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 +5 -1
- package/dist/audit.js +13 -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/output.d.ts +3 -0
- package/dist/cli/output.js +7 -0
- package/dist/cli/projects.d.ts +4 -0
- package/dist/cli/projects.js +90 -0
- package/dist/cli/shared.js +23 -13
- package/dist/cli/sleep.js +5 -3
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +486 -397
- 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 +48 -35
- package/dist/customer-notes.js +14 -13
- package/dist/dag.js +7 -4
- 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/dedupe.js +1 -1
- package/dist/doctor.js +28 -0
- package/dist/dormant.d.ts +2 -2
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/extract.js +19 -18
- 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/project-merge.d.ts +52 -0
- package/dist/project-merge.js +168 -0
- package/dist/raw-archive.js +7 -6
- package/dist/recall-scope.d.ts +5 -4
- package/dist/recall-scope.js +7 -5
- 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/rerankers/clef.d.ts +29 -0
- package/dist/rerankers/clef.js +182 -0
- package/dist/rerankers/index.js +3 -0
- package/dist/rerankers/jev.d.ts +11 -0
- package/dist/rerankers/jev.js +10 -5
- package/dist/rerankers/types.d.ts +16 -0
- 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 +9 -2
- package/dist/server.js +188 -411
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +15 -14
- package/dist/store.js +10 -10
- 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 +5 -2
- 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,10 @@ import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomer
|
|
|
37
38
|
import { handleMcpRequest } from './mcp/server.js';
|
|
38
39
|
import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
|
|
39
40
|
import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
|
|
40
|
-
import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, readBody, sendJson, } from './http-util.js';
|
|
41
|
+
import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, mapApiError, readBody, sendJson, } from './http-util.js';
|
|
42
|
+
import { ForbiddenError, NotFoundError } from './api-errors.js';
|
|
43
|
+
// Add-on packages revoke keys through these without importing the whole api surface.
|
|
44
|
+
export { authRevoke, ForbiddenError };
|
|
41
45
|
// Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
|
|
42
46
|
// New unauth routes MUST be added here AND get a corresponding entry in
|
|
43
47
|
// tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
|
|
@@ -156,6 +160,22 @@ const VALID_KINDS = new Set([
|
|
|
156
160
|
// v1.3.1: source from src/version.ts so /health no longer reports stale 0.39.0.
|
|
157
161
|
const VERSION = PACKAGE_VERSION;
|
|
158
162
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
163
|
+
// The caller's id lands in a response header and in logs, so only a short plain token is echoed back.
|
|
164
|
+
const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
165
|
+
/** The caller's `X-Request-Id` when it is a short plain token, else a fresh UUID. */
|
|
166
|
+
function resolveRequestId(header) {
|
|
167
|
+
const value = Array.isArray(header) ? undefined : header;
|
|
168
|
+
return value && REQUEST_ID_RE.test(value) ? value : randomUUID();
|
|
169
|
+
}
|
|
170
|
+
/** One line per failed request; 4xx is the caller's mistake, so it stays below the default level. */
|
|
171
|
+
function logRequestFailure(req, err, requestId, status) {
|
|
172
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
173
|
+
const line = `${req.method ?? 'GET'} ${(req.url ?? '/').split('?')[0]} failed: ${message}`;
|
|
174
|
+
if (status >= 500)
|
|
175
|
+
log.error(line, { requestId, status });
|
|
176
|
+
else
|
|
177
|
+
log.info(line, { requestId, status });
|
|
178
|
+
}
|
|
159
179
|
function sendError(res, status, message) {
|
|
160
180
|
sendJson(res, status, { error: message });
|
|
161
181
|
}
|
|
@@ -176,35 +196,17 @@ async function parseJsonBody(req) {
|
|
|
176
196
|
throw new HttpError(400, 'invalid JSON body');
|
|
177
197
|
}
|
|
178
198
|
}
|
|
179
|
-
|
|
180
|
-
|
|
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 };
|
|
199
|
+
function parseRequest(req) {
|
|
200
|
+
let url;
|
|
201
|
+
try {
|
|
202
|
+
url = new URL(req.url ?? '/', 'http://placeholder');
|
|
200
203
|
}
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
catch (e) {
|
|
205
|
+
// A request target the URL parser rejects is the caller's fault, not a server failure.
|
|
206
|
+
if (e instanceof TypeError)
|
|
207
|
+
throw new HttpError(400, e.message);
|
|
208
|
+
throw e;
|
|
203
209
|
}
|
|
204
|
-
return { status: 400, message };
|
|
205
|
-
}
|
|
206
|
-
function parseRequest(req) {
|
|
207
|
-
const url = new URL(req.url ?? '/', 'http://placeholder');
|
|
208
210
|
return {
|
|
209
211
|
method: req.method ?? 'GET',
|
|
210
212
|
path: url.pathname,
|
|
@@ -219,6 +221,16 @@ function parseRequest(req) {
|
|
|
219
221
|
* mapping each :param name to its value. Path segments are exact-matched
|
|
220
222
|
* except for parameter slots.
|
|
221
223
|
*/
|
|
224
|
+
function decodePathSegment(segment) {
|
|
225
|
+
try {
|
|
226
|
+
return decodeURIComponent(segment);
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
if (e instanceof URIError)
|
|
230
|
+
throw new HttpError(400, e.message);
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
222
234
|
function matchPath(pattern, path) {
|
|
223
235
|
const patternParts = pattern.split('/');
|
|
224
236
|
const pathParts = path.split('/');
|
|
@@ -231,7 +243,7 @@ function matchPath(pattern, path) {
|
|
|
231
243
|
if (pp.startsWith(':')) {
|
|
232
244
|
if (ap.length === 0)
|
|
233
245
|
return null;
|
|
234
|
-
params[pp.slice(1)] =
|
|
246
|
+
params[pp.slice(1)] = decodePathSegment(ap);
|
|
235
247
|
}
|
|
236
248
|
else if (pp !== ap) {
|
|
237
249
|
return null;
|
|
@@ -265,10 +277,22 @@ export function isCrossSite(req) {
|
|
|
265
277
|
const origin = req.headers.origin;
|
|
266
278
|
return origin !== undefined && origin !== `http://${req.headers.host}`;
|
|
267
279
|
}
|
|
280
|
+
// A proxy on this host (nginx, Caddy, cloudflared) connects from loopback, so these headers mean the caller is not local.
|
|
281
|
+
const PROXY_HEADERS = [
|
|
282
|
+
'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip',
|
|
283
|
+
];
|
|
284
|
+
// The auth helpers only see the request, so its id rides here for their log lines.
|
|
285
|
+
const requestIds = new WeakMap();
|
|
268
286
|
// A browser on this machine is loopback too, so the no-key fallback also needs a local Host and a same-site caller.
|
|
269
287
|
function assertLocalCaller(req) {
|
|
270
288
|
if (!isLoopback(req.socket.remoteAddress))
|
|
271
289
|
throw new HttpError(401, 'auth required');
|
|
290
|
+
const proxyHeader = PROXY_HEADERS.find((name) => req.headers[name] !== undefined);
|
|
291
|
+
if (proxyHeader !== undefined) {
|
|
292
|
+
log.warn(`proxied loopback request refused: it carries ${proxyHeader}, so the no-key local fallback does not apply. ` +
|
|
293
|
+
'Send an API key (hippo auth create, then Authorization: Bearer hk_...).', { requestId: requestIds.get(req) });
|
|
294
|
+
throw new HttpError(401, 'auth required');
|
|
295
|
+
}
|
|
272
296
|
const host = req.headers.host;
|
|
273
297
|
if ((host !== undefined && !LOOPBACK_HOST_HEADER.test(host)) || isCrossSite(req)) {
|
|
274
298
|
throw new HttpError(403, 'cross-site or non-local request refused; send an API key');
|
|
@@ -342,6 +366,11 @@ export function clientIpForRateLimit(req) {
|
|
|
342
366
|
const RESERVED_ACTOR_NAMES = [
|
|
343
367
|
'api_key', 'localhost', 'cli', 'system', 'mcp', 'connector', 'sleep', 'post-compact', 'recall', 'agent-memories',
|
|
344
368
|
];
|
|
369
|
+
/** Add-ons call this to refuse a subject that would collide with a built-in actor. */
|
|
370
|
+
export function isReservedActor(subject) {
|
|
371
|
+
const lower = subject.toLowerCase();
|
|
372
|
+
return RESERVED_ACTOR_NAMES.some((n) => lower === n || lower.startsWith(`${n}:`));
|
|
373
|
+
}
|
|
345
374
|
function hasControlChar(s) {
|
|
346
375
|
for (let i = 0; i < s.length; i++) {
|
|
347
376
|
const c = s.charCodeAt(i);
|
|
@@ -366,8 +395,7 @@ function sanitiseResolved(r) {
|
|
|
366
395
|
// Padding would let "system " pass the reserved-name check yet read as `system` in an audit log.
|
|
367
396
|
if (hasControlChar(subject) || subject !== subject.trim())
|
|
368
397
|
return null;
|
|
369
|
-
|
|
370
|
-
if (RESERVED_ACTOR_NAMES.some((n) => lower === n || lower.startsWith(`${n}:`)))
|
|
398
|
+
if (isReservedActor(subject))
|
|
371
399
|
return null;
|
|
372
400
|
const clean = { tenantId: tenant, subject, role: role === 'admin' ? 'admin' : 'member' };
|
|
373
401
|
if (Array.isArray(scopes))
|
|
@@ -452,9 +480,8 @@ async function buildContextWithAuth(req, opts) {
|
|
|
452
480
|
actor.viaAuthResolver = true;
|
|
453
481
|
return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor };
|
|
454
482
|
}
|
|
455
|
-
// No Authorization header. Loopback-only fallback
|
|
456
|
-
//
|
|
457
|
-
// and by deployments that want to forbid the local-CLI escape hatch).
|
|
483
|
+
// No Authorization header. Loopback-only fallback for a direct local caller (no proxy headers),
|
|
484
|
+
// unless HIPPO_REQUIRE_AUTH=1 forbids the local-CLI escape hatch.
|
|
458
485
|
if (process.env.HIPPO_REQUIRE_AUTH === '1') {
|
|
459
486
|
throw new HttpError(401, 'auth required');
|
|
460
487
|
}
|
|
@@ -1117,36 +1144,16 @@ async function handleListQuarantine({ req, res, opts, query }) {
|
|
|
1117
1144
|
async function handleApproveQuarantine({ req, res, opts }, quarantineApproveMatch) {
|
|
1118
1145
|
validateIdSegment(quarantineApproveMatch.id, 'memory id');
|
|
1119
1146
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1120
|
-
|
|
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
|
-
}
|
|
1147
|
+
quarantineApprove(ctx, quarantineApproveMatch.id);
|
|
1148
|
+
sendJson(res, 200, { approved: quarantineApproveMatch.id });
|
|
1132
1149
|
return;
|
|
1133
1150
|
}
|
|
1134
1151
|
// POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
|
|
1135
1152
|
async function handleRejectQuarantine({ req, res, opts }, quarantineRejectMatch) {
|
|
1136
1153
|
validateIdSegment(quarantineRejectMatch.id, 'memory id');
|
|
1137
1154
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1138
|
-
|
|
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
|
-
}
|
|
1155
|
+
quarantineReject(ctx, quarantineRejectMatch.id);
|
|
1156
|
+
sendJson(res, 200, { rejected: quarantineRejectMatch.id });
|
|
1150
1157
|
return;
|
|
1151
1158
|
}
|
|
1152
1159
|
// GET /v1/audit?op=&since=&limit= — read audit events. All three filters
|
|
@@ -1335,21 +1342,12 @@ async function handleClosePrediction({ req, res, opts }, predictionCloseMatch) {
|
|
|
1335
1342
|
closureNote = note;
|
|
1336
1343
|
}
|
|
1337
1344
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1338
|
-
|
|
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
|
-
}
|
|
1345
|
+
const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
|
|
1346
|
+
closureState: state,
|
|
1347
|
+
actualValue,
|
|
1348
|
+
closureNote,
|
|
1349
|
+
}, ctx.actor.subject);
|
|
1350
|
+
sendJson(res, 200, { prediction });
|
|
1353
1351
|
return;
|
|
1354
1352
|
}
|
|
1355
1353
|
// ── decisions (E2 first-class object) ──
|
|
@@ -1400,10 +1398,9 @@ async function handleCreateDecision({ req, res, opts }) {
|
|
|
1400
1398
|
sendJson(res, 201, { decision });
|
|
1401
1399
|
}
|
|
1402
1400
|
catch (e) {
|
|
1403
|
-
|
|
1404
|
-
if (
|
|
1405
|
-
throw new HttpError(409,
|
|
1406
|
-
}
|
|
1401
|
+
// A missing referenced row is a conflict with the create, not a missing target.
|
|
1402
|
+
if (e instanceof NotFoundError)
|
|
1403
|
+
throw new HttpError(409, e.message);
|
|
1407
1404
|
throw e;
|
|
1408
1405
|
}
|
|
1409
1406
|
return;
|
|
@@ -1450,43 +1447,19 @@ async function handleSupersedeDecision({ req, res, opts }, decisionSupersedeMatc
|
|
|
1450
1447
|
context = contextRaw;
|
|
1451
1448
|
}
|
|
1452
1449
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1453
|
-
|
|
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
|
-
}
|
|
1450
|
+
const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
|
|
1451
|
+
decisionText: text,
|
|
1452
|
+
context,
|
|
1453
|
+
supersedesDecisionId: oldId,
|
|
1454
|
+
}, ctx.actor.subject);
|
|
1455
|
+
sendJson(res, 201, { decision });
|
|
1471
1456
|
return;
|
|
1472
1457
|
}
|
|
1473
1458
|
async function handleCloseDecision({ req, res, opts }, decisionCloseMatch) {
|
|
1474
1459
|
const id = parseInt(decisionCloseMatch[1], 10);
|
|
1475
1460
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1476
|
-
|
|
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
|
-
}
|
|
1461
|
+
const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1462
|
+
sendJson(res, 200, { decision });
|
|
1490
1463
|
return;
|
|
1491
1464
|
}
|
|
1492
1465
|
async function handleGetDecision({ req, res, opts }, decisionByIdMatch) {
|
|
@@ -1555,10 +1528,9 @@ async function handleCreateIncident({ req, res, opts }) {
|
|
|
1555
1528
|
sendJson(res, 201, { incident });
|
|
1556
1529
|
}
|
|
1557
1530
|
catch (e) {
|
|
1558
|
-
|
|
1559
|
-
if (
|
|
1560
|
-
throw new HttpError(409,
|
|
1561
|
-
}
|
|
1531
|
+
// A missing referenced row is a conflict with the create, not a missing target.
|
|
1532
|
+
if (e instanceof NotFoundError)
|
|
1533
|
+
throw new HttpError(409, e.message);
|
|
1562
1534
|
throw e;
|
|
1563
1535
|
}
|
|
1564
1536
|
return;
|
|
@@ -1594,39 +1566,15 @@ async function handleResolveIncident({ req, res, opts }, incidentResolveMatch) {
|
|
|
1594
1566
|
throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
|
|
1595
1567
|
}
|
|
1596
1568
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1597
|
-
|
|
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
|
-
}
|
|
1569
|
+
const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
|
|
1570
|
+
sendJson(res, 200, { incident });
|
|
1611
1571
|
return;
|
|
1612
1572
|
}
|
|
1613
1573
|
async function handleCloseIncident({ req, res, opts }, incidentCloseMatch) {
|
|
1614
1574
|
const id = parseInt(incidentCloseMatch[1], 10);
|
|
1615
1575
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1616
|
-
|
|
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
|
-
}
|
|
1576
|
+
const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1577
|
+
sendJson(res, 200, { incident });
|
|
1630
1578
|
return;
|
|
1631
1579
|
}
|
|
1632
1580
|
async function handleGetIncident({ req, res, opts }, incidentByIdMatch) {
|
|
@@ -1737,45 +1685,21 @@ async function handleSupersedeProcess({ req, res, opts }, processSupersedeMatch)
|
|
|
1737
1685
|
if (!existing) {
|
|
1738
1686
|
throw new HttpError(404, `process ${id} not found`);
|
|
1739
1687
|
}
|
|
1740
|
-
|
|
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
|
-
}
|
|
1688
|
+
const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
|
|
1689
|
+
processName: existing.processName,
|
|
1690
|
+
steps,
|
|
1691
|
+
description,
|
|
1692
|
+
changeSummary,
|
|
1693
|
+
supersedesProcessId: id,
|
|
1694
|
+
}, ctx.actor.subject);
|
|
1695
|
+
sendJson(res, 200, { process });
|
|
1760
1696
|
return;
|
|
1761
1697
|
}
|
|
1762
1698
|
async function handleCloseProcess({ req, res, opts }, processCloseMatch) {
|
|
1763
1699
|
const id = parseInt(processCloseMatch[1], 10);
|
|
1764
1700
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1765
|
-
|
|
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
|
-
}
|
|
1701
|
+
const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1702
|
+
sendJson(res, 200, { process });
|
|
1779
1703
|
return;
|
|
1780
1704
|
}
|
|
1781
1705
|
async function handleGetProcess({ req, res, opts }, processByIdMatch) {
|
|
@@ -1816,19 +1740,13 @@ async function handleCreatePolicy({ req, res, opts }) {
|
|
|
1816
1740
|
const validFrom = optionalDateField(body['validFrom'], 'validFrom');
|
|
1817
1741
|
const validTo = optionalDateField(body['validTo'], 'validTo');
|
|
1818
1742
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1819
|
-
|
|
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
|
-
}
|
|
1743
|
+
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
1744
|
+
policyName,
|
|
1745
|
+
policyText,
|
|
1746
|
+
validFrom,
|
|
1747
|
+
validTo,
|
|
1748
|
+
}, ctx.actor.subject);
|
|
1749
|
+
sendJson(res, 201, { policy });
|
|
1832
1750
|
return;
|
|
1833
1751
|
}
|
|
1834
1752
|
async function handleListPolicies({ req, res, opts, query }) {
|
|
@@ -1860,13 +1778,8 @@ async function handlePoliciesAsOf({ req, res, opts, query }) {
|
|
|
1860
1778
|
}
|
|
1861
1779
|
const name = query.get('name') ?? undefined;
|
|
1862
1780
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
sendJson(res, 200, { policies });
|
|
1866
|
-
}
|
|
1867
|
-
catch (e) {
|
|
1868
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
1869
|
-
}
|
|
1781
|
+
const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
|
|
1782
|
+
sendJson(res, 200, { policies });
|
|
1870
1783
|
return;
|
|
1871
1784
|
}
|
|
1872
1785
|
async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
|
|
@@ -1897,47 +1810,22 @@ async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
|
|
|
1897
1810
|
if (!existing) {
|
|
1898
1811
|
throw new HttpError(404, `policy ${id} not found`);
|
|
1899
1812
|
}
|
|
1900
|
-
|
|
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
|
-
}
|
|
1813
|
+
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
1814
|
+
policyName: existing.policyName,
|
|
1815
|
+
policyText,
|
|
1816
|
+
validFrom,
|
|
1817
|
+
validTo,
|
|
1818
|
+
changeSummary,
|
|
1819
|
+
supersedesPolicyId: id,
|
|
1820
|
+
}, ctx.actor.subject);
|
|
1821
|
+
sendJson(res, 200, { policy });
|
|
1922
1822
|
return;
|
|
1923
1823
|
}
|
|
1924
1824
|
async function handleClosePolicy({ req, res, opts }, policyCloseMatch) {
|
|
1925
1825
|
const id = parseInt(policyCloseMatch[1], 10);
|
|
1926
1826
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1927
|
-
|
|
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
|
-
}
|
|
1827
|
+
const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1828
|
+
sendJson(res, 200, { policy });
|
|
1941
1829
|
return;
|
|
1942
1830
|
}
|
|
1943
1831
|
async function handleGetPolicy({ req, res, opts }, policyByIdMatch) {
|
|
@@ -1990,18 +1878,12 @@ async function handleCreateSkill({ req, res, opts }) {
|
|
|
1990
1878
|
trigger = triggerRaw;
|
|
1991
1879
|
}
|
|
1992
1880
|
const ctx = await buildContextWithAuth(req, opts);
|
|
1993
|
-
|
|
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
|
-
}
|
|
1881
|
+
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
1882
|
+
skillName,
|
|
1883
|
+
instructions,
|
|
1884
|
+
trigger,
|
|
1885
|
+
}, ctx.actor.subject);
|
|
1886
|
+
sendJson(res, 201, { skill });
|
|
2005
1887
|
return;
|
|
2006
1888
|
}
|
|
2007
1889
|
async function handleListSkills({ req, res, opts, query }) {
|
|
@@ -2069,45 +1951,21 @@ async function handleSupersedeSkill({ req, res, opts }, skillSupersedeMatch) {
|
|
|
2069
1951
|
if (!existing) {
|
|
2070
1952
|
throw new HttpError(404, `skill ${id} not found`);
|
|
2071
1953
|
}
|
|
2072
|
-
|
|
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
|
-
}
|
|
1954
|
+
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
1955
|
+
skillName: existing.skillName,
|
|
1956
|
+
instructions,
|
|
1957
|
+
trigger,
|
|
1958
|
+
changeSummary,
|
|
1959
|
+
supersedesSkillId: id,
|
|
1960
|
+
}, ctx.actor.subject);
|
|
1961
|
+
sendJson(res, 200, { skill });
|
|
2092
1962
|
return;
|
|
2093
1963
|
}
|
|
2094
1964
|
async function handleCloseSkill({ req, res, opts }, skillCloseMatch) {
|
|
2095
1965
|
const id = parseInt(skillCloseMatch[1], 10);
|
|
2096
1966
|
const ctx = await buildContextWithAuth(req, opts);
|
|
2097
|
-
|
|
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
|
-
}
|
|
1967
|
+
const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1968
|
+
sendJson(res, 200, { skill });
|
|
2111
1969
|
return;
|
|
2112
1970
|
}
|
|
2113
1971
|
async function handleGetSkill({ req, res, opts }, skillByIdMatch) {
|
|
@@ -2147,17 +2005,11 @@ async function handleCreateProjectBrief({ req, res, opts }) {
|
|
|
2147
2005
|
throw new HttpError(400, 'summary exceeds 8192-character cap');
|
|
2148
2006
|
}
|
|
2149
2007
|
const ctx = await buildContextWithAuth(req, opts);
|
|
2150
|
-
|
|
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
|
-
}
|
|
2008
|
+
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2009
|
+
repo,
|
|
2010
|
+
summary,
|
|
2011
|
+
}, ctx.actor.subject);
|
|
2012
|
+
sendJson(res, 201, { brief });
|
|
2161
2013
|
return;
|
|
2162
2014
|
}
|
|
2163
2015
|
async function handleListProjectBriefs({ req, res, opts, query }) {
|
|
@@ -2192,29 +2044,13 @@ async function handleRefreshProjectBrief({ req, res, opts }) {
|
|
|
2192
2044
|
}
|
|
2193
2045
|
const dryRun = body['dryRun'] === true;
|
|
2194
2046
|
const ctx = await buildContextWithAuth(req, opts);
|
|
2195
|
-
|
|
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);
|
|
2047
|
+
if (dryRun) {
|
|
2048
|
+
const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
|
|
2049
|
+
sendJson(res, 200, { markdown, receiptCount });
|
|
2050
|
+
return;
|
|
2217
2051
|
}
|
|
2052
|
+
const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
|
|
2053
|
+
sendJson(res, 200, { brief });
|
|
2218
2054
|
return;
|
|
2219
2055
|
}
|
|
2220
2056
|
async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMatch) {
|
|
@@ -2243,44 +2079,20 @@ async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMat
|
|
|
2243
2079
|
if (!existing) {
|
|
2244
2080
|
throw new HttpError(404, `project brief ${id} not found`);
|
|
2245
2081
|
}
|
|
2246
|
-
|
|
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
|
-
}
|
|
2082
|
+
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2083
|
+
repo: existing.repo,
|
|
2084
|
+
summary,
|
|
2085
|
+
changeSummary,
|
|
2086
|
+
supersedesBriefId: id,
|
|
2087
|
+
}, ctx.actor.subject);
|
|
2088
|
+
sendJson(res, 200, { brief });
|
|
2265
2089
|
return;
|
|
2266
2090
|
}
|
|
2267
2091
|
async function handleCloseProjectBrief({ req, res, opts }, briefCloseMatch) {
|
|
2268
2092
|
const id = parseInt(briefCloseMatch[1], 10);
|
|
2269
2093
|
const ctx = await buildContextWithAuth(req, opts);
|
|
2270
|
-
|
|
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
|
-
}
|
|
2094
|
+
const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2095
|
+
sendJson(res, 200, { brief });
|
|
2284
2096
|
return;
|
|
2285
2097
|
}
|
|
2286
2098
|
async function handleGetProjectBrief({ req, res, opts }, briefByIdMatch) {
|
|
@@ -2318,17 +2130,11 @@ async function handleCreateCustomerNote({ req, res, opts }) {
|
|
|
2318
2130
|
throw new HttpError(400, 'note exceeds 8192-character cap');
|
|
2319
2131
|
}
|
|
2320
2132
|
const ctx = await buildContextWithAuth(req, opts);
|
|
2321
|
-
|
|
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
|
-
}
|
|
2133
|
+
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2134
|
+
customer,
|
|
2135
|
+
note,
|
|
2136
|
+
}, ctx.actor.subject);
|
|
2137
|
+
sendJson(res, 201, { note: customerNote });
|
|
2332
2138
|
return;
|
|
2333
2139
|
}
|
|
2334
2140
|
async function handleListCustomerNotes({ req, res, opts, query }) {
|
|
@@ -2376,44 +2182,20 @@ async function handleSupersedeCustomerNote({ req, res, opts }, noteSupersedeMatc
|
|
|
2376
2182
|
if (!existing) {
|
|
2377
2183
|
throw new HttpError(404, `customer note ${id} not found`);
|
|
2378
2184
|
}
|
|
2379
|
-
|
|
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
|
-
}
|
|
2185
|
+
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2186
|
+
customer: existing.customer,
|
|
2187
|
+
note,
|
|
2188
|
+
changeSummary,
|
|
2189
|
+
supersedesNoteId: id,
|
|
2190
|
+
}, ctx.actor.subject);
|
|
2191
|
+
sendJson(res, 200, { note: customerNote });
|
|
2398
2192
|
return;
|
|
2399
2193
|
}
|
|
2400
2194
|
async function handleCloseCustomerNote({ req, res, opts }, noteCloseMatch) {
|
|
2401
2195
|
const id = parseInt(noteCloseMatch[1], 10);
|
|
2402
2196
|
const ctx = await buildContextWithAuth(req, opts);
|
|
2403
|
-
|
|
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
|
-
}
|
|
2197
|
+
const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2198
|
+
sendJson(res, 200, { note: customerNote });
|
|
2417
2199
|
return;
|
|
2418
2200
|
}
|
|
2419
2201
|
async function handleGetCustomerNote({ req, res, opts }, noteByIdMatch) {
|
|
@@ -2536,6 +2318,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
2536
2318
|
version: VERSION,
|
|
2537
2319
|
started_at: startedAt,
|
|
2538
2320
|
pid: process.pid,
|
|
2321
|
+
audit_write_failures: auditWriteFailureCount(),
|
|
2539
2322
|
});
|
|
2540
2323
|
}
|
|
2541
2324
|
else {
|
|
@@ -2738,7 +2521,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
2738
2521
|
* webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
|
|
2739
2522
|
* secrets and 404 when those secrets are unset. But the loopback
|
|
2740
2523
|
* no-auth fallback inside buildContextWithAuth still admits unauthenticated
|
|
2741
|
-
* requests from a loopback remote address
|
|
2524
|
+
* requests from a loopback remote address (unless they carry Forwarded,
|
|
2525
|
+
* X-Forwarded-For/-Host/-Proto, X-Real-IP, Cf-Connecting-Ip or True-Client-Ip, which mark a same-host proxy and get
|
|
2526
|
+
* a 401 like any keyless remote request), so binding to a non-loopback host
|
|
2742
2527
|
* is only safe once that fallback is disabled with HIPPO_REQUIRE_AUTH=1,
|
|
2743
2528
|
* which forces every request (loopback or not) through Bearer-token
|
|
2744
2529
|
* validation. Without that env var set, a non-loopback bind would expose the
|
|
@@ -2777,7 +2562,12 @@ export async function serve(opts) {
|
|
|
2777
2562
|
? createRateLimiter({ ratePerSec: v1Rps, burst: v1Rps * 2, idleEvictMs: 60000, maxKeys: 10000 })
|
|
2778
2563
|
: undefined;
|
|
2779
2564
|
const server = createServer((req, res) => {
|
|
2565
|
+
const requestId = resolveRequestId(req.headers['x-request-id']);
|
|
2566
|
+
requestIds.set(req, requestId);
|
|
2567
|
+
res.setHeader('X-Request-Id', requestId);
|
|
2780
2568
|
handleRequest(req, res, opts, startedAt, limiter).catch((err) => {
|
|
2569
|
+
const mapped = mapApiError(err);
|
|
2570
|
+
logRequestFailure(req, err, requestId, mapped.status);
|
|
2781
2571
|
if (res.headersSent) {
|
|
2782
2572
|
try {
|
|
2783
2573
|
res.end();
|
|
@@ -2785,33 +2575,20 @@ export async function serve(opts) {
|
|
|
2785
2575
|
catch { /* socket already gone */ }
|
|
2786
2576
|
return;
|
|
2787
2577
|
}
|
|
2788
|
-
if (
|
|
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);
|
|
2578
|
+
if (mapped.status === 500) {
|
|
2579
|
+
// The id lets an operator find the logged cause without the client seeing internal text.
|
|
2580
|
+
sendJson(res, 500, { error: mapped.message, requestId });
|
|
2799
2581
|
return;
|
|
2800
2582
|
}
|
|
2801
|
-
//
|
|
2802
|
-
// at 400 with {error: <message>, code: <code>}. The `error` field
|
|
2803
|
-
// matches `sendError`'s shape (human message, used by HttpError /
|
|
2804
|
-
// BodyTooLargeError / mapApiError). The `code` field is the typed
|
|
2805
|
-
// discriminator — clients can branch on `body.code` without parsing
|
|
2806
|
-
// prose. Earlier draft used {error: code, message: text} but that
|
|
2807
|
-
// diverged from the rest of v1/* and forced clients to special-case
|
|
2808
|
-
// the error path.
|
|
2583
|
+
// RecallContractError keeps the shared {error} shape and adds `code` so clients branch without parsing prose.
|
|
2809
2584
|
if (err instanceof RecallContractError) {
|
|
2810
2585
|
sendJson(res, 400, { error: err.message, code: err.code });
|
|
2811
2586
|
return;
|
|
2812
2587
|
}
|
|
2813
|
-
const mapped = mapApiError(err);
|
|
2814
2588
|
sendError(res, mapped.status, mapped.message);
|
|
2589
|
+
// M3: readBody hit the 1 MB cap mid-stream, so drop the socket rather than drain unbounded bytes.
|
|
2590
|
+
if (err instanceof BodyTooLargeError)
|
|
2591
|
+
req.destroy();
|
|
2815
2592
|
});
|
|
2816
2593
|
});
|
|
2817
2594
|
// T3b capture (v1.26.2): tests/server-concurrency.test.ts's ECONNRESET flake
|