hippo-memory 1.14.0 → 1.16.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 +862 -861
- package/dist/audit.d.ts +1 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1594 -229
- package/dist/cli.js.map +1 -1
- package/dist/customer-notes.d.ts +95 -0
- package/dist/customer-notes.d.ts.map +1 -0
- package/dist/customer-notes.js +296 -0
- package/dist/customer-notes.js.map +1 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1286 -472
- package/dist/db.js.map +1 -1
- package/dist/decisions.d.ts +91 -0
- package/dist/decisions.d.ts.map +1 -0
- package/dist/decisions.js +278 -0
- package/dist/decisions.js.map +1 -0
- package/dist/graph-extract.d.ts +39 -0
- package/dist/graph-extract.d.ts.map +1 -0
- package/dist/graph-extract.js +141 -0
- package/dist/graph-extract.js.map +1 -0
- package/dist/graph-recall.d.ts +41 -0
- package/dist/graph-recall.d.ts.map +1 -0
- package/dist/graph-recall.js +246 -0
- package/dist/graph-recall.js.map +1 -0
- package/dist/graph.d.ts +137 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +433 -0
- package/dist/graph.js.map +1 -0
- package/dist/incidents.d.ts +100 -0
- package/dist/incidents.d.ts.map +1 -0
- package/dist/incidents.js +322 -0
- package/dist/incidents.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts +6 -0
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +6 -0
- package/dist/memory.js.map +1 -1
- package/dist/policies.d.ts +149 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +380 -0
- package/dist/policies.js.map +1 -0
- package/dist/processes.d.ts +104 -0
- package/dist/processes.d.ts.map +1 -0
- package/dist/processes.js +330 -0
- package/dist/processes.js.map +1 -0
- package/dist/project-briefs.d.ts +126 -0
- package/dist/project-briefs.d.ts.map +1 -0
- package/dist/project-briefs.js +453 -0
- package/dist/project-briefs.js.map +1 -0
- package/dist/search.d.ts +7 -0
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1181 -8
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +98 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +339 -0
- package/dist/skills.js.map +1 -0
- package/dist/src/audit.js.map +1 -1
- package/dist/src/cli.js +1594 -229
- package/dist/src/cli.js.map +1 -1
- package/dist/src/customer-notes.js +296 -0
- package/dist/src/customer-notes.js.map +1 -0
- package/dist/src/db.js +1286 -472
- package/dist/src/db.js.map +1 -1
- package/dist/src/decisions.js +278 -0
- package/dist/src/decisions.js.map +1 -0
- package/dist/src/graph-extract.js +141 -0
- package/dist/src/graph-extract.js.map +1 -0
- package/dist/src/graph-recall.js +246 -0
- package/dist/src/graph-recall.js.map +1 -0
- package/dist/src/graph.js +433 -0
- package/dist/src/graph.js.map +1 -0
- package/dist/src/incidents.js +322 -0
- package/dist/src/incidents.js.map +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/memory.js +6 -0
- package/dist/src/memory.js.map +1 -1
- package/dist/src/policies.js +380 -0
- package/dist/src/policies.js.map +1 -0
- package/dist/src/processes.js +330 -0
- package/dist/src/processes.js.map +1 -0
- package/dist/src/project-briefs.js +453 -0
- package/dist/src/project-briefs.js.map +1 -0
- package/dist/src/search.js.map +1 -1
- package/dist/src/server.js +1181 -8
- package/dist/src/server.js.map +1 -1
- package/dist/src/skills.js +339 -0
- package/dist/src/skills.js.map +1 -0
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +46 -46
- package/extensions/openclaw-plugin/package.json +14 -14
- package/openclaw.plugin.json +45 -45
- package/package.json +75 -75
package/dist/src/server.js
CHANGED
|
@@ -21,6 +21,13 @@ import { validateApiKey } from './auth.js';
|
|
|
21
21
|
import { createRateLimiter } from './rate-limit.js';
|
|
22
22
|
import { remember, recall, RecallContractError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, } from './api.js';
|
|
23
23
|
import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
|
|
24
|
+
import { saveDecision, closeDecision, loadDecisionById, loadDecisions, VALID_DECISION_STATES, } from './decisions.js';
|
|
25
|
+
import { saveIncident, resolveIncident, closeIncident, loadIncidentById, loadIncidents, VALID_INCIDENT_STATES, } from './incidents.js';
|
|
26
|
+
import { saveProcess, closeProcess, loadProcessById, loadProcesses, VALID_PROCESS_STATES, } from './processes.js';
|
|
27
|
+
import { savePolicy, closePolicy, loadPolicyById, loadPolicies, loadPoliciesAsOf, VALID_POLICY_STATES, } from './policies.js';
|
|
28
|
+
import { saveSkill, closeSkill, loadSkillById, loadSkills, exportSkills, VALID_SKILL_STATES, } from './skills.js';
|
|
29
|
+
import { saveProjectBrief, closeProjectBrief, loadProjectBriefById, loadProjectBriefs, assembleBriefFromReceipts, refreshBrief, VALID_BRIEF_STATES, } from './project-briefs.js';
|
|
30
|
+
import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomerNotes, VALID_NOTE_STATES, } from './customer-notes.js';
|
|
24
31
|
import { handleMcpRequest } from './mcp/server.js';
|
|
25
32
|
import { verifySlackSignature } from './connectors/slack/signature.js';
|
|
26
33
|
import { isSlackEventEnvelope, isSlackMessageEvent } from './connectors/slack/types.js';
|
|
@@ -76,11 +83,88 @@ const VALID_AUDIT_OPS = new Set([
|
|
|
76
83
|
'recall_anchor_detected_memory_dominance', // v0.33 / J1 — emitted by detector on R2 fire
|
|
77
84
|
'recall_anchor_skipped_no_session', // v0.33 / J1 — telemetry: no sessionId, ring skipped
|
|
78
85
|
'recall_availability_detected', // v1.13.x / J2 - emitted when availability/recency-bias hint fires
|
|
86
|
+
'decision_create', // E2 decision first-class object — emitted by saveDecision
|
|
87
|
+
'decision_supersede', // E2 — emitted by saveDecision when --supersedes resolves to an active decision row
|
|
88
|
+
'decision_close', // E2 — emitted by closeDecision
|
|
89
|
+
'incident_open', // E2 incident first-class object — emitted by saveIncident
|
|
90
|
+
'incident_resolve', // E2 — emitted by resolveIncident (open -> resolved)
|
|
91
|
+
'incident_close', // E2 — emitted by closeIncident (open|resolved -> closed)
|
|
92
|
+
'process_create', // E2 process first-class object — emitted by saveProcess
|
|
93
|
+
'process_supersede', // E2 — emitted by saveProcess on a supersession
|
|
94
|
+
'process_close', // E2 — emitted by closeProcess
|
|
95
|
+
'policy_create', // E2 policy first-class object — emitted by savePolicy
|
|
96
|
+
'policy_supersede', // E2 — emitted by savePolicy on a supersession
|
|
97
|
+
'policy_close', // E2 — emitted by closePolicy
|
|
98
|
+
'skill_create', // E2 skill first-class object — emitted by saveSkill
|
|
99
|
+
'skill_supersede', // E2 — emitted by saveSkill on a supersession
|
|
100
|
+
'skill_close', // E2 — emitted by closeSkill
|
|
101
|
+
'project_brief_create', // E2 project_brief first-class object — emitted by saveProjectBrief
|
|
102
|
+
'project_brief_supersede', // E2 — emitted by saveProjectBrief on a supersession (incl. refresh)
|
|
103
|
+
'project_brief_close', // E2 — emitted by closeProjectBrief
|
|
104
|
+
'customer_note_create', // E2 customer_note first-class object — emitted by saveCustomerNote
|
|
105
|
+
'customer_note_supersede', // E2 — emitted by saveCustomerNote on a supersession
|
|
106
|
+
'customer_note_close', // E2 — emitted by closeCustomerNote
|
|
79
107
|
]);
|
|
80
108
|
// Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
|
|
81
109
|
// enough to dump a small deployment's full audit log without paginating, but
|
|
82
110
|
// small enough that a malicious client can't ask for the world.
|
|
83
111
|
const MAX_AUDIT_LIMIT = 10000;
|
|
112
|
+
// HTTP-boundary validation for a process `steps` body (untrusted). Returns the
|
|
113
|
+
// step strings (saveProcess re-validates + trims, this is the fail-fast 400
|
|
114
|
+
// gate). Caps mirror src/processes.ts MAX_PROCESS_STEPS / MAX_PROCESS_STEP_LEN.
|
|
115
|
+
function validateProcessStepsBody(raw) {
|
|
116
|
+
if (raw === undefined || raw === null)
|
|
117
|
+
return [];
|
|
118
|
+
if (!Array.isArray(raw)) {
|
|
119
|
+
throw new HttpError(400, 'steps must be an array of strings');
|
|
120
|
+
}
|
|
121
|
+
if (raw.length > 200) {
|
|
122
|
+
throw new HttpError(400, 'steps exceeds 200-step cap');
|
|
123
|
+
}
|
|
124
|
+
for (const item of raw) {
|
|
125
|
+
if (typeof item !== 'string') {
|
|
126
|
+
throw new HttpError(400, 'each step must be a string');
|
|
127
|
+
}
|
|
128
|
+
if (item.trim().length === 0) {
|
|
129
|
+
throw new HttpError(400, 'a step is empty');
|
|
130
|
+
}
|
|
131
|
+
if (item.length > 2000) {
|
|
132
|
+
throw new HttpError(400, 'a step exceeds the 2000-character cap');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
// HTTP-boundary check for an optional policy date field (validFrom/validTo).
|
|
138
|
+
// Type + length only; savePolicy/loadPoliciesAsOf normalize + format-validate the
|
|
139
|
+
// value (an unparseable date throws there -> mapped to 400). 64-char cap bounds a
|
|
140
|
+
// junk string before it reaches the Date parser.
|
|
141
|
+
function optionalDateField(raw, label) {
|
|
142
|
+
if (raw === undefined || raw === null)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (typeof raw !== 'string') {
|
|
145
|
+
throw new HttpError(400, `${label} must be a string`);
|
|
146
|
+
}
|
|
147
|
+
if (raw.length > 64) {
|
|
148
|
+
throw new HttpError(400, `${label} exceeds 64-character cap`);
|
|
149
|
+
}
|
|
150
|
+
return raw;
|
|
151
|
+
}
|
|
152
|
+
// Parse a `?limit=` query param for the E2 list routes. Defaults to 100; requires
|
|
153
|
+
// a positive INTEGER <= 1000. Number.isInteger rejects fractional values like
|
|
154
|
+
// "1.5" that Number.isFinite would pass but SQLite `LIMIT ?` rejects with a
|
|
155
|
+
// datatype mismatch (a 500). Shared across the decision/incident/process/policy
|
|
156
|
+
// list routes so the guard cannot drift (codex review 2026-05-30 P2: fractional
|
|
157
|
+
// limit reached SQLite on the policy route; the same latent hole existed in the
|
|
158
|
+
// sibling routes this was copied from).
|
|
159
|
+
function parseListLimit(limitRaw) {
|
|
160
|
+
if (limitRaw === null)
|
|
161
|
+
return 100;
|
|
162
|
+
const limit = Number(limitRaw);
|
|
163
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
|
|
164
|
+
throw new HttpError(400, 'limit must be a positive integer <= 1000');
|
|
165
|
+
}
|
|
166
|
+
return limit;
|
|
167
|
+
}
|
|
84
168
|
const VALID_KINDS = new Set([
|
|
85
169
|
'raw',
|
|
86
170
|
'distilled',
|
|
@@ -1025,14 +1109,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
1025
1109
|
if (method === 'GET' && path === '/v1/predictions') {
|
|
1026
1110
|
const classTag = query.get('class') ?? undefined;
|
|
1027
1111
|
const status = query.get('status') ?? 'all';
|
|
1028
|
-
const
|
|
1029
|
-
let limit = 100;
|
|
1030
|
-
if (limitRaw !== null) {
|
|
1031
|
-
limit = Number(limitRaw);
|
|
1032
|
-
if (!Number.isFinite(limit) || limit <= 0 || limit > 1000) {
|
|
1033
|
-
throw new HttpError(400, 'limit must be a positive integer <= 1000');
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1112
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1036
1113
|
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1037
1114
|
let predictions;
|
|
1038
1115
|
if (status === 'all') {
|
|
@@ -1137,6 +1214,1102 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
1137
1214
|
}
|
|
1138
1215
|
return;
|
|
1139
1216
|
}
|
|
1217
|
+
// ── decisions (E2 first-class object) ──
|
|
1218
|
+
//
|
|
1219
|
+
// 5 routes: POST /v1/decisions (create, optional supersedesDecisionId),
|
|
1220
|
+
// GET /v1/decisions (list, status filter), GET /v1/decisions/:id (show),
|
|
1221
|
+
// POST /v1/decisions/:id/supersede (create a successor + supersede :id),
|
|
1222
|
+
// POST /v1/decisions/:id/close (retire). Bearer-authed + tenant-scoped via
|
|
1223
|
+
// buildContextWithAuth. status validated against VALID_DECISION_STATES.
|
|
1224
|
+
// DoS caps: text 4096, context 4096 (v1.11.4 pattern). The HTTP surface is
|
|
1225
|
+
// new (no legacy --supersedes <memory-id> constraint), so it supersedes by
|
|
1226
|
+
// table id and never weakens a memory mirror.
|
|
1227
|
+
if (method === 'POST' && path === '/v1/decisions') {
|
|
1228
|
+
const body = await parseJsonBody(req);
|
|
1229
|
+
const text = body['text'];
|
|
1230
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
1231
|
+
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1232
|
+
}
|
|
1233
|
+
if (text.length > 4096) {
|
|
1234
|
+
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1235
|
+
}
|
|
1236
|
+
const contextRaw = body['context'];
|
|
1237
|
+
let context;
|
|
1238
|
+
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1239
|
+
if (typeof contextRaw !== 'string') {
|
|
1240
|
+
throw new HttpError(400, 'context must be a string');
|
|
1241
|
+
}
|
|
1242
|
+
if (contextRaw.length > 4096) {
|
|
1243
|
+
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1244
|
+
}
|
|
1245
|
+
context = contextRaw;
|
|
1246
|
+
}
|
|
1247
|
+
const supRaw = body['supersedesDecisionId'];
|
|
1248
|
+
let supersedesDecisionId;
|
|
1249
|
+
if (supRaw !== undefined && supRaw !== null) {
|
|
1250
|
+
if (typeof supRaw !== 'number' || !Number.isInteger(supRaw) || supRaw <= 0) {
|
|
1251
|
+
throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
|
|
1252
|
+
}
|
|
1253
|
+
supersedesDecisionId = supRaw;
|
|
1254
|
+
}
|
|
1255
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1256
|
+
try {
|
|
1257
|
+
const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
|
|
1258
|
+
decisionText: text,
|
|
1259
|
+
context,
|
|
1260
|
+
supersedesDecisionId,
|
|
1261
|
+
}, ctx.actor.subject);
|
|
1262
|
+
sendJson(res, 201, { decision });
|
|
1263
|
+
}
|
|
1264
|
+
catch (e) {
|
|
1265
|
+
const msg = e.message;
|
|
1266
|
+
if (msg.includes('not found') || msg.includes('not active')) {
|
|
1267
|
+
throw new HttpError(409, msg);
|
|
1268
|
+
}
|
|
1269
|
+
throw e;
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (method === 'GET' && path === '/v1/decisions') {
|
|
1274
|
+
const status = query.get('status') ?? 'all';
|
|
1275
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1276
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1277
|
+
let decisions;
|
|
1278
|
+
if (status === 'all') {
|
|
1279
|
+
decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
if (!VALID_DECISION_STATES.has(status)) {
|
|
1283
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1284
|
+
}
|
|
1285
|
+
decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
|
|
1286
|
+
status: status,
|
|
1287
|
+
limit,
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
sendJson(res, 200, { decisions });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const decisionSupersedeMatch = path.match(/^\/v1\/decisions\/(\d+)\/supersede$/);
|
|
1294
|
+
if (method === 'POST' && decisionSupersedeMatch) {
|
|
1295
|
+
const oldId = parseInt(decisionSupersedeMatch[1], 10);
|
|
1296
|
+
const body = await parseJsonBody(req);
|
|
1297
|
+
const text = body['text'];
|
|
1298
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
1299
|
+
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1300
|
+
}
|
|
1301
|
+
if (text.length > 4096) {
|
|
1302
|
+
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1303
|
+
}
|
|
1304
|
+
const contextRaw = body['context'];
|
|
1305
|
+
let context;
|
|
1306
|
+
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1307
|
+
if (typeof contextRaw !== 'string') {
|
|
1308
|
+
throw new HttpError(400, 'context must be a string');
|
|
1309
|
+
}
|
|
1310
|
+
if (contextRaw.length > 4096) {
|
|
1311
|
+
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1312
|
+
}
|
|
1313
|
+
context = contextRaw;
|
|
1314
|
+
}
|
|
1315
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1316
|
+
try {
|
|
1317
|
+
const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
|
|
1318
|
+
decisionText: text,
|
|
1319
|
+
context,
|
|
1320
|
+
supersedesDecisionId: oldId,
|
|
1321
|
+
}, ctx.actor.subject);
|
|
1322
|
+
sendJson(res, 201, { decision });
|
|
1323
|
+
}
|
|
1324
|
+
catch (e) {
|
|
1325
|
+
const msg = e.message;
|
|
1326
|
+
if (msg.includes('not found')) {
|
|
1327
|
+
throw new HttpError(404, msg);
|
|
1328
|
+
}
|
|
1329
|
+
if (msg.includes('not active')) {
|
|
1330
|
+
throw new HttpError(409, msg);
|
|
1331
|
+
}
|
|
1332
|
+
throw e;
|
|
1333
|
+
}
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const decisionCloseMatch = path.match(/^\/v1\/decisions\/(\d+)\/close$/);
|
|
1337
|
+
if (method === 'POST' && decisionCloseMatch) {
|
|
1338
|
+
const id = parseInt(decisionCloseMatch[1], 10);
|
|
1339
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1340
|
+
try {
|
|
1341
|
+
const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1342
|
+
sendJson(res, 200, { decision });
|
|
1343
|
+
}
|
|
1344
|
+
catch (e) {
|
|
1345
|
+
const msg = e.message;
|
|
1346
|
+
if (msg.includes('not found')) {
|
|
1347
|
+
throw new HttpError(404, msg);
|
|
1348
|
+
}
|
|
1349
|
+
if (msg.includes('not active')) {
|
|
1350
|
+
throw new HttpError(409, msg);
|
|
1351
|
+
}
|
|
1352
|
+
throw e;
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const decisionByIdMatch = path.match(/^\/v1\/decisions\/(\d+)$/);
|
|
1357
|
+
if (method === 'GET' && decisionByIdMatch) {
|
|
1358
|
+
const id = parseInt(decisionByIdMatch[1], 10);
|
|
1359
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1360
|
+
const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
|
|
1361
|
+
if (!decision) {
|
|
1362
|
+
throw new HttpError(404, `decision ${id} not found`);
|
|
1363
|
+
}
|
|
1364
|
+
sendJson(res, 200, { decision });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
// ── incidents (E2 first-class object) ──
|
|
1368
|
+
//
|
|
1369
|
+
// 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
|
|
1370
|
+
// GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
|
|
1371
|
+
// POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
|
|
1372
|
+
// POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
|
|
1373
|
+
// tenant-scoped via buildContextWithAuth. status validated against
|
|
1374
|
+
// VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
|
|
1375
|
+
// 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
|
|
1376
|
+
// open->resolved->closed (no supersede), so linkedMemoryIds replaces
|
|
1377
|
+
// supersedesDecisionId on create.
|
|
1378
|
+
if (method === 'POST' && path === '/v1/incidents') {
|
|
1379
|
+
const body = await parseJsonBody(req);
|
|
1380
|
+
const text = body['text'];
|
|
1381
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
1382
|
+
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1383
|
+
}
|
|
1384
|
+
if (text.length > 4096) {
|
|
1385
|
+
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1386
|
+
}
|
|
1387
|
+
const contextRaw = body['context'];
|
|
1388
|
+
let context;
|
|
1389
|
+
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1390
|
+
if (typeof contextRaw !== 'string') {
|
|
1391
|
+
throw new HttpError(400, 'context must be a string');
|
|
1392
|
+
}
|
|
1393
|
+
if (contextRaw.length > 4096) {
|
|
1394
|
+
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1395
|
+
}
|
|
1396
|
+
context = contextRaw;
|
|
1397
|
+
}
|
|
1398
|
+
const linkedRaw = body['linkedMemoryIds'];
|
|
1399
|
+
let linkedMemoryIds;
|
|
1400
|
+
if (linkedRaw !== undefined && linkedRaw !== null) {
|
|
1401
|
+
if (!Array.isArray(linkedRaw)) {
|
|
1402
|
+
throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
|
|
1403
|
+
}
|
|
1404
|
+
if (linkedRaw.length > 256) {
|
|
1405
|
+
throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
|
|
1406
|
+
}
|
|
1407
|
+
for (const item of linkedRaw) {
|
|
1408
|
+
if (typeof item !== 'string' || item.length === 0 || item.length > 4096) {
|
|
1409
|
+
throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
linkedMemoryIds = linkedRaw;
|
|
1413
|
+
}
|
|
1414
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1415
|
+
try {
|
|
1416
|
+
const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
|
|
1417
|
+
incidentText: text,
|
|
1418
|
+
context,
|
|
1419
|
+
linkedMemoryIds,
|
|
1420
|
+
}, ctx.actor.subject);
|
|
1421
|
+
sendJson(res, 201, { incident });
|
|
1422
|
+
}
|
|
1423
|
+
catch (e) {
|
|
1424
|
+
const msg = e.message;
|
|
1425
|
+
if (msg.includes('not found')) {
|
|
1426
|
+
throw new HttpError(409, msg);
|
|
1427
|
+
}
|
|
1428
|
+
throw e;
|
|
1429
|
+
}
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (method === 'GET' && path === '/v1/incidents') {
|
|
1433
|
+
const status = query.get('status') ?? 'all';
|
|
1434
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1435
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1436
|
+
let incidents;
|
|
1437
|
+
if (status === 'all') {
|
|
1438
|
+
incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
if (!VALID_INCIDENT_STATES.has(status)) {
|
|
1442
|
+
throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
|
|
1443
|
+
}
|
|
1444
|
+
incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
|
|
1445
|
+
status: status,
|
|
1446
|
+
limit,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
sendJson(res, 200, { incidents });
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const incidentResolveMatch = path.match(/^\/v1\/incidents\/(\d+)\/resolve$/);
|
|
1453
|
+
if (method === 'POST' && incidentResolveMatch) {
|
|
1454
|
+
const id = parseInt(incidentResolveMatch[1], 10);
|
|
1455
|
+
const body = await parseJsonBody(req);
|
|
1456
|
+
const resolutionText = body['resolutionText'];
|
|
1457
|
+
if (typeof resolutionText !== 'string' || resolutionText.trim().length === 0) {
|
|
1458
|
+
throw new HttpError(400, 'resolutionText is required (non-empty string)');
|
|
1459
|
+
}
|
|
1460
|
+
if (resolutionText.length > 4096) {
|
|
1461
|
+
throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
|
|
1462
|
+
}
|
|
1463
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1464
|
+
try {
|
|
1465
|
+
const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
|
|
1466
|
+
sendJson(res, 200, { incident });
|
|
1467
|
+
}
|
|
1468
|
+
catch (e) {
|
|
1469
|
+
const msg = e.message;
|
|
1470
|
+
if (msg.includes('not found')) {
|
|
1471
|
+
throw new HttpError(404, msg);
|
|
1472
|
+
}
|
|
1473
|
+
if (msg.includes('not open')) {
|
|
1474
|
+
throw new HttpError(409, msg);
|
|
1475
|
+
}
|
|
1476
|
+
throw e;
|
|
1477
|
+
}
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
const incidentCloseMatch = path.match(/^\/v1\/incidents\/(\d+)\/close$/);
|
|
1481
|
+
if (method === 'POST' && incidentCloseMatch) {
|
|
1482
|
+
const id = parseInt(incidentCloseMatch[1], 10);
|
|
1483
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1484
|
+
try {
|
|
1485
|
+
const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1486
|
+
sendJson(res, 200, { incident });
|
|
1487
|
+
}
|
|
1488
|
+
catch (e) {
|
|
1489
|
+
const msg = e.message;
|
|
1490
|
+
if (msg.includes('not found')) {
|
|
1491
|
+
throw new HttpError(404, msg);
|
|
1492
|
+
}
|
|
1493
|
+
if (msg.includes('already closed')) {
|
|
1494
|
+
throw new HttpError(409, msg);
|
|
1495
|
+
}
|
|
1496
|
+
throw e;
|
|
1497
|
+
}
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const incidentByIdMatch = path.match(/^\/v1\/incidents\/(\d+)$/);
|
|
1501
|
+
if (method === 'GET' && incidentByIdMatch) {
|
|
1502
|
+
const id = parseInt(incidentByIdMatch[1], 10);
|
|
1503
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1504
|
+
const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
|
|
1505
|
+
if (!incident) {
|
|
1506
|
+
throw new HttpError(404, `incident ${id} not found`);
|
|
1507
|
+
}
|
|
1508
|
+
sendJson(res, 200, { incident });
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
// ── processes (E2 first-class object) ──
|
|
1512
|
+
//
|
|
1513
|
+
// 5 routes: POST /v1/processes (new; body processName + steps[] + description),
|
|
1514
|
+
// GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
|
|
1515
|
+
// POST /v1/processes/:id/supersede (active -> superseded by a new version; body
|
|
1516
|
+
// steps[] + changeSummary + description; reuses the predecessor's name),
|
|
1517
|
+
// POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
|
|
1518
|
+
// via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
|
|
1519
|
+
// caps: processName/description/changeSummary 4096, steps 200x2000
|
|
1520
|
+
// (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
|
|
1521
|
+
// decision supersede path.
|
|
1522
|
+
if (method === 'POST' && path === '/v1/processes') {
|
|
1523
|
+
const body = await parseJsonBody(req);
|
|
1524
|
+
const processName = body['processName'];
|
|
1525
|
+
if (typeof processName !== 'string' || processName.trim().length === 0) {
|
|
1526
|
+
throw new HttpError(400, 'processName is required (non-empty string)');
|
|
1527
|
+
}
|
|
1528
|
+
if (processName.length > 4096) {
|
|
1529
|
+
throw new HttpError(400, 'processName exceeds 4096-character cap');
|
|
1530
|
+
}
|
|
1531
|
+
const steps = validateProcessStepsBody(body['steps']);
|
|
1532
|
+
const descriptionRaw = body['description'];
|
|
1533
|
+
let description;
|
|
1534
|
+
if (descriptionRaw !== undefined && descriptionRaw !== null) {
|
|
1535
|
+
if (typeof descriptionRaw !== 'string') {
|
|
1536
|
+
throw new HttpError(400, 'description must be a string');
|
|
1537
|
+
}
|
|
1538
|
+
if (descriptionRaw.length > 4096) {
|
|
1539
|
+
throw new HttpError(400, 'description exceeds 4096-character cap');
|
|
1540
|
+
}
|
|
1541
|
+
description = descriptionRaw;
|
|
1542
|
+
}
|
|
1543
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1544
|
+
const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
|
|
1545
|
+
processName,
|
|
1546
|
+
steps,
|
|
1547
|
+
description,
|
|
1548
|
+
}, ctx.actor.subject);
|
|
1549
|
+
sendJson(res, 201, { process });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
if (method === 'GET' && path === '/v1/processes') {
|
|
1553
|
+
const status = query.get('status') ?? 'all';
|
|
1554
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1555
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1556
|
+
let processes;
|
|
1557
|
+
if (status === 'all') {
|
|
1558
|
+
processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
if (!VALID_PROCESS_STATES.has(status)) {
|
|
1562
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1563
|
+
}
|
|
1564
|
+
processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
|
|
1565
|
+
status: status,
|
|
1566
|
+
limit,
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
sendJson(res, 200, { processes });
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
const processSupersedeMatch = path.match(/^\/v1\/processes\/(\d+)\/supersede$/);
|
|
1573
|
+
if (method === 'POST' && processSupersedeMatch) {
|
|
1574
|
+
const id = parseInt(processSupersedeMatch[1], 10);
|
|
1575
|
+
const body = await parseJsonBody(req);
|
|
1576
|
+
const steps = validateProcessStepsBody(body['steps']);
|
|
1577
|
+
if (steps.length === 0) {
|
|
1578
|
+
throw new HttpError(400, 'steps is required (at least one step) for a supersession');
|
|
1579
|
+
}
|
|
1580
|
+
const changeRaw = body['changeSummary'];
|
|
1581
|
+
let changeSummary;
|
|
1582
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
1583
|
+
if (typeof changeRaw !== 'string') {
|
|
1584
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
1585
|
+
}
|
|
1586
|
+
if (changeRaw.length > 4096) {
|
|
1587
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
1588
|
+
}
|
|
1589
|
+
changeSummary = changeRaw;
|
|
1590
|
+
}
|
|
1591
|
+
const descRaw = body['description'];
|
|
1592
|
+
let description;
|
|
1593
|
+
if (descRaw !== undefined && descRaw !== null) {
|
|
1594
|
+
if (typeof descRaw !== 'string') {
|
|
1595
|
+
throw new HttpError(400, 'description must be a string');
|
|
1596
|
+
}
|
|
1597
|
+
if (descRaw.length > 4096) {
|
|
1598
|
+
throw new HttpError(400, 'description exceeds 4096-character cap');
|
|
1599
|
+
}
|
|
1600
|
+
description = descRaw;
|
|
1601
|
+
}
|
|
1602
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1603
|
+
// A supersession is a new version of the SAME process: reuse the
|
|
1604
|
+
// predecessor's name. 404 if the target does not exist; saveProcess's
|
|
1605
|
+
// in-SAVEPOINT preflight is the authoritative active-state check (409).
|
|
1606
|
+
const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
|
|
1607
|
+
if (!existing) {
|
|
1608
|
+
throw new HttpError(404, `process ${id} not found`);
|
|
1609
|
+
}
|
|
1610
|
+
try {
|
|
1611
|
+
const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
|
|
1612
|
+
processName: existing.processName,
|
|
1613
|
+
steps,
|
|
1614
|
+
description,
|
|
1615
|
+
changeSummary,
|
|
1616
|
+
supersedesProcessId: id,
|
|
1617
|
+
}, ctx.actor.subject);
|
|
1618
|
+
sendJson(res, 200, { process });
|
|
1619
|
+
}
|
|
1620
|
+
catch (e) {
|
|
1621
|
+
const msg = e.message;
|
|
1622
|
+
if (msg.includes('not found')) {
|
|
1623
|
+
throw new HttpError(404, msg);
|
|
1624
|
+
}
|
|
1625
|
+
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
1626
|
+
throw new HttpError(409, msg);
|
|
1627
|
+
}
|
|
1628
|
+
throw e;
|
|
1629
|
+
}
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const processCloseMatch = path.match(/^\/v1\/processes\/(\d+)\/close$/);
|
|
1633
|
+
if (method === 'POST' && processCloseMatch) {
|
|
1634
|
+
const id = parseInt(processCloseMatch[1], 10);
|
|
1635
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1636
|
+
try {
|
|
1637
|
+
const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1638
|
+
sendJson(res, 200, { process });
|
|
1639
|
+
}
|
|
1640
|
+
catch (e) {
|
|
1641
|
+
const msg = e.message;
|
|
1642
|
+
if (msg.includes('not found')) {
|
|
1643
|
+
throw new HttpError(404, msg);
|
|
1644
|
+
}
|
|
1645
|
+
if (msg.includes('not active')) {
|
|
1646
|
+
throw new HttpError(409, msg);
|
|
1647
|
+
}
|
|
1648
|
+
throw e;
|
|
1649
|
+
}
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const processByIdMatch = path.match(/^\/v1\/processes\/(\d+)$/);
|
|
1653
|
+
if (method === 'GET' && processByIdMatch) {
|
|
1654
|
+
const id = parseInt(processByIdMatch[1], 10);
|
|
1655
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1656
|
+
const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
|
|
1657
|
+
if (!process) {
|
|
1658
|
+
throw new HttpError(404, `process ${id} not found`);
|
|
1659
|
+
}
|
|
1660
|
+
sendJson(res, 200, { process });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
// ── policies (E2 first-class object, bi-temporal-first) ──
|
|
1664
|
+
//
|
|
1665
|
+
// 6 routes: POST /v1/policies (new; processName-style body policyName +
|
|
1666
|
+
// policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
|
|
1667
|
+
// GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
|
|
1668
|
+
// placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
|
|
1669
|
+
// /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
|
|
1670
|
+
// Date inputs are normalized + range-validated in the store; an invalid/inverted
|
|
1671
|
+
// date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
|
|
1672
|
+
if (method === 'POST' && path === '/v1/policies') {
|
|
1673
|
+
const body = await parseJsonBody(req);
|
|
1674
|
+
const policyName = body['policyName'];
|
|
1675
|
+
if (typeof policyName !== 'string' || policyName.trim().length === 0) {
|
|
1676
|
+
throw new HttpError(400, 'policyName is required (non-empty string)');
|
|
1677
|
+
}
|
|
1678
|
+
if (policyName.length > 4096) {
|
|
1679
|
+
throw new HttpError(400, 'policyName exceeds 4096-character cap');
|
|
1680
|
+
}
|
|
1681
|
+
const policyText = body['policyText'];
|
|
1682
|
+
if (typeof policyText !== 'string' || policyText.trim().length === 0) {
|
|
1683
|
+
throw new HttpError(400, 'policyText is required (non-empty string)');
|
|
1684
|
+
}
|
|
1685
|
+
if (policyText.length > 4096) {
|
|
1686
|
+
throw new HttpError(400, 'policyText exceeds 4096-character cap');
|
|
1687
|
+
}
|
|
1688
|
+
const validFrom = optionalDateField(body['validFrom'], 'validFrom');
|
|
1689
|
+
const validTo = optionalDateField(body['validTo'], 'validTo');
|
|
1690
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1691
|
+
try {
|
|
1692
|
+
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
1693
|
+
policyName,
|
|
1694
|
+
policyText,
|
|
1695
|
+
validFrom,
|
|
1696
|
+
validTo,
|
|
1697
|
+
}, ctx.actor.subject);
|
|
1698
|
+
sendJson(res, 201, { policy });
|
|
1699
|
+
}
|
|
1700
|
+
catch (e) {
|
|
1701
|
+
// savePolicy throws on invalid/inverted dates (validation) -> 400.
|
|
1702
|
+
throw new HttpError(400, e.message);
|
|
1703
|
+
}
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
if (method === 'GET' && path === '/v1/policies') {
|
|
1707
|
+
const status = query.get('status') ?? 'all';
|
|
1708
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1709
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1710
|
+
let policies;
|
|
1711
|
+
if (status === 'all') {
|
|
1712
|
+
policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1713
|
+
}
|
|
1714
|
+
else {
|
|
1715
|
+
if (!VALID_POLICY_STATES.has(status)) {
|
|
1716
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1717
|
+
}
|
|
1718
|
+
policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
|
|
1719
|
+
status: status,
|
|
1720
|
+
limit,
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
sendJson(res, 200, { policies });
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
// The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
|
|
1727
|
+
// the /(\d+)/ route would not match it, but order it first for clarity).
|
|
1728
|
+
if (method === 'GET' && path === '/v1/policies/asof') {
|
|
1729
|
+
const date = query.get('date');
|
|
1730
|
+
if (date === null || date.length === 0) {
|
|
1731
|
+
throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
|
|
1732
|
+
}
|
|
1733
|
+
const name = query.get('name') ?? undefined;
|
|
1734
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1735
|
+
try {
|
|
1736
|
+
const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
|
|
1737
|
+
sendJson(res, 200, { policies });
|
|
1738
|
+
}
|
|
1739
|
+
catch (e) {
|
|
1740
|
+
throw new HttpError(400, e.message);
|
|
1741
|
+
}
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
const policySupersedeMatch = path.match(/^\/v1\/policies\/(\d+)\/supersede$/);
|
|
1745
|
+
if (method === 'POST' && policySupersedeMatch) {
|
|
1746
|
+
const id = parseInt(policySupersedeMatch[1], 10);
|
|
1747
|
+
const body = await parseJsonBody(req);
|
|
1748
|
+
const policyText = body['policyText'];
|
|
1749
|
+
if (typeof policyText !== 'string' || policyText.trim().length === 0) {
|
|
1750
|
+
throw new HttpError(400, 'policyText is required (non-empty string)');
|
|
1751
|
+
}
|
|
1752
|
+
if (policyText.length > 4096) {
|
|
1753
|
+
throw new HttpError(400, 'policyText exceeds 4096-character cap');
|
|
1754
|
+
}
|
|
1755
|
+
const validFrom = optionalDateField(body['validFrom'], 'validFrom');
|
|
1756
|
+
const validTo = optionalDateField(body['validTo'], 'validTo');
|
|
1757
|
+
const changeRaw = body['changeSummary'];
|
|
1758
|
+
let changeSummary;
|
|
1759
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
1760
|
+
if (typeof changeRaw !== 'string') {
|
|
1761
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
1762
|
+
}
|
|
1763
|
+
if (changeRaw.length > 4096) {
|
|
1764
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
1765
|
+
}
|
|
1766
|
+
changeSummary = changeRaw;
|
|
1767
|
+
}
|
|
1768
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1769
|
+
const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
|
|
1770
|
+
if (!existing) {
|
|
1771
|
+
throw new HttpError(404, `policy ${id} not found`);
|
|
1772
|
+
}
|
|
1773
|
+
try {
|
|
1774
|
+
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
1775
|
+
policyName: existing.policyName,
|
|
1776
|
+
policyText,
|
|
1777
|
+
validFrom,
|
|
1778
|
+
validTo,
|
|
1779
|
+
changeSummary,
|
|
1780
|
+
supersedesPolicyId: id,
|
|
1781
|
+
}, ctx.actor.subject);
|
|
1782
|
+
sendJson(res, 200, { policy });
|
|
1783
|
+
}
|
|
1784
|
+
catch (e) {
|
|
1785
|
+
const msg = e.message;
|
|
1786
|
+
if (msg.includes('not found')) {
|
|
1787
|
+
throw new HttpError(404, msg);
|
|
1788
|
+
}
|
|
1789
|
+
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
1790
|
+
throw new HttpError(409, msg);
|
|
1791
|
+
}
|
|
1792
|
+
// invalid/inverted date or missing field -> validation.
|
|
1793
|
+
throw new HttpError(400, msg);
|
|
1794
|
+
}
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const policyCloseMatch = path.match(/^\/v1\/policies\/(\d+)\/close$/);
|
|
1798
|
+
if (method === 'POST' && policyCloseMatch) {
|
|
1799
|
+
const id = parseInt(policyCloseMatch[1], 10);
|
|
1800
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1801
|
+
try {
|
|
1802
|
+
const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1803
|
+
sendJson(res, 200, { policy });
|
|
1804
|
+
}
|
|
1805
|
+
catch (e) {
|
|
1806
|
+
const msg = e.message;
|
|
1807
|
+
if (msg.includes('not found')) {
|
|
1808
|
+
throw new HttpError(404, msg);
|
|
1809
|
+
}
|
|
1810
|
+
if (msg.includes('not active')) {
|
|
1811
|
+
throw new HttpError(409, msg);
|
|
1812
|
+
}
|
|
1813
|
+
throw e;
|
|
1814
|
+
}
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const policyByIdMatch = path.match(/^\/v1\/policies\/(\d+)$/);
|
|
1818
|
+
if (method === 'GET' && policyByIdMatch) {
|
|
1819
|
+
const id = parseInt(policyByIdMatch[1], 10);
|
|
1820
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1821
|
+
const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
|
|
1822
|
+
if (!policy) {
|
|
1823
|
+
throw new HttpError(404, `policy ${id} not found`);
|
|
1824
|
+
}
|
|
1825
|
+
sendJson(res, 200, { policy });
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
// ── skills (E2 first-class object, executable/exportable) ──
|
|
1829
|
+
//
|
|
1830
|
+
// 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
|
|
1831
|
+
// GET /v1/skills (list, status filter; shared parseListLimit), GET
|
|
1832
|
+
// /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
|
|
1833
|
+
// block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
|
|
1834
|
+
// cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
|
|
1835
|
+
// POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
|
|
1836
|
+
// skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
|
|
1837
|
+
// validates + throws; the boundary maps validation -> 400, not-found -> 404,
|
|
1838
|
+
// not-active -> 409. Mirrors /v1/processes; "executable" = exportable
|
|
1839
|
+
// instruction (no code exec).
|
|
1840
|
+
if (method === 'POST' && path === '/v1/skills') {
|
|
1841
|
+
const body = await parseJsonBody(req);
|
|
1842
|
+
const skillName = body['skillName'];
|
|
1843
|
+
if (typeof skillName !== 'string' || skillName.trim().length === 0) {
|
|
1844
|
+
throw new HttpError(400, 'skillName is required (non-empty string)');
|
|
1845
|
+
}
|
|
1846
|
+
if (skillName.length > 256) {
|
|
1847
|
+
throw new HttpError(400, 'skillName exceeds 256-character cap');
|
|
1848
|
+
}
|
|
1849
|
+
const instructions = body['instructions'];
|
|
1850
|
+
if (typeof instructions !== 'string' || instructions.trim().length === 0) {
|
|
1851
|
+
throw new HttpError(400, 'instructions are required (non-empty string)');
|
|
1852
|
+
}
|
|
1853
|
+
if (instructions.length > 8192) {
|
|
1854
|
+
throw new HttpError(400, 'instructions exceed 8192-character cap');
|
|
1855
|
+
}
|
|
1856
|
+
const triggerRaw = body['trigger'];
|
|
1857
|
+
let trigger;
|
|
1858
|
+
if (triggerRaw !== undefined && triggerRaw !== null) {
|
|
1859
|
+
if (typeof triggerRaw !== 'string') {
|
|
1860
|
+
throw new HttpError(400, 'trigger must be a string');
|
|
1861
|
+
}
|
|
1862
|
+
if (triggerRaw.length > 1024) {
|
|
1863
|
+
throw new HttpError(400, 'trigger exceeds 1024-character cap');
|
|
1864
|
+
}
|
|
1865
|
+
trigger = triggerRaw;
|
|
1866
|
+
}
|
|
1867
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1868
|
+
try {
|
|
1869
|
+
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
1870
|
+
skillName,
|
|
1871
|
+
instructions,
|
|
1872
|
+
trigger,
|
|
1873
|
+
}, ctx.actor.subject);
|
|
1874
|
+
sendJson(res, 201, { skill });
|
|
1875
|
+
}
|
|
1876
|
+
catch (e) {
|
|
1877
|
+
// saveSkill throws on validation (single-line name etc.) -> 400.
|
|
1878
|
+
throw new HttpError(400, e.message);
|
|
1879
|
+
}
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
if (method === 'GET' && path === '/v1/skills') {
|
|
1883
|
+
const status = query.get('status') ?? 'all';
|
|
1884
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1885
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1886
|
+
let skills;
|
|
1887
|
+
if (status === 'all') {
|
|
1888
|
+
skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1889
|
+
}
|
|
1890
|
+
else {
|
|
1891
|
+
if (!VALID_SKILL_STATES.has(status)) {
|
|
1892
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1893
|
+
}
|
|
1894
|
+
skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
|
|
1895
|
+
status: status,
|
|
1896
|
+
limit,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
sendJson(res, 200, { skills });
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
// The export renderer: must precede the /:id GET (literal 'export' is
|
|
1903
|
+
// non-numeric so the /(\d+)/ route would not match it, but order it first).
|
|
1904
|
+
if (method === 'GET' && path === '/v1/skills/export') {
|
|
1905
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1906
|
+
const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
|
|
1907
|
+
sendJson(res, 200, { markdown });
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const skillSupersedeMatch = path.match(/^\/v1\/skills\/(\d+)\/supersede$/);
|
|
1911
|
+
if (method === 'POST' && skillSupersedeMatch) {
|
|
1912
|
+
const id = parseInt(skillSupersedeMatch[1], 10);
|
|
1913
|
+
const body = await parseJsonBody(req);
|
|
1914
|
+
const instructions = body['instructions'];
|
|
1915
|
+
if (typeof instructions !== 'string' || instructions.trim().length === 0) {
|
|
1916
|
+
throw new HttpError(400, 'instructions are required (non-empty string)');
|
|
1917
|
+
}
|
|
1918
|
+
if (instructions.length > 8192) {
|
|
1919
|
+
throw new HttpError(400, 'instructions exceed 8192-character cap');
|
|
1920
|
+
}
|
|
1921
|
+
const triggerRaw = body['trigger'];
|
|
1922
|
+
let trigger;
|
|
1923
|
+
if (triggerRaw !== undefined && triggerRaw !== null) {
|
|
1924
|
+
if (typeof triggerRaw !== 'string') {
|
|
1925
|
+
throw new HttpError(400, 'trigger must be a string');
|
|
1926
|
+
}
|
|
1927
|
+
if (triggerRaw.length > 1024) {
|
|
1928
|
+
throw new HttpError(400, 'trigger exceeds 1024-character cap');
|
|
1929
|
+
}
|
|
1930
|
+
trigger = triggerRaw;
|
|
1931
|
+
}
|
|
1932
|
+
const changeRaw = body['changeSummary'];
|
|
1933
|
+
let changeSummary;
|
|
1934
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
1935
|
+
if (typeof changeRaw !== 'string') {
|
|
1936
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
1937
|
+
}
|
|
1938
|
+
if (changeRaw.length > 4096) {
|
|
1939
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
1940
|
+
}
|
|
1941
|
+
changeSummary = changeRaw;
|
|
1942
|
+
}
|
|
1943
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1944
|
+
const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
|
|
1945
|
+
if (!existing) {
|
|
1946
|
+
throw new HttpError(404, `skill ${id} not found`);
|
|
1947
|
+
}
|
|
1948
|
+
try {
|
|
1949
|
+
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
1950
|
+
skillName: existing.skillName,
|
|
1951
|
+
instructions,
|
|
1952
|
+
trigger,
|
|
1953
|
+
changeSummary,
|
|
1954
|
+
supersedesSkillId: id,
|
|
1955
|
+
}, ctx.actor.subject);
|
|
1956
|
+
sendJson(res, 200, { skill });
|
|
1957
|
+
}
|
|
1958
|
+
catch (e) {
|
|
1959
|
+
const msg = e.message;
|
|
1960
|
+
if (msg.includes('not found')) {
|
|
1961
|
+
throw new HttpError(404, msg);
|
|
1962
|
+
}
|
|
1963
|
+
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
1964
|
+
throw new HttpError(409, msg);
|
|
1965
|
+
}
|
|
1966
|
+
throw new HttpError(400, msg);
|
|
1967
|
+
}
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
const skillCloseMatch = path.match(/^\/v1\/skills\/(\d+)\/close$/);
|
|
1971
|
+
if (method === 'POST' && skillCloseMatch) {
|
|
1972
|
+
const id = parseInt(skillCloseMatch[1], 10);
|
|
1973
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1974
|
+
try {
|
|
1975
|
+
const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1976
|
+
sendJson(res, 200, { skill });
|
|
1977
|
+
}
|
|
1978
|
+
catch (e) {
|
|
1979
|
+
const msg = e.message;
|
|
1980
|
+
if (msg.includes('not found')) {
|
|
1981
|
+
throw new HttpError(404, msg);
|
|
1982
|
+
}
|
|
1983
|
+
if (msg.includes('not active')) {
|
|
1984
|
+
throw new HttpError(409, msg);
|
|
1985
|
+
}
|
|
1986
|
+
throw e;
|
|
1987
|
+
}
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const skillByIdMatch = path.match(/^\/v1\/skills\/(\d+)$/);
|
|
1991
|
+
if (method === 'GET' && skillByIdMatch) {
|
|
1992
|
+
const id = parseInt(skillByIdMatch[1], 10);
|
|
1993
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
1994
|
+
const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
|
|
1995
|
+
if (!skill) {
|
|
1996
|
+
throw new HttpError(404, `skill ${id} not found`);
|
|
1997
|
+
}
|
|
1998
|
+
sendJson(res, 200, { skill });
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
// ── E2 project_brief routes ──
|
|
2002
|
+
//
|
|
2003
|
+
// 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
|
|
2004
|
+
// /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
|
|
2005
|
+
// /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
|
|
2006
|
+
// from the repo's receipts; dryRun returns {markdown} without writing; ordered
|
|
2007
|
+
// before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
|
|
2008
|
+
// POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
|
|
2009
|
+
// changeSummary 4096. The store validates + throws; the boundary maps validation
|
|
2010
|
+
// -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
|
|
2011
|
+
if (method === 'POST' && path === '/v1/project-briefs') {
|
|
2012
|
+
const body = await parseJsonBody(req);
|
|
2013
|
+
const repo = body['repo'];
|
|
2014
|
+
if (typeof repo !== 'string' || repo.trim().length === 0) {
|
|
2015
|
+
throw new HttpError(400, 'repo is required (non-empty string)');
|
|
2016
|
+
}
|
|
2017
|
+
if (repo.length > 256) {
|
|
2018
|
+
throw new HttpError(400, 'repo exceeds 256-character cap');
|
|
2019
|
+
}
|
|
2020
|
+
const summary = body['summary'];
|
|
2021
|
+
if (typeof summary !== 'string' || summary.trim().length === 0) {
|
|
2022
|
+
throw new HttpError(400, 'summary is required (non-empty string)');
|
|
2023
|
+
}
|
|
2024
|
+
if (summary.length > 8192) {
|
|
2025
|
+
throw new HttpError(400, 'summary exceeds 8192-character cap');
|
|
2026
|
+
}
|
|
2027
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2028
|
+
try {
|
|
2029
|
+
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2030
|
+
repo,
|
|
2031
|
+
summary,
|
|
2032
|
+
}, ctx.actor.subject);
|
|
2033
|
+
sendJson(res, 201, { brief });
|
|
2034
|
+
}
|
|
2035
|
+
catch (e) {
|
|
2036
|
+
// saveProjectBrief throws on validation (single-line repo etc.) -> 400.
|
|
2037
|
+
throw new HttpError(400, e.message);
|
|
2038
|
+
}
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
if (method === 'GET' && path === '/v1/project-briefs') {
|
|
2042
|
+
const status = query.get('status') ?? 'all';
|
|
2043
|
+
const repoFilter = query.get('repo');
|
|
2044
|
+
const limit = parseListLimit(query.get('limit'));
|
|
2045
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2046
|
+
const listOpts = { limit };
|
|
2047
|
+
if (repoFilter !== null && repoFilter.trim().length > 0) {
|
|
2048
|
+
listOpts.repo = repoFilter.trim();
|
|
2049
|
+
}
|
|
2050
|
+
if (status !== 'all') {
|
|
2051
|
+
if (!VALID_BRIEF_STATES.has(status)) {
|
|
2052
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
2053
|
+
}
|
|
2054
|
+
listOpts.status = status;
|
|
2055
|
+
}
|
|
2056
|
+
const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
|
|
2057
|
+
sendJson(res, 200, { briefs });
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
// The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
|
|
2061
|
+
// so the /(\d+)/ routes would not match it, but order it first).
|
|
2062
|
+
if (method === 'POST' && path === '/v1/project-briefs/refresh') {
|
|
2063
|
+
const body = await parseJsonBody(req);
|
|
2064
|
+
const repo = body['repo'];
|
|
2065
|
+
if (typeof repo !== 'string' || repo.trim().length === 0) {
|
|
2066
|
+
throw new HttpError(400, 'repo is required (non-empty string)');
|
|
2067
|
+
}
|
|
2068
|
+
if (repo.length > 256) {
|
|
2069
|
+
throw new HttpError(400, 'repo exceeds 256-character cap');
|
|
2070
|
+
}
|
|
2071
|
+
const dryRun = body['dryRun'] === true;
|
|
2072
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2073
|
+
try {
|
|
2074
|
+
if (dryRun) {
|
|
2075
|
+
const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
|
|
2076
|
+
sendJson(res, 200, { markdown, receiptCount });
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
|
|
2080
|
+
sendJson(res, 200, { brief });
|
|
2081
|
+
}
|
|
2082
|
+
catch (e) {
|
|
2083
|
+
// A refresh race (the active brief is closed/superseded between
|
|
2084
|
+
// loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
|
|
2085
|
+
// validation error — map it to 409 like the explicit supersede route
|
|
2086
|
+
// (codex-review 2026-05-30, P3).
|
|
2087
|
+
const msg = e.message;
|
|
2088
|
+
if (msg.includes('not found')) {
|
|
2089
|
+
throw new HttpError(404, msg);
|
|
2090
|
+
}
|
|
2091
|
+
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2092
|
+
throw new HttpError(409, msg);
|
|
2093
|
+
}
|
|
2094
|
+
throw new HttpError(400, msg);
|
|
2095
|
+
}
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
const briefSupersedeMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/supersede$/);
|
|
2099
|
+
if (method === 'POST' && briefSupersedeMatch) {
|
|
2100
|
+
const id = parseInt(briefSupersedeMatch[1], 10);
|
|
2101
|
+
const body = await parseJsonBody(req);
|
|
2102
|
+
const summary = body['summary'];
|
|
2103
|
+
if (typeof summary !== 'string' || summary.trim().length === 0) {
|
|
2104
|
+
throw new HttpError(400, 'summary is required (non-empty string)');
|
|
2105
|
+
}
|
|
2106
|
+
if (summary.length > 8192) {
|
|
2107
|
+
throw new HttpError(400, 'summary exceeds 8192-character cap');
|
|
2108
|
+
}
|
|
2109
|
+
const changeRaw = body['changeSummary'];
|
|
2110
|
+
let changeSummary;
|
|
2111
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2112
|
+
if (typeof changeRaw !== 'string') {
|
|
2113
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
2114
|
+
}
|
|
2115
|
+
if (changeRaw.length > 4096) {
|
|
2116
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2117
|
+
}
|
|
2118
|
+
changeSummary = changeRaw;
|
|
2119
|
+
}
|
|
2120
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2121
|
+
const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
|
|
2122
|
+
if (!existing) {
|
|
2123
|
+
throw new HttpError(404, `project brief ${id} not found`);
|
|
2124
|
+
}
|
|
2125
|
+
try {
|
|
2126
|
+
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2127
|
+
repo: existing.repo,
|
|
2128
|
+
summary,
|
|
2129
|
+
changeSummary,
|
|
2130
|
+
supersedesBriefId: id,
|
|
2131
|
+
}, ctx.actor.subject);
|
|
2132
|
+
sendJson(res, 200, { brief });
|
|
2133
|
+
}
|
|
2134
|
+
catch (e) {
|
|
2135
|
+
const msg = e.message;
|
|
2136
|
+
if (msg.includes('not found')) {
|
|
2137
|
+
throw new HttpError(404, msg);
|
|
2138
|
+
}
|
|
2139
|
+
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2140
|
+
throw new HttpError(409, msg);
|
|
2141
|
+
}
|
|
2142
|
+
throw new HttpError(400, msg);
|
|
2143
|
+
}
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
const briefCloseMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/close$/);
|
|
2147
|
+
if (method === 'POST' && briefCloseMatch) {
|
|
2148
|
+
const id = parseInt(briefCloseMatch[1], 10);
|
|
2149
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2150
|
+
try {
|
|
2151
|
+
const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2152
|
+
sendJson(res, 200, { brief });
|
|
2153
|
+
}
|
|
2154
|
+
catch (e) {
|
|
2155
|
+
const msg = e.message;
|
|
2156
|
+
if (msg.includes('not found')) {
|
|
2157
|
+
throw new HttpError(404, msg);
|
|
2158
|
+
}
|
|
2159
|
+
if (msg.includes('not active')) {
|
|
2160
|
+
throw new HttpError(409, msg);
|
|
2161
|
+
}
|
|
2162
|
+
throw e;
|
|
2163
|
+
}
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
const briefByIdMatch = path.match(/^\/v1\/project-briefs\/(\d+)$/);
|
|
2167
|
+
if (method === 'GET' && briefByIdMatch) {
|
|
2168
|
+
const id = parseInt(briefByIdMatch[1], 10);
|
|
2169
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2170
|
+
const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
|
|
2171
|
+
if (!brief) {
|
|
2172
|
+
throw new HttpError(404, `project brief ${id} not found`);
|
|
2173
|
+
}
|
|
2174
|
+
sendJson(res, 200, { brief });
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
// ── E2 customer_note routes ──
|
|
2178
|
+
//
|
|
2179
|
+
// 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
|
|
2180
|
+
// note), GET /v1/customer-notes (list; status + customer filter; shared
|
|
2181
|
+
// parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
|
|
2182
|
+
// POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
|
|
2183
|
+
// changeSummary 4096. The store validates + throws; the boundary maps validation ->
|
|
2184
|
+
// 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
|
|
2185
|
+
if (method === 'POST' && path === '/v1/customer-notes') {
|
|
2186
|
+
const body = await parseJsonBody(req);
|
|
2187
|
+
const customer = body['customer'];
|
|
2188
|
+
if (typeof customer !== 'string' || customer.trim().length === 0) {
|
|
2189
|
+
throw new HttpError(400, 'customer is required (non-empty string)');
|
|
2190
|
+
}
|
|
2191
|
+
if (customer.length > 256) {
|
|
2192
|
+
throw new HttpError(400, 'customer exceeds 256-character cap');
|
|
2193
|
+
}
|
|
2194
|
+
const note = body['note'];
|
|
2195
|
+
if (typeof note !== 'string' || note.trim().length === 0) {
|
|
2196
|
+
throw new HttpError(400, 'note is required (non-empty string)');
|
|
2197
|
+
}
|
|
2198
|
+
if (note.length > 8192) {
|
|
2199
|
+
throw new HttpError(400, 'note exceeds 8192-character cap');
|
|
2200
|
+
}
|
|
2201
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2202
|
+
try {
|
|
2203
|
+
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2204
|
+
customer,
|
|
2205
|
+
note,
|
|
2206
|
+
}, ctx.actor.subject);
|
|
2207
|
+
sendJson(res, 201, { note: customerNote });
|
|
2208
|
+
}
|
|
2209
|
+
catch (e) {
|
|
2210
|
+
// saveCustomerNote throws on validation (single-line customer etc.) -> 400.
|
|
2211
|
+
throw new HttpError(400, e.message);
|
|
2212
|
+
}
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
if (method === 'GET' && path === '/v1/customer-notes') {
|
|
2216
|
+
const status = query.get('status') ?? 'all';
|
|
2217
|
+
const customerFilter = query.get('customer');
|
|
2218
|
+
const limit = parseListLimit(query.get('limit'));
|
|
2219
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2220
|
+
const listOpts = { limit };
|
|
2221
|
+
if (customerFilter !== null && customerFilter.trim().length > 0) {
|
|
2222
|
+
listOpts.customer = customerFilter.trim();
|
|
2223
|
+
}
|
|
2224
|
+
if (status !== 'all') {
|
|
2225
|
+
if (!VALID_NOTE_STATES.has(status)) {
|
|
2226
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
2227
|
+
}
|
|
2228
|
+
listOpts.status = status;
|
|
2229
|
+
}
|
|
2230
|
+
const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
|
|
2231
|
+
sendJson(res, 200, { notes });
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
const noteSupersedeMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/supersede$/);
|
|
2235
|
+
if (method === 'POST' && noteSupersedeMatch) {
|
|
2236
|
+
const id = parseInt(noteSupersedeMatch[1], 10);
|
|
2237
|
+
const body = await parseJsonBody(req);
|
|
2238
|
+
const note = body['note'];
|
|
2239
|
+
if (typeof note !== 'string' || note.trim().length === 0) {
|
|
2240
|
+
throw new HttpError(400, 'note is required (non-empty string)');
|
|
2241
|
+
}
|
|
2242
|
+
if (note.length > 8192) {
|
|
2243
|
+
throw new HttpError(400, 'note exceeds 8192-character cap');
|
|
2244
|
+
}
|
|
2245
|
+
const changeRaw = body['changeSummary'];
|
|
2246
|
+
let changeSummary;
|
|
2247
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2248
|
+
if (typeof changeRaw !== 'string') {
|
|
2249
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
2250
|
+
}
|
|
2251
|
+
if (changeRaw.length > 4096) {
|
|
2252
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2253
|
+
}
|
|
2254
|
+
changeSummary = changeRaw;
|
|
2255
|
+
}
|
|
2256
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2257
|
+
const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
|
|
2258
|
+
if (!existing) {
|
|
2259
|
+
throw new HttpError(404, `customer note ${id} not found`);
|
|
2260
|
+
}
|
|
2261
|
+
try {
|
|
2262
|
+
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2263
|
+
customer: existing.customer,
|
|
2264
|
+
note,
|
|
2265
|
+
changeSummary,
|
|
2266
|
+
supersedesNoteId: id,
|
|
2267
|
+
}, ctx.actor.subject);
|
|
2268
|
+
sendJson(res, 200, { note: customerNote });
|
|
2269
|
+
}
|
|
2270
|
+
catch (e) {
|
|
2271
|
+
const msg = e.message;
|
|
2272
|
+
if (msg.includes('not found')) {
|
|
2273
|
+
throw new HttpError(404, msg);
|
|
2274
|
+
}
|
|
2275
|
+
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2276
|
+
throw new HttpError(409, msg);
|
|
2277
|
+
}
|
|
2278
|
+
throw new HttpError(400, msg);
|
|
2279
|
+
}
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
const noteCloseMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/close$/);
|
|
2283
|
+
if (method === 'POST' && noteCloseMatch) {
|
|
2284
|
+
const id = parseInt(noteCloseMatch[1], 10);
|
|
2285
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2286
|
+
try {
|
|
2287
|
+
const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2288
|
+
sendJson(res, 200, { note: customerNote });
|
|
2289
|
+
}
|
|
2290
|
+
catch (e) {
|
|
2291
|
+
const msg = e.message;
|
|
2292
|
+
if (msg.includes('not found')) {
|
|
2293
|
+
throw new HttpError(404, msg);
|
|
2294
|
+
}
|
|
2295
|
+
if (msg.includes('not active')) {
|
|
2296
|
+
throw new HttpError(409, msg);
|
|
2297
|
+
}
|
|
2298
|
+
throw e;
|
|
2299
|
+
}
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
const noteByIdMatch = path.match(/^\/v1\/customer-notes\/(\d+)$/);
|
|
2303
|
+
if (method === 'GET' && noteByIdMatch) {
|
|
2304
|
+
const id = parseInt(noteByIdMatch[1], 10);
|
|
2305
|
+
const ctx = buildContextWithAuth(req, opts.hippoRoot);
|
|
2306
|
+
const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
|
|
2307
|
+
if (!customerNote) {
|
|
2308
|
+
throw new HttpError(404, `customer note ${id} not found`);
|
|
2309
|
+
}
|
|
2310
|
+
sendJson(res, 200, { note: customerNote });
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
1140
2313
|
// ── POST /v1/connectors/slack/events ──
|
|
1141
2314
|
//
|
|
1142
2315
|
// Slack Events API webhook. Auth is signature-based (HMAC over the raw
|