hippo-memory 1.56.0 → 1.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +21 -14
- package/dist/api.js +97 -71
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +11 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/card-detail.d.ts +1 -1
- package/dist/card-detail.js +1 -1
- package/dist/cli/shared.d.ts +137 -0
- package/dist/cli/shared.js +834 -0
- package/dist/cli/sleep.d.ts +10 -0
- package/dist/cli/sleep.js +171 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +322 -1827
- 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/github/webhook.d.ts +19 -0
- package/dist/connectors/github/webhook.js +313 -0
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/connectors/slack/webhook.d.ts +22 -0
- package/dist/connectors/slack/webhook.js +203 -0
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +38 -35
- package/dist/context-auto.d.ts +3 -0
- package/dist/context-auto.js +34 -0
- package/dist/customer-notes.js +16 -14
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +3 -2
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +11 -9
- package/dist/doctor.js +5 -0
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/eval-stats.d.ts +58 -0
- package/dist/eval-stats.js +111 -0
- package/dist/extract.js +3 -2
- package/dist/goals.d.ts +49 -25
- package/dist/goals.js +39 -22
- package/dist/graph-extract.js +1 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +1 -1
- package/dist/graph.js +1 -1
- package/dist/hooks.d.ts +1 -3
- package/dist/hooks.js +2 -4
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +39 -0
- package/dist/http-util.js +56 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +13 -11
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +224 -308
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.d.ts +19 -0
- package/dist/memory.js +41 -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 +14 -12
- package/dist/predictions.js +11 -9
- package/dist/processes.js +16 -14
- package/dist/project-briefs.js +19 -16
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/prompt-recall.js +1 -1
- package/dist/raw-archive.js +7 -6
- package/dist/recall-history.d.ts +5 -0
- package/dist/recall-history.js +9 -0
- package/dist/recall-pipeline.d.ts +101 -0
- package/dist/recall-pipeline.js +313 -0
- package/dist/recall-scope.d.ts +24 -1
- package/dist/recall-scope.js +29 -2
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/search.d.ts +0 -20
- package/dist/search.js +16 -51
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1854 -2566
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +17 -15
- package/dist/store-cards.d.ts +53 -0
- package/dist/store-cards.js +512 -0
- package/dist/store.d.ts +2 -89
- package/dist/store.js +10 -566
- package/dist/tenant.d.ts +22 -0
- package/dist/tenant.js +26 -0
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { resolveProjectIdentity } from './project-identity.js';
|
|
5
5
|
import { assembleCost, contextCost, drillCost } from './context-render.js';
|
|
@@ -7,8 +7,8 @@ import { detectServer, writePidfile, removePidfileIfOwned } from './server-detec
|
|
|
7
7
|
import { resolveTenantId } from './tenant.js';
|
|
8
8
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
9
9
|
import { updateStats } from './store.js';
|
|
10
|
-
import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, } from './recall-history.js';
|
|
11
|
-
import { appendAuditEvent, auditQueryFields, AUDIT_OPS } from './audit.js';
|
|
10
|
+
import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, biasHintEnabled, } from './recall-history.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';
|
|
@@ -35,19 +36,10 @@ import { saveSkill, closeSkill, loadSkillById, loadSkills, exportSkills, VALID_S
|
|
|
35
36
|
import { saveProjectBrief, closeProjectBrief, loadProjectBriefById, loadProjectBriefs, assembleBriefFromReceipts, refreshBrief, VALID_BRIEF_STATES, } from './project-briefs.js';
|
|
36
37
|
import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomerNotes, VALID_NOTE_STATES, } from './customer-notes.js';
|
|
37
38
|
import { handleMcpRequest } from './mcp/server.js';
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import { writeToDlq } from './connectors/slack/dlq.js';
|
|
43
|
-
import { resolveTenantForTeam } from './connectors/slack/tenant-routing.js';
|
|
44
|
-
import { verifyGitHubSignature } from './connectors/github/signature.js';
|
|
45
|
-
import { isGitHubWebhookEnvelope, isGitHubIssueEvent, isGitHubIssueCommentEvent, isGitHubPullRequestEvent, isGitHubPullRequestReviewCommentEvent, } from './connectors/github/types.js';
|
|
46
|
-
import { ingestEvent as ingestGitHubEvent } from './connectors/github/ingest.js';
|
|
47
|
-
import { handleCommentDeleted as handleGitHubCommentDeleted } from './connectors/github/deletion.js';
|
|
48
|
-
import { writeToDlq as writeToGitHubDlq } from './connectors/github/dlq.js';
|
|
49
|
-
import { resolveTenantForGitHub } from './connectors/github/tenant-routing.js';
|
|
50
|
-
import { computeDeletionKey as computeGitHubDeletionKey } from './connectors/github/signature.js';
|
|
39
|
+
import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
|
|
40
|
+
import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
|
|
41
|
+
import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, mapApiError, readBody, sendJson, } from './http-util.js';
|
|
42
|
+
import { NotFoundError } from './api-errors.js';
|
|
51
43
|
// Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
|
|
52
44
|
// New unauth routes MUST be added here AND get a corresponding entry in
|
|
53
45
|
// tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
|
|
@@ -78,14 +70,6 @@ function isJsonNumber(value) {
|
|
|
78
70
|
function isJsonBoolean(value) {
|
|
79
71
|
return typeof value === 'boolean';
|
|
80
72
|
}
|
|
81
|
-
function isJsonObjectRecord(value) {
|
|
82
|
-
return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
83
|
-
}
|
|
84
|
-
// node:http header values are `string | string[] | undefined` (never a bare
|
|
85
|
-
// unknown), so this gets its own predicate rather than reusing isJsonString.
|
|
86
|
-
function isHeaderString(value) {
|
|
87
|
-
return typeof value === 'string';
|
|
88
|
-
}
|
|
89
73
|
// server.address() returns AddressInfo once a TCP socket is bound; null before
|
|
90
74
|
// listening, a string only for pipe/unix-socket listeners (never used here).
|
|
91
75
|
function isAddressInfo(a) {
|
|
@@ -173,47 +157,26 @@ const VALID_KINDS = new Set([
|
|
|
173
157
|
// avoid for tests that mkdtemp a hippoRoot.
|
|
174
158
|
// v1.3.1: source from src/version.ts so /health no longer reports stale 0.39.0.
|
|
175
159
|
const VERSION = PACKAGE_VERSION;
|
|
176
|
-
// 1 MB body cap. The CLI never sends payloads near this; anything bigger is
|
|
177
|
-
// almost certainly a misconfigured client or a deliberate memory-blowup attempt.
|
|
178
|
-
const MAX_BODY_BYTES = 1024 * 1024;
|
|
179
160
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
161
|
+
// The caller's id lands in a response header and in logs, so only a short plain token is echoed back.
|
|
162
|
+
const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
163
|
+
/** The caller's `X-Request-Id` when it is a short plain token, else a fresh UUID. */
|
|
164
|
+
function resolveRequestId(header) {
|
|
165
|
+
const value = Array.isArray(header) ? undefined : header;
|
|
166
|
+
return value && REQUEST_ID_RE.test(value) ? value : randomUUID();
|
|
187
167
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
168
|
+
/** One line per failed request; 4xx is the caller's mistake, so it stays below the default level. */
|
|
169
|
+
function logRequestFailure(req, err, requestId, status) {
|
|
170
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
171
|
+
const line = `${req.method ?? 'GET'} ${(req.url ?? '/').split('?')[0]} failed: ${message}`;
|
|
172
|
+
if (status >= 500)
|
|
173
|
+
log.error(line, { requestId, status });
|
|
174
|
+
else
|
|
175
|
+
log.info(line, { requestId, status });
|
|
193
176
|
}
|
|
194
177
|
function sendError(res, status, message) {
|
|
195
178
|
sendJson(res, status, { error: message });
|
|
196
179
|
}
|
|
197
|
-
/**
|
|
198
|
-
* Read the entire request body into a Buffer. Caps at MAX_BODY_BYTES to keep
|
|
199
|
-
* a malicious or buggy client from exhausting memory. The cap is enforced
|
|
200
|
-
* mid-stream so we don't wait for an attacker to finish before erroring out.
|
|
201
|
-
*/
|
|
202
|
-
async function readBody(req) {
|
|
203
|
-
const chunks = [];
|
|
204
|
-
let total = 0;
|
|
205
|
-
for await (const chunk of req) {
|
|
206
|
-
// SAFETY: IncomingMessage never runs setEncoding() here, so every
|
|
207
|
-
// streamed chunk is a Buffer, not a decoded string.
|
|
208
|
-
const buf = chunk;
|
|
209
|
-
total += buf.length;
|
|
210
|
-
if (total > MAX_BODY_BYTES) {
|
|
211
|
-
throw new BodyTooLargeError('request body exceeds 1MB');
|
|
212
|
-
}
|
|
213
|
-
chunks.push(buf);
|
|
214
|
-
}
|
|
215
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
216
|
-
}
|
|
217
180
|
async function parseJsonBody(req) {
|
|
218
181
|
const raw = await readBody(req);
|
|
219
182
|
if (raw.length === 0)
|
|
@@ -231,35 +194,17 @@ async function parseJsonBody(req) {
|
|
|
231
194
|
throw new HttpError(400, 'invalid JSON body');
|
|
232
195
|
}
|
|
233
196
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
* - /not found/i → 404 (forget on unknown id, supersede on unknown old id, etc.)
|
|
239
|
-
* - /unknown/i → 404 (auth_revoke on unknown key_id)
|
|
240
|
-
* - /already superseded/i → 409 (chain conflict)
|
|
241
|
-
* - /not raw/i → 400 (archive_raw on non-raw row)
|
|
242
|
-
* ForbiddenError maps to 403; everything else to 400 (bad input).
|
|
243
|
-
*/
|
|
244
|
-
function mapApiError(err) {
|
|
245
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
246
|
-
if (err instanceof ForbiddenError) {
|
|
247
|
-
return { status: 403, message };
|
|
248
|
-
}
|
|
249
|
-
const lower = message.toLowerCase();
|
|
250
|
-
if (/not found/.test(lower) || /^unknown /.test(lower)) {
|
|
251
|
-
return { status: 404, message };
|
|
252
|
-
}
|
|
253
|
-
if (/already superseded/.test(lower)) {
|
|
254
|
-
return { status: 409, message };
|
|
197
|
+
function parseRequest(req) {
|
|
198
|
+
let url;
|
|
199
|
+
try {
|
|
200
|
+
url = new URL(req.url ?? '/', 'http://placeholder');
|
|
255
201
|
}
|
|
256
|
-
|
|
257
|
-
|
|
202
|
+
catch (e) {
|
|
203
|
+
// A request target the URL parser rejects is the caller's fault, not a server failure.
|
|
204
|
+
if (e instanceof TypeError)
|
|
205
|
+
throw new HttpError(400, e.message);
|
|
206
|
+
throw e;
|
|
258
207
|
}
|
|
259
|
-
return { status: 400, message };
|
|
260
|
-
}
|
|
261
|
-
function parseRequest(req) {
|
|
262
|
-
const url = new URL(req.url ?? '/', 'http://placeholder');
|
|
263
208
|
return {
|
|
264
209
|
method: req.method ?? 'GET',
|
|
265
210
|
path: url.pathname,
|
|
@@ -274,6 +219,16 @@ function parseRequest(req) {
|
|
|
274
219
|
* mapping each :param name to its value. Path segments are exact-matched
|
|
275
220
|
* except for parameter slots.
|
|
276
221
|
*/
|
|
222
|
+
function decodePathSegment(segment) {
|
|
223
|
+
try {
|
|
224
|
+
return decodeURIComponent(segment);
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
if (e instanceof URIError)
|
|
228
|
+
throw new HttpError(400, e.message);
|
|
229
|
+
throw e;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
277
232
|
function matchPath(pattern, path) {
|
|
278
233
|
const patternParts = pattern.split('/');
|
|
279
234
|
const pathParts = path.split('/');
|
|
@@ -286,7 +241,7 @@ function matchPath(pattern, path) {
|
|
|
286
241
|
if (pp.startsWith(':')) {
|
|
287
242
|
if (ap.length === 0)
|
|
288
243
|
return null;
|
|
289
|
-
params[pp.slice(1)] =
|
|
244
|
+
params[pp.slice(1)] = decodePathSegment(ap);
|
|
290
245
|
}
|
|
291
246
|
else if (pp !== ap) {
|
|
292
247
|
return null;
|
|
@@ -320,10 +275,22 @@ export function isCrossSite(req) {
|
|
|
320
275
|
const origin = req.headers.origin;
|
|
321
276
|
return origin !== undefined && origin !== `http://${req.headers.host}`;
|
|
322
277
|
}
|
|
278
|
+
// A proxy on this host (nginx, Caddy, cloudflared) connects from loopback, so these headers mean the caller is not local.
|
|
279
|
+
const PROXY_HEADERS = [
|
|
280
|
+
'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip',
|
|
281
|
+
];
|
|
282
|
+
// The auth helpers only see the request, so its id rides here for their log lines.
|
|
283
|
+
const requestIds = new WeakMap();
|
|
323
284
|
// A browser on this machine is loopback too, so the no-key fallback also needs a local Host and a same-site caller.
|
|
324
285
|
function assertLocalCaller(req) {
|
|
325
286
|
if (!isLoopback(req.socket.remoteAddress))
|
|
326
287
|
throw new HttpError(401, 'auth required');
|
|
288
|
+
const proxyHeader = PROXY_HEADERS.find((name) => req.headers[name] !== undefined);
|
|
289
|
+
if (proxyHeader !== undefined) {
|
|
290
|
+
log.warn(`proxied loopback request refused: it carries ${proxyHeader}, so the no-key local fallback does not apply. ` +
|
|
291
|
+
'Send an API key (hippo auth create, then Authorization: Bearer hk_...).', { requestId: requestIds.get(req) });
|
|
292
|
+
throw new HttpError(401, 'auth required');
|
|
293
|
+
}
|
|
327
294
|
const host = req.headers.host;
|
|
328
295
|
if ((host !== undefined && !LOOPBACK_HOST_HEADER.test(host)) || isCrossSite(req)) {
|
|
329
296
|
throw new HttpError(403, 'cross-site or non-local request refused; send an API key');
|
|
@@ -507,9 +474,8 @@ async function buildContextWithAuth(req, opts) {
|
|
|
507
474
|
actor.viaAuthResolver = true;
|
|
508
475
|
return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor };
|
|
509
476
|
}
|
|
510
|
-
// No Authorization header. Loopback-only fallback
|
|
511
|
-
//
|
|
512
|
-
// and by deployments that want to forbid the local-CLI escape hatch).
|
|
477
|
+
// No Authorization header. Loopback-only fallback for a direct local caller (no proxy headers),
|
|
478
|
+
// unless HIPPO_REQUIRE_AUTH=1 forbids the local-CLI escape hatch.
|
|
513
479
|
if (process.env.HIPPO_REQUIRE_AUTH === '1') {
|
|
514
480
|
throw new HttpError(401, 'auth required');
|
|
515
481
|
}
|
|
@@ -609,6 +575,1727 @@ function validateIdSegment(id, fieldName) {
|
|
|
609
575
|
throw new HttpError(400, `${fieldName} contains invalid characters; allowed: A-Z a-z 0-9 _ : . -`);
|
|
610
576
|
}
|
|
611
577
|
}
|
|
578
|
+
// POST /v1/memories
|
|
579
|
+
async function handleCreateMemory({ req, res, opts }) {
|
|
580
|
+
const body = await parseJsonBody(req);
|
|
581
|
+
const content = getString(body, 'content');
|
|
582
|
+
if (!content) {
|
|
583
|
+
throw new HttpError(400, 'content is required');
|
|
584
|
+
}
|
|
585
|
+
const kindRaw = getString(body, 'kind');
|
|
586
|
+
if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
|
|
587
|
+
throw new HttpError(400, `invalid kind: ${kindRaw}`);
|
|
588
|
+
}
|
|
589
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
590
|
+
const result = remember(ctx, {
|
|
591
|
+
content,
|
|
592
|
+
kind: kindRaw,
|
|
593
|
+
scope: getString(body, 'scope'),
|
|
594
|
+
owner: getString(body, 'owner'),
|
|
595
|
+
artifactRef: getString(body, 'artifactRef'),
|
|
596
|
+
tags: getStringArray(body, 'tags'),
|
|
597
|
+
});
|
|
598
|
+
sendJson(res, 200, result);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
// GET /v1/graph?entity=NAME&limit=N — read-only entity/relation graph (tenant-scoped)
|
|
602
|
+
async function handleGetGraph({ req, res, opts, query }) {
|
|
603
|
+
const entityRaw = query.get('entity');
|
|
604
|
+
// Cap at the graph entity-name cap (512), not the id-shaped 256, so a valid
|
|
605
|
+
// long decision/policy name remains focusable over HTTP (codex P2).
|
|
606
|
+
if (entityRaw !== null && entityRaw.length > MAX_ENTITY_NAME_LEN) {
|
|
607
|
+
throw new HttpError(400, `entity exceeds the ${MAX_ENTITY_NAME_LEN}-character cap`);
|
|
608
|
+
}
|
|
609
|
+
const limit = parseListLimit(query.get('limit'));
|
|
610
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
611
|
+
const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
|
|
612
|
+
entity: entityRaw ?? undefined,
|
|
613
|
+
limit,
|
|
614
|
+
});
|
|
615
|
+
sendJson(res, 200, model);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
// GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
|
|
619
|
+
async function handleRecallMemories({ req, res, opts, query }) {
|
|
620
|
+
const q = query.get('q');
|
|
621
|
+
if (!q) {
|
|
622
|
+
throw new HttpError(400, 'q is required');
|
|
623
|
+
}
|
|
624
|
+
const limitRaw = query.get('limit');
|
|
625
|
+
const limit = limitRaw === null ? undefined : parseListLimit(limitRaw);
|
|
626
|
+
const mode = query.get('mode');
|
|
627
|
+
if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
|
|
628
|
+
throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
|
|
629
|
+
}
|
|
630
|
+
const scope = query.get('scope');
|
|
631
|
+
const includeContinuityRaw = query.get('include_continuity');
|
|
632
|
+
const includeContinuity = includeContinuityRaw === '1'
|
|
633
|
+
|| includeContinuityRaw === 'true';
|
|
634
|
+
// v1.6.2: surface the v1.5.0/v1.5.2 RecallOpts additions to HTTP
|
|
635
|
+
// callers. Pre-v1.6.2 the route silently ignored these so the
|
|
636
|
+
// session-scoped fresh-tail and summary substitution were JS-only.
|
|
637
|
+
const freshTailCountRaw = query.get('fresh_tail_count');
|
|
638
|
+
const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
|
|
639
|
+
if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
|
|
640
|
+
throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
|
|
641
|
+
}
|
|
642
|
+
// v1.6.3 senior-review P1-3: cap session_id length consistent with the
|
|
643
|
+
// rest of the API. Untrimmed strings round-trip through the SQL layer
|
|
644
|
+
// and through any downstream metric/log; 256 is generous for a session
|
|
645
|
+
// id and matches the rest of this file's id-shaped param parsers.
|
|
646
|
+
const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
|
|
647
|
+
if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
|
|
648
|
+
throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
|
|
649
|
+
}
|
|
650
|
+
const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
|
|
651
|
+
? freshTailSessionIdRaw
|
|
652
|
+
: undefined;
|
|
653
|
+
// v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
|
|
654
|
+
// convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
|
|
655
|
+
// so `?summarize_overflow=banana` and `?summarize_overflow=` both
|
|
656
|
+
// turned it on. Surface convention drift fixed.
|
|
657
|
+
const summarizeOverflowRaw = query.get('summarize_overflow');
|
|
658
|
+
const summarizeOverflow = summarizeOverflowRaw === null
|
|
659
|
+
? undefined
|
|
660
|
+
: (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
|
|
661
|
+
// recall() owns the shape rule (NaN, 0 and negatives throw invalid_scorer_window); the transport caps remote cost.
|
|
662
|
+
const scorerWindowRaw = query.get('scorer_window');
|
|
663
|
+
const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
|
|
664
|
+
if (scorerWindow !== undefined && scorerWindow > 1000) {
|
|
665
|
+
throw new HttpError(400, 'scorer_window must be <= 1000');
|
|
666
|
+
}
|
|
667
|
+
// v1.7.4: session_id for the dlPFC goal-stack boost. 256-char cap mirrors
|
|
668
|
+
// fresh_tail_session_id (above). Trim then drop if empty so api.recall
|
|
669
|
+
// sees undefined when the param is omitted or whitespace-only.
|
|
670
|
+
const sessionIdRaw = query.get('session_id');
|
|
671
|
+
if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
|
|
672
|
+
throw new HttpError(400, 'session_id exceeds 256-character cap');
|
|
673
|
+
}
|
|
674
|
+
const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
|
|
675
|
+
? sessionIdRaw.trim()
|
|
676
|
+
: undefined;
|
|
677
|
+
// A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
|
|
678
|
+
// lifecycle re-ranking trace (goal-boost step on the api pipeline) +
|
|
679
|
+
// rerankPipeline:'api' to each result item; the field then rides on the
|
|
680
|
+
// serialized RecallResult. Mirrors the include_continuity convention.
|
|
681
|
+
const explainRaw = query.get('explain');
|
|
682
|
+
const explain = explainRaw === '1' || explainRaw === 'true';
|
|
683
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
684
|
+
// v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
|
|
685
|
+
// ring snapshot via opts.recallHistory so api.recall's own
|
|
686
|
+
// anchoringHint compute path activates. Unlike CLI (which computes
|
|
687
|
+
// its own hint separately because cmdRecall runs its own physics/
|
|
688
|
+
// hybrid pipeline outside api.recall), HTTP's /v1/memories response
|
|
689
|
+
// body IS api.recall's result directly. So the api.recall-computed
|
|
690
|
+
// hint flows through. HIPPO_ANCHORING=off short-circuits.
|
|
691
|
+
let httpRecallHistory;
|
|
692
|
+
let httpRingKey;
|
|
693
|
+
if (biasHintEnabled('anchoring')) {
|
|
694
|
+
if (sessionId) {
|
|
695
|
+
// Codex round-5 P2 catch: do NOT mutate sessionRecallHistoryHttp
|
|
696
|
+
// before recall() preflight runs. A request with an invalid
|
|
697
|
+
// scorer_window / fresh_tail_count would create-or-touch the
|
|
698
|
+
// session ring (LRU-evicting valid sessions) even though recall
|
|
699
|
+
// throws 400. Snapshot the EXISTING ring if present; only
|
|
700
|
+
// create-or-touch after the recall returns successfully.
|
|
701
|
+
httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
|
|
702
|
+
const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
|
|
703
|
+
httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
// Telemetry: caller had no session_id so ring tracking skipped.
|
|
707
|
+
// Per the normal recall-audit convention (api.ts:854 stores
|
|
708
|
+
// SHA-256/16 hash of the query, NOT raw text), avoid retaining
|
|
709
|
+
// prompts in audit_log here too — query content can contain
|
|
710
|
+
// secrets, PII, or RTBF-restricted material. Codex round-2 P2
|
|
711
|
+
// catch: hashQueryText is a 32-bit FNV-1a designed for recall
|
|
712
|
+
// matching, NOT a privacy hash; brute-force trivial for low-
|
|
713
|
+
// entropy queries. Use the same SHA-256/16 truncation as the
|
|
714
|
+
// canonical recall audit.
|
|
715
|
+
const dbForAudit = openHippoDb(opts.hippoRoot);
|
|
716
|
+
try {
|
|
717
|
+
appendAuditEvent(dbForAudit, {
|
|
718
|
+
tenantId: ctx.tenantId,
|
|
719
|
+
actor: ctx.actor.subject,
|
|
720
|
+
op: 'recall_anchor_skipped_no_session',
|
|
721
|
+
targetId: undefined,
|
|
722
|
+
metadata: auditQueryFields(q),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
closeHippoDb(dbForAudit);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const recallExtra = {};
|
|
731
|
+
if (freshTailCount !== undefined)
|
|
732
|
+
recallExtra.freshTailCount = freshTailCount;
|
|
733
|
+
if (freshTailSessionId !== undefined)
|
|
734
|
+
recallExtra.freshTailSessionId = freshTailSessionId;
|
|
735
|
+
if (summarizeOverflow !== undefined)
|
|
736
|
+
recallExtra.summarizeOverflow = summarizeOverflow;
|
|
737
|
+
if (scorerWindow !== undefined)
|
|
738
|
+
recallExtra.scorerWindow = scorerWindow;
|
|
739
|
+
if (sessionId !== undefined)
|
|
740
|
+
recallExtra.sessionId = sessionId;
|
|
741
|
+
if (httpRecallHistory !== undefined)
|
|
742
|
+
recallExtra.recallHistory = httpRecallHistory;
|
|
743
|
+
if (explain)
|
|
744
|
+
recallExtra.explain = explain;
|
|
745
|
+
const result = await retrieve(ctx, {
|
|
746
|
+
query: q,
|
|
747
|
+
limit,
|
|
748
|
+
mode: mode ?? undefined,
|
|
749
|
+
scope: scope ?? undefined,
|
|
750
|
+
includeContinuity,
|
|
751
|
+
...recallExtra,
|
|
752
|
+
});
|
|
753
|
+
// v0.33 / J1 — append AFTER recall completes (snapshot was taken before
|
|
754
|
+
// recall() ran). anchoredOn carries the memoryId of any hint that fired
|
|
755
|
+
// (api.recall computed it from the same snapshot we passed in), feeding
|
|
756
|
+
// the cooldown logic for the NEXT recall on this session.
|
|
757
|
+
// Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
|
|
758
|
+
// returns successfully. Invalid requests that throw 400 in recall()
|
|
759
|
+
// never reach this point, so they cannot LRU-evict valid sessions.
|
|
760
|
+
if (httpRingKey) {
|
|
761
|
+
const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
|
|
762
|
+
const topId = result.results[0]?.id ?? null;
|
|
763
|
+
appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
|
|
764
|
+
}
|
|
765
|
+
// Each recall surface counts its own hits; api.recall is no chokepoint,
|
|
766
|
+
// since the CLI never calls it and MCP shows the user a different band.
|
|
767
|
+
updateStats(opts.hippoRoot, { recalled: result.results.length });
|
|
768
|
+
// Continuity payloads should never be cached. The caller is asking for
|
|
769
|
+
// session-state-aware data; intermediaries must not reuse it across users.
|
|
770
|
+
if (includeContinuity) {
|
|
771
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
772
|
+
}
|
|
773
|
+
recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
|
|
774
|
+
sendJson(res, 200, result);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
// GET /v1/sessions/:id/assemble?budget=N&freshTail=N&summarizeOlder=0|1
|
|
778
|
+
// Phase 2 context-engine API. Returns ordered AssembledContextItem[]
|
|
779
|
+
// with fresh-tail raws + summary substitutions + bio-aware budget fit.
|
|
780
|
+
// Tenant scope from Bearer; default-deny on private rows.
|
|
781
|
+
async function handleAssembleSession({ req, res, opts, query }, assembleMatch) {
|
|
782
|
+
validateIdSegment(assembleMatch.id, 'session id');
|
|
783
|
+
const budgetRaw = query.get('budget');
|
|
784
|
+
const budget = budgetRaw === null ? undefined : Number(budgetRaw);
|
|
785
|
+
if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
|
|
786
|
+
throw new HttpError(400, 'budget must be a positive number');
|
|
787
|
+
}
|
|
788
|
+
const ftRaw = query.get('freshTail');
|
|
789
|
+
const freshTailCount = ftRaw === null ? undefined : Number(ftRaw);
|
|
790
|
+
if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
|
|
791
|
+
throw new HttpError(400, 'freshTail must be a non-negative number');
|
|
792
|
+
}
|
|
793
|
+
// v1.6.3 senior review P1: same strict-parse convention as the v1.6.3
|
|
794
|
+
// summarize_overflow tighten on /v1/memories. Pre-v1.6.3 accepted any
|
|
795
|
+
// non-'0'/'false' as true; ?summarizeOlder=banana now correctly returns
|
|
796
|
+
// false (matches includeContinuity convention).
|
|
797
|
+
const sumOlderRaw = query.get('summarizeOlder');
|
|
798
|
+
const summarizeOlder = sumOlderRaw === null
|
|
799
|
+
? undefined
|
|
800
|
+
: (sumOlderRaw === '1' || sumOlderRaw === 'true');
|
|
801
|
+
const scopeQ = query.get('scope');
|
|
802
|
+
const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
|
|
803
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
804
|
+
const assembleExtra = {};
|
|
805
|
+
if (budget !== undefined)
|
|
806
|
+
assembleExtra.budget = budget;
|
|
807
|
+
if (freshTailCount !== undefined)
|
|
808
|
+
assembleExtra.freshTailCount = freshTailCount;
|
|
809
|
+
if (summarizeOlder !== undefined)
|
|
810
|
+
assembleExtra.summarizeOlder = summarizeOlder;
|
|
811
|
+
if (scope !== undefined)
|
|
812
|
+
assembleExtra.scope = scope;
|
|
813
|
+
const result = assemble(ctx, assembleMatch.id, { ...assembleExtra, cost: assembleCost(assembleMatch.id) });
|
|
814
|
+
recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
|
|
815
|
+
sendJson(res, 200, result);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// GET /v1/recall/drill/:id?limit=N&budget=N
|
|
819
|
+
// Companion to /v1/memories. When recall surfaces a level-2 summary in
|
|
820
|
+
// place of overflowed children (RecallResultItem.isSummary === true), the
|
|
821
|
+
// caller drills into the summary id to recover the originals. Tenant
|
|
822
|
+
// scoped via Bearer; default-deny on private scopes for both summary
|
|
823
|
+
// and children.
|
|
824
|
+
async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
|
|
825
|
+
validateIdSegment(drillMatch.id, 'summary id');
|
|
826
|
+
const limitRaw = query.get('limit');
|
|
827
|
+
const limit = limitRaw === null ? undefined : Number(limitRaw);
|
|
828
|
+
if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
|
|
829
|
+
throw new HttpError(400, 'limit must be a positive number');
|
|
830
|
+
}
|
|
831
|
+
const budgetRaw = query.get('budget');
|
|
832
|
+
const budget = budgetRaw === null ? undefined : Number(budgetRaw);
|
|
833
|
+
if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
|
|
834
|
+
throw new HttpError(400, 'budget must be a positive number');
|
|
835
|
+
}
|
|
836
|
+
// v0.30 / E5: depth query param walks N levels (default 1, hard cap 10).
|
|
837
|
+
const depthRaw = query.get('depth');
|
|
838
|
+
let depth;
|
|
839
|
+
if (depthRaw !== null) {
|
|
840
|
+
const parsed = Number(depthRaw);
|
|
841
|
+
// L4 fold: reject out-of-range explicitly (no silent clamp).
|
|
842
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
|
|
843
|
+
throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
|
|
844
|
+
}
|
|
845
|
+
depth = parsed;
|
|
846
|
+
}
|
|
847
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
848
|
+
const drillExtra = {};
|
|
849
|
+
if (limit !== undefined)
|
|
850
|
+
drillExtra.limit = limit;
|
|
851
|
+
if (budget !== undefined)
|
|
852
|
+
drillExtra.budget = budget;
|
|
853
|
+
if (depth !== undefined)
|
|
854
|
+
drillExtra.depth = depth;
|
|
855
|
+
const result = drillDown(ctx, drillMatch.id, { ...drillExtra, cost: drillCost });
|
|
856
|
+
if ('failure' in result) {
|
|
857
|
+
// v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
|
|
858
|
+
// as 404 to avoid leaking cross-tenant existence or scope grants.
|
|
859
|
+
if (result.failure === 'not_drillable') {
|
|
860
|
+
throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
|
|
861
|
+
}
|
|
862
|
+
throw new HttpError(404, 'No drillable summary at this id');
|
|
863
|
+
}
|
|
864
|
+
sendJson(res, 200, result);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
// /v1/memories/:id/* and DELETE /v1/memories/:id
|
|
868
|
+
async function handleArchiveMemory({ req, res, opts }, archiveMatch) {
|
|
869
|
+
validateIdSegment(archiveMatch.id, 'memory id');
|
|
870
|
+
const body = await parseJsonBody(req);
|
|
871
|
+
const reason = getString(body, 'reason');
|
|
872
|
+
if (!reason) {
|
|
873
|
+
throw new HttpError(400, 'reason is required');
|
|
874
|
+
}
|
|
875
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
876
|
+
const result = archiveRaw(ctx, archiveMatch.id, reason);
|
|
877
|
+
sendJson(res, 200, result);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
async function handleSupersedeMemory({ req, res, opts }, supersedeMatch) {
|
|
881
|
+
validateIdSegment(supersedeMatch.id, 'memory id');
|
|
882
|
+
const body = await parseJsonBody(req);
|
|
883
|
+
const content = getString(body, 'content');
|
|
884
|
+
if (!content) {
|
|
885
|
+
throw new HttpError(400, 'content is required');
|
|
886
|
+
}
|
|
887
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
888
|
+
const result = supersede(ctx, supersedeMatch.id, content);
|
|
889
|
+
sendJson(res, 200, result);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
async function handlePromoteMemory({ req, res, opts }, promoteMatch) {
|
|
893
|
+
validateIdSegment(promoteMatch.id, 'memory id');
|
|
894
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
895
|
+
const result = promote(ctx, promoteMatch.id);
|
|
896
|
+
sendJson(res, 200, result);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
async function handleForgetMemory({ req, res, opts }, idMatch) {
|
|
900
|
+
validateIdSegment(idMatch.id, 'memory id');
|
|
901
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
902
|
+
const result = forget(ctx, idMatch.id);
|
|
903
|
+
sendJson(res, 200, result);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// POST /v1/outcome — apply a positive/negative outcome to memory ids.
|
|
907
|
+
// Body: {ids?: string[], good: boolean}. If ids omitted, falls back to
|
|
908
|
+
// the last-recall path (api.outcomeForLastRecall); returned shape is
|
|
909
|
+
// {applied, ids} in that case so callers can disambiguate "no recent
|
|
910
|
+
// recall" from "all ids skipped". Each applied id writes one audit_log
|
|
911
|
+
// row (op='outcome', actor from Bearer).
|
|
912
|
+
async function handleApplyOutcome({ req, res, opts }) {
|
|
913
|
+
const body = await parseJsonBody(req);
|
|
914
|
+
const good = body['good'];
|
|
915
|
+
if (!isJsonBoolean(good)) {
|
|
916
|
+
throw new HttpError(400, 'good is required (boolean)');
|
|
917
|
+
}
|
|
918
|
+
const idsRaw = body['ids'];
|
|
919
|
+
let ids;
|
|
920
|
+
if (idsRaw !== undefined) {
|
|
921
|
+
if (!Array.isArray(idsRaw)) {
|
|
922
|
+
throw new HttpError(400, 'ids must be an array of non-empty strings');
|
|
923
|
+
}
|
|
924
|
+
const isNonEmptyId = (item) => isJsonString(item) && item.length > 0;
|
|
925
|
+
if (!idsRaw.every(isNonEmptyId)) {
|
|
926
|
+
throw new HttpError(400, 'ids must be an array of non-empty strings');
|
|
927
|
+
}
|
|
928
|
+
// v1.11.5: DoS cap on ids.length. Each id triggers ~3 DB ops (readEntry +
|
|
929
|
+
// writeEntry + appendAuditEvent). N=1000 keeps per-request work bounded
|
|
930
|
+
// to sub-second wall time on SQLite hot path. Cap BEFORE buildContextWithAuth
|
|
931
|
+
// so attack traffic doesn't pay the api-key lookup cost.
|
|
932
|
+
if (idsRaw.length > 1000) {
|
|
933
|
+
throw new HttpError(400, 'ids exceeds 1000-id cap');
|
|
934
|
+
}
|
|
935
|
+
ids = idsRaw;
|
|
936
|
+
}
|
|
937
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
938
|
+
if (ids !== undefined) {
|
|
939
|
+
const { applied } = outcome(ctx, ids, good);
|
|
940
|
+
sendJson(res, 200, { applied });
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
const result = outcomeForLastRecall(ctx, good);
|
|
944
|
+
sendJson(res, 200, result);
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
// GET /v1/context — assemble a budget-bounded context bundle. Returns
|
|
949
|
+
// ContextResult JSON (entries + tokens + activeSnapshot + sessionHandoff
|
|
950
|
+
// + recentEvents). No server-side rendering; clients render. Tenant-scoped
|
|
951
|
+
// via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
|
|
952
|
+
// (matches cmdContext); real-query hybrid search emits one 'recall' row.
|
|
953
|
+
async function handleGetContext({ req, res, opts, query }) {
|
|
954
|
+
const q = query.get('q') ?? undefined;
|
|
955
|
+
// v1.11.5: DoS cap on q-param length. 1024 covers real multi-clause queries
|
|
956
|
+
// (pasted error messages, multi-stem searches) while bounding BM25
|
|
957
|
+
// tokenisation cost (~150 tokens worst case at 1024 chars).
|
|
958
|
+
if (q !== undefined && q.length > 1024) {
|
|
959
|
+
throw new HttpError(400, 'q exceeds 1024-character cap');
|
|
960
|
+
}
|
|
961
|
+
const budgetRaw = query.get('budget');
|
|
962
|
+
let budget;
|
|
963
|
+
if (budgetRaw !== null) {
|
|
964
|
+
budget = Number(budgetRaw);
|
|
965
|
+
if (!Number.isFinite(budget) || budget < 0) {
|
|
966
|
+
throw new HttpError(400, 'budget must be a non-negative number');
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const limitRaw = query.get('limit');
|
|
970
|
+
let limit;
|
|
971
|
+
if (limitRaw !== null) {
|
|
972
|
+
limit = Number(limitRaw);
|
|
973
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
974
|
+
throw new HttpError(400, 'limit must be a positive number');
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
const pinnedOnlyRaw = query.get('pinned_only');
|
|
978
|
+
const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
|
|
979
|
+
const scopeRaw = query.get('scope');
|
|
980
|
+
if (scopeRaw !== null && scopeRaw.length > 256) {
|
|
981
|
+
throw new HttpError(400, 'scope exceeds 256-character cap');
|
|
982
|
+
}
|
|
983
|
+
const scope = scopeRaw === null ? undefined : scopeRaw;
|
|
984
|
+
const includeRecentRaw = query.get('include_recent');
|
|
985
|
+
let includeRecent;
|
|
986
|
+
if (includeRecentRaw !== null) {
|
|
987
|
+
includeRecent = Number(includeRecentRaw);
|
|
988
|
+
if (!Number.isFinite(includeRecent) || includeRecent < 0) {
|
|
989
|
+
throw new HttpError(400, 'include_recent must be a non-negative number');
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// v39 memory scope isolation: cross_project=1|true re-includes
|
|
993
|
+
// other-project rows (tagged category 'cross-project' in the response).
|
|
994
|
+
// The partition identity comes from the SERVED STORE's location, not the
|
|
995
|
+
// daemon's process cwd - a daemon started from anywhere still isolates
|
|
996
|
+
// the project it serves.
|
|
997
|
+
const crossProjectRaw = query.get('cross_project');
|
|
998
|
+
const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
|
|
999
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1000
|
+
const result = await getContext(ctx, {
|
|
1001
|
+
q,
|
|
1002
|
+
budget,
|
|
1003
|
+
limit,
|
|
1004
|
+
pinnedOnly,
|
|
1005
|
+
scope,
|
|
1006
|
+
includeRecent,
|
|
1007
|
+
crossProject,
|
|
1008
|
+
currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
|
|
1009
|
+
cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
|
|
1010
|
+
});
|
|
1011
|
+
recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
|
|
1012
|
+
sendJson(res, 200, result);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
// POST /v1/sleep — host-wide consolidation pipeline (consolidate + dedup +
|
|
1016
|
+
// audit + share + ambient). serve() refuses non-loopback hosts at boot, AND
|
|
1017
|
+
// this per-request loopback assertion makes the host-wide semantic fail-
|
|
1018
|
+
// closed regardless of any future serve() boot-config change. Body:
|
|
1019
|
+
// {dry_run?, no_share?}. Returns SleepResult JSON.
|
|
1020
|
+
//
|
|
1021
|
+
// Tenant scope (Episode A follow-up tracked in TODOS.md): api.sleep operates
|
|
1022
|
+
// on the WHOLE hippoRoot (cross-tenant by design, matching CLI cmdSleep).
|
|
1023
|
+
// The loopback-only guard is the trust boundary today. Future non-loopback
|
|
1024
|
+
// serving must also zero the cross-tenant counters for other tenants
|
|
1025
|
+
// (D1 in docs/decisions/2026-05-24-blocked-items.md).
|
|
1026
|
+
async function handleSleep({ req, res, opts }) {
|
|
1027
|
+
// Defensive per-request loopback guard. Uses the canonical isLoopback()
|
|
1028
|
+
// helper above so any future extension (additional mapped/IPv6 forms,
|
|
1029
|
+
// NAT64 prefixes) flows through without drift. serve()'s boot-time host
|
|
1030
|
+
// check is the primary trust boundary; this is belt-and-suspenders.
|
|
1031
|
+
if (!isLoopback(req.socket.remoteAddress)) {
|
|
1032
|
+
throw new HttpError(403, '/v1/sleep is loopback-only (host-wide consolidation; see CHANGELOG v1.11.4)');
|
|
1033
|
+
}
|
|
1034
|
+
// v1.12.0 A5 v2 sub-1: admin-role gate. Forward-defensive — exists today
|
|
1035
|
+
// under loopback-only enforcement (loopback fallback is admin by default;
|
|
1036
|
+
// any Bearer-authed caller now carries an explicit role from the api_keys
|
|
1037
|
+
// row). When non-loopback serving lands, this gate is the actual auth
|
|
1038
|
+
// boundary on host-wide sleep.
|
|
1039
|
+
const sleepCtx = await buildContextWithAuth(req, opts);
|
|
1040
|
+
// Sleep consolidates every tenant under hippoRoot, so it is a cross-tenant action.
|
|
1041
|
+
assertCrossTenantAdmin(sleepCtx, '/v1/sleep');
|
|
1042
|
+
const body = await parseJsonBody(req);
|
|
1043
|
+
const dryRunRaw = body['dry_run'];
|
|
1044
|
+
if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
|
|
1045
|
+
throw new HttpError(400, 'dry_run must be a boolean');
|
|
1046
|
+
}
|
|
1047
|
+
const noShareRaw = body['no_share'];
|
|
1048
|
+
if (noShareRaw !== undefined && !isJsonBoolean(noShareRaw)) {
|
|
1049
|
+
throw new HttpError(400, 'no_share must be a boolean');
|
|
1050
|
+
}
|
|
1051
|
+
// v1.12.0: sleepCtx already built above for the admin-role gate; reuse.
|
|
1052
|
+
const result = await sleep(sleepCtx, {
|
|
1053
|
+
dryRun: dryRunRaw === true,
|
|
1054
|
+
noShare: noShareRaw === true,
|
|
1055
|
+
});
|
|
1056
|
+
sendJson(res, 200, result);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
// POST /v1/auth/keys — mint a new API key. Plaintext lands in the response
|
|
1060
|
+
// body (Task 8): the HTTP layer hands it to the client; the user-facing
|
|
1061
|
+
// "store this somewhere safe" warning belongs in the CLI client, not here.
|
|
1062
|
+
async function handleCreateAuthKey({ req, res, opts }) {
|
|
1063
|
+
const body = await parseJsonBody(req);
|
|
1064
|
+
const labelRaw = body['label'];
|
|
1065
|
+
if (labelRaw !== undefined && !isJsonString(labelRaw)) {
|
|
1066
|
+
throw new HttpError(400, 'label must be a string');
|
|
1067
|
+
}
|
|
1068
|
+
// v1.12.3: optional body.role mirrors the --role CLI flag. Validated
|
|
1069
|
+
// strictly — anything other than 'admin'|'member' is a 400 (no silent
|
|
1070
|
+
// fallback to admin). authCreate refuses a member caller with a 403.
|
|
1071
|
+
const roleRaw = body['role'];
|
|
1072
|
+
let role;
|
|
1073
|
+
if (roleRaw !== undefined) {
|
|
1074
|
+
if (roleRaw !== 'admin' && roleRaw !== 'member') {
|
|
1075
|
+
throw new HttpError(400, "role must be 'admin' or 'member'");
|
|
1076
|
+
}
|
|
1077
|
+
role = roleRaw;
|
|
1078
|
+
}
|
|
1079
|
+
// Security: any `tenantId` in the body is IGNORED. The minted key is
|
|
1080
|
+
// bound to the caller's authenticated tenant (ctx.tenantId, resolved
|
|
1081
|
+
// from the Bearer token). Forwarding body.tenantId here would let
|
|
1082
|
+
// tenant A mint a key for tenant B — see authCreate doc comment.
|
|
1083
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1084
|
+
const result = authCreate(ctx, {
|
|
1085
|
+
label: labelRaw,
|
|
1086
|
+
role,
|
|
1087
|
+
});
|
|
1088
|
+
sendJson(res, 200, result);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
// GET /v1/auth/keys?active=true — list keys visible to ctx.tenantId.
|
|
1092
|
+
// `active` defaults to true so the common case (show me usable keys) is
|
|
1093
|
+
// a single GET; ?active=false includes revoked rows.
|
|
1094
|
+
async function handleListAuthKeys({ req, res, opts, query }) {
|
|
1095
|
+
const activeRaw = query.get('active');
|
|
1096
|
+
let active = true;
|
|
1097
|
+
if (activeRaw !== null) {
|
|
1098
|
+
if (activeRaw === 'true')
|
|
1099
|
+
active = true;
|
|
1100
|
+
else if (activeRaw === 'false')
|
|
1101
|
+
active = false;
|
|
1102
|
+
else
|
|
1103
|
+
throw new HttpError(400, "active must be 'true' or 'false'");
|
|
1104
|
+
}
|
|
1105
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1106
|
+
const result = authList(ctx, { active });
|
|
1107
|
+
sendJson(res, 200, result);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
// DELETE /v1/auth/keys/:keyId — revoke. Missing or cross-tenant keys are 404
|
|
1111
|
+
// (no info leak); a member key targeting any key but its own is 403.
|
|
1112
|
+
// 200 with the body rather than 204 so the caller sees revokedAt.
|
|
1113
|
+
async function handleRevokeAuthKey({ req, res, opts }, keyMatch) {
|
|
1114
|
+
validateIdSegment(keyMatch.keyId, 'key id');
|
|
1115
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1116
|
+
const result = authRevoke(ctx, keyMatch.keyId);
|
|
1117
|
+
sendJson(res, 200, result);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
// GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
|
|
1121
|
+
async function handleListQuarantine({ req, res, opts, query }) {
|
|
1122
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1123
|
+
if (ctx.actor.role !== 'admin') {
|
|
1124
|
+
throw new HttpError(403, '/v1/quarantine requires admin role');
|
|
1125
|
+
}
|
|
1126
|
+
const statusRaw = query.get('status');
|
|
1127
|
+
let status = 'pending';
|
|
1128
|
+
if (statusRaw !== null) {
|
|
1129
|
+
if (statusRaw !== 'pending' && statusRaw !== 'approved' && statusRaw !== 'rejected' && statusRaw !== 'all') {
|
|
1130
|
+
throw new HttpError(400, 'status must be one of: pending | approved | rejected | all');
|
|
1131
|
+
}
|
|
1132
|
+
status = statusRaw;
|
|
1133
|
+
}
|
|
1134
|
+
sendJson(res, 200, { quarantine: quarantineList(ctx, { status }) });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
// POST /v1/quarantine/:id/approve: admin only; ForbiddenError falls through to mapApiError's 403.
|
|
1138
|
+
async function handleApproveQuarantine({ req, res, opts }, quarantineApproveMatch) {
|
|
1139
|
+
validateIdSegment(quarantineApproveMatch.id, 'memory id');
|
|
1140
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1141
|
+
quarantineApprove(ctx, quarantineApproveMatch.id);
|
|
1142
|
+
sendJson(res, 200, { approved: quarantineApproveMatch.id });
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
// POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
|
|
1146
|
+
async function handleRejectQuarantine({ req, res, opts }, quarantineRejectMatch) {
|
|
1147
|
+
validateIdSegment(quarantineRejectMatch.id, 'memory id');
|
|
1148
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1149
|
+
quarantineReject(ctx, quarantineRejectMatch.id);
|
|
1150
|
+
sendJson(res, 200, { rejected: quarantineRejectMatch.id });
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
// GET /v1/audit?op=&since=&limit= — read audit events. All three filters
|
|
1154
|
+
// validated at the route boundary so an invalid value lands a 400 before
|
|
1155
|
+
// we hit the DB.
|
|
1156
|
+
async function handleListAudit({ req, res, opts, query }) {
|
|
1157
|
+
const opRaw = query.get('op');
|
|
1158
|
+
let op;
|
|
1159
|
+
if (opRaw !== null) {
|
|
1160
|
+
if (!isSetMember(VALID_AUDIT_OPS, opRaw)) {
|
|
1161
|
+
throw new HttpError(400, `invalid op: ${opRaw}`);
|
|
1162
|
+
}
|
|
1163
|
+
op = opRaw;
|
|
1164
|
+
}
|
|
1165
|
+
const sinceRaw = query.get('since');
|
|
1166
|
+
let since;
|
|
1167
|
+
if (sinceRaw !== null) {
|
|
1168
|
+
const parsed = Date.parse(sinceRaw);
|
|
1169
|
+
if (!Number.isFinite(parsed)) {
|
|
1170
|
+
throw new HttpError(400, `invalid since: ${sinceRaw}`);
|
|
1171
|
+
}
|
|
1172
|
+
since = sinceRaw;
|
|
1173
|
+
}
|
|
1174
|
+
const limitRaw = query.get('limit');
|
|
1175
|
+
let limit;
|
|
1176
|
+
if (limitRaw !== null) {
|
|
1177
|
+
const parsed = Number(limitRaw);
|
|
1178
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > MAX_AUDIT_LIMIT) {
|
|
1179
|
+
throw new HttpError(400, `limit must be an integer between 1 and ${MAX_AUDIT_LIMIT}`);
|
|
1180
|
+
}
|
|
1181
|
+
limit = parsed;
|
|
1182
|
+
}
|
|
1183
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1184
|
+
// ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
|
|
1185
|
+
const tenantOverride = query.get('tenant');
|
|
1186
|
+
const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
|
|
1187
|
+
if (crossTenant)
|
|
1188
|
+
assertCrossTenantAdmin(ctx, '/v1/audit?tenant= for another tenant');
|
|
1189
|
+
const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
|
|
1190
|
+
const result = auditList(effectiveCtx, { op, since, limit });
|
|
1191
|
+
sendJson(res, 200, result);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
// ── E2 prediction first-class object (v0.31) ──
|
|
1195
|
+
// docs/plans/2026-05-26-e2-prediction-object.md
|
|
1196
|
+
//
|
|
1197
|
+
// 4 routes: POST /v1/predictions (create), GET /v1/predictions (list),
|
|
1198
|
+
// GET /v1/predictions/:id (show), POST /v1/predictions/:id/close (close).
|
|
1199
|
+
// All Bearer-authed + tenant-scoped via buildContextWithAuth. closure_state
|
|
1200
|
+
// validated against VALID_CLOSURE_STATES (3 states). DoS caps on claim
|
|
1201
|
+
// (4096 chars) + closureNote (2048 chars) per v1.11.4 pattern.
|
|
1202
|
+
async function handleCreatePrediction({ req, res, opts }) {
|
|
1203
|
+
const body = await parseJsonBody(req);
|
|
1204
|
+
const claim = body['claim'];
|
|
1205
|
+
if (!isJsonString(claim) || claim.length === 0) {
|
|
1206
|
+
throw new HttpError(400, 'claim is required (non-empty string)');
|
|
1207
|
+
}
|
|
1208
|
+
if (claim.length > 4096) {
|
|
1209
|
+
throw new HttpError(400, 'claim exceeds 4096-character cap');
|
|
1210
|
+
}
|
|
1211
|
+
const classTag = body['classTag'];
|
|
1212
|
+
if (!isJsonString(classTag) || classTag.length === 0) {
|
|
1213
|
+
throw new HttpError(400, 'classTag is required (non-empty string)');
|
|
1214
|
+
}
|
|
1215
|
+
const estimate = body['estimate'];
|
|
1216
|
+
let estimateValue;
|
|
1217
|
+
if (estimate !== undefined && estimate !== null) {
|
|
1218
|
+
if (!isJsonNumber(estimate) || !Number.isFinite(estimate)) {
|
|
1219
|
+
throw new HttpError(400, 'estimate must be a finite number');
|
|
1220
|
+
}
|
|
1221
|
+
estimateValue = estimate;
|
|
1222
|
+
}
|
|
1223
|
+
const unit = body['unit'];
|
|
1224
|
+
let estimateUnit;
|
|
1225
|
+
if (unit !== undefined && unit !== null) {
|
|
1226
|
+
if (!isJsonString(unit)) {
|
|
1227
|
+
throw new HttpError(400, 'unit must be a string');
|
|
1228
|
+
}
|
|
1229
|
+
estimateUnit = unit;
|
|
1230
|
+
}
|
|
1231
|
+
const targetDate = body['targetDate'];
|
|
1232
|
+
let targetDateValue;
|
|
1233
|
+
if (targetDate !== undefined && targetDate !== null) {
|
|
1234
|
+
if (!isJsonString(targetDate)) {
|
|
1235
|
+
throw new HttpError(400, 'targetDate must be an ISO date string');
|
|
1236
|
+
}
|
|
1237
|
+
targetDateValue = targetDate;
|
|
1238
|
+
}
|
|
1239
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1240
|
+
const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
|
|
1241
|
+
classTag,
|
|
1242
|
+
claimText: claim,
|
|
1243
|
+
estimateValue,
|
|
1244
|
+
estimateUnit,
|
|
1245
|
+
targetDate: targetDateValue,
|
|
1246
|
+
}, ctx.actor.subject);
|
|
1247
|
+
sendJson(res, 201, { prediction });
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
async function handleListPredictions({ req, res, opts, query }) {
|
|
1251
|
+
const classTag = query.get('class') ?? undefined;
|
|
1252
|
+
const status = query.get('status') ?? 'all';
|
|
1253
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1254
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1255
|
+
let predictions;
|
|
1256
|
+
if (status === 'all') {
|
|
1257
|
+
if (classTag) {
|
|
1258
|
+
predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, { limit });
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
else if (status === 'open') {
|
|
1265
|
+
predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, {
|
|
1266
|
+
classTag: classTag || undefined,
|
|
1267
|
+
limit,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
if (!isSetMember(VALID_CLOSURE_STATES, status)) {
|
|
1272
|
+
throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
|
|
1273
|
+
}
|
|
1274
|
+
if (!classTag) {
|
|
1275
|
+
throw new HttpError(400, 'status filter (non-open) requires class param');
|
|
1276
|
+
}
|
|
1277
|
+
predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, {
|
|
1278
|
+
closureState: status,
|
|
1279
|
+
limit,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
sendJson(res, 200, { predictions });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
// J3 reference-class / planning-fallacy detector (v0.31).
|
|
1286
|
+
// Order matters: this must match BEFORE /v1/predictions/:id since 'stats'
|
|
1287
|
+
// is not a number — the :id regex requires \d+ so they don't conflict,
|
|
1288
|
+
// but routing this first avoids the dispatch order risk.
|
|
1289
|
+
async function handlePredictionStats({ req, res, opts, query }) {
|
|
1290
|
+
const classTag = query.get('class');
|
|
1291
|
+
if (!classTag || classTag.length === 0) {
|
|
1292
|
+
throw new HttpError(400, 'class param is required');
|
|
1293
|
+
}
|
|
1294
|
+
if (classTag.length > 256) {
|
|
1295
|
+
throw new HttpError(400, 'class exceeds 256-character cap');
|
|
1296
|
+
}
|
|
1297
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1298
|
+
const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
|
|
1299
|
+
sendJson(res, 200, { baserate });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
async function handleGetPrediction({ req, res, opts }, predictionByIdMatch) {
|
|
1303
|
+
const id = parseInt(predictionByIdMatch[1], 10);
|
|
1304
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1305
|
+
const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
|
|
1306
|
+
if (!prediction) {
|
|
1307
|
+
throw new HttpError(404, `prediction ${id} not found`);
|
|
1308
|
+
}
|
|
1309
|
+
sendJson(res, 200, { prediction });
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
async function handleClosePrediction({ req, res, opts }, predictionCloseMatch) {
|
|
1313
|
+
const id = parseInt(predictionCloseMatch[1], 10);
|
|
1314
|
+
const body = await parseJsonBody(req);
|
|
1315
|
+
const state = body['state'];
|
|
1316
|
+
if (!isJsonString(state) || !isSetMember(VALID_CLOSURE_STATES, state) || state === 'open') {
|
|
1317
|
+
throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
|
|
1318
|
+
}
|
|
1319
|
+
const actual = body['actual'];
|
|
1320
|
+
let actualValue;
|
|
1321
|
+
if (actual !== undefined && actual !== null) {
|
|
1322
|
+
if (!isJsonNumber(actual) || !Number.isFinite(actual)) {
|
|
1323
|
+
throw new HttpError(400, 'actual must be a finite number');
|
|
1324
|
+
}
|
|
1325
|
+
actualValue = actual;
|
|
1326
|
+
}
|
|
1327
|
+
const note = body['note'];
|
|
1328
|
+
let closureNote;
|
|
1329
|
+
if (note !== undefined && note !== null) {
|
|
1330
|
+
if (!isJsonString(note)) {
|
|
1331
|
+
throw new HttpError(400, 'note must be a string');
|
|
1332
|
+
}
|
|
1333
|
+
if (note.length > 2048) {
|
|
1334
|
+
throw new HttpError(400, 'note exceeds 2048-character cap');
|
|
1335
|
+
}
|
|
1336
|
+
closureNote = note;
|
|
1337
|
+
}
|
|
1338
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1339
|
+
const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
|
|
1340
|
+
closureState: state,
|
|
1341
|
+
actualValue,
|
|
1342
|
+
closureNote,
|
|
1343
|
+
}, ctx.actor.subject);
|
|
1344
|
+
sendJson(res, 200, { prediction });
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
// ── decisions (E2 first-class object) ──
|
|
1348
|
+
//
|
|
1349
|
+
// 5 routes: POST /v1/decisions (create, optional supersedesDecisionId),
|
|
1350
|
+
// GET /v1/decisions (list, status filter), GET /v1/decisions/:id (show),
|
|
1351
|
+
// POST /v1/decisions/:id/supersede (create a successor + supersede :id),
|
|
1352
|
+
// POST /v1/decisions/:id/close (retire). Bearer-authed + tenant-scoped via
|
|
1353
|
+
// buildContextWithAuth. status validated against VALID_DECISION_STATES.
|
|
1354
|
+
// DoS caps: text 4096, context 4096 (v1.11.4 pattern). The HTTP surface is
|
|
1355
|
+
// new (no legacy --supersedes <memory-id> constraint), so it supersedes by
|
|
1356
|
+
// table id and never weakens a memory mirror.
|
|
1357
|
+
async function handleCreateDecision({ req, res, opts }) {
|
|
1358
|
+
const body = await parseJsonBody(req);
|
|
1359
|
+
const text = body['text'];
|
|
1360
|
+
if (!isJsonString(text) || text.length === 0) {
|
|
1361
|
+
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1362
|
+
}
|
|
1363
|
+
if (text.length > 4096) {
|
|
1364
|
+
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1365
|
+
}
|
|
1366
|
+
const contextRaw = body['context'];
|
|
1367
|
+
let context;
|
|
1368
|
+
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1369
|
+
if (!isJsonString(contextRaw)) {
|
|
1370
|
+
throw new HttpError(400, 'context must be a string');
|
|
1371
|
+
}
|
|
1372
|
+
if (contextRaw.length > 4096) {
|
|
1373
|
+
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1374
|
+
}
|
|
1375
|
+
context = contextRaw;
|
|
1376
|
+
}
|
|
1377
|
+
const supRaw = body['supersedesDecisionId'];
|
|
1378
|
+
let supersedesDecisionId;
|
|
1379
|
+
if (supRaw !== undefined && supRaw !== null) {
|
|
1380
|
+
if (!isJsonNumber(supRaw) || !Number.isInteger(supRaw) || supRaw <= 0) {
|
|
1381
|
+
throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
|
|
1382
|
+
}
|
|
1383
|
+
supersedesDecisionId = supRaw;
|
|
1384
|
+
}
|
|
1385
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1386
|
+
try {
|
|
1387
|
+
const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
|
|
1388
|
+
decisionText: text,
|
|
1389
|
+
context,
|
|
1390
|
+
supersedesDecisionId,
|
|
1391
|
+
}, ctx.actor.subject);
|
|
1392
|
+
sendJson(res, 201, { decision });
|
|
1393
|
+
}
|
|
1394
|
+
catch (e) {
|
|
1395
|
+
// A missing referenced row is a conflict with the create, not a missing target.
|
|
1396
|
+
if (e instanceof NotFoundError)
|
|
1397
|
+
throw new HttpError(409, e.message);
|
|
1398
|
+
throw e;
|
|
1399
|
+
}
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
async function handleListDecisions({ req, res, opts, query }) {
|
|
1403
|
+
const status = query.get('status') ?? 'all';
|
|
1404
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1405
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1406
|
+
let decisions;
|
|
1407
|
+
if (status === 'all') {
|
|
1408
|
+
decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
if (!isSetMember(VALID_DECISION_STATES, status)) {
|
|
1412
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1413
|
+
}
|
|
1414
|
+
decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
|
|
1415
|
+
status,
|
|
1416
|
+
limit,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
sendJson(res, 200, { decisions });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
async function handleSupersedeDecision({ req, res, opts }, decisionSupersedeMatch) {
|
|
1423
|
+
const oldId = parseInt(decisionSupersedeMatch[1], 10);
|
|
1424
|
+
const body = await parseJsonBody(req);
|
|
1425
|
+
const text = body['text'];
|
|
1426
|
+
if (!isJsonString(text) || text.length === 0) {
|
|
1427
|
+
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1428
|
+
}
|
|
1429
|
+
if (text.length > 4096) {
|
|
1430
|
+
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1431
|
+
}
|
|
1432
|
+
const contextRaw = body['context'];
|
|
1433
|
+
let context;
|
|
1434
|
+
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1435
|
+
if (!isJsonString(contextRaw)) {
|
|
1436
|
+
throw new HttpError(400, 'context must be a string');
|
|
1437
|
+
}
|
|
1438
|
+
if (contextRaw.length > 4096) {
|
|
1439
|
+
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1440
|
+
}
|
|
1441
|
+
context = contextRaw;
|
|
1442
|
+
}
|
|
1443
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1444
|
+
const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
|
|
1445
|
+
decisionText: text,
|
|
1446
|
+
context,
|
|
1447
|
+
supersedesDecisionId: oldId,
|
|
1448
|
+
}, ctx.actor.subject);
|
|
1449
|
+
sendJson(res, 201, { decision });
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
async function handleCloseDecision({ req, res, opts }, decisionCloseMatch) {
|
|
1453
|
+
const id = parseInt(decisionCloseMatch[1], 10);
|
|
1454
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1455
|
+
const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1456
|
+
sendJson(res, 200, { decision });
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
async function handleGetDecision({ req, res, opts }, decisionByIdMatch) {
|
|
1460
|
+
const id = parseInt(decisionByIdMatch[1], 10);
|
|
1461
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1462
|
+
const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
|
|
1463
|
+
if (!decision) {
|
|
1464
|
+
throw new HttpError(404, `decision ${id} not found`);
|
|
1465
|
+
}
|
|
1466
|
+
sendJson(res, 200, { decision });
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
// ── incidents (E2 first-class object) ──
|
|
1470
|
+
//
|
|
1471
|
+
// 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
|
|
1472
|
+
// GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
|
|
1473
|
+
// POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
|
|
1474
|
+
// POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
|
|
1475
|
+
// tenant-scoped via buildContextWithAuth. status validated against
|
|
1476
|
+
// VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
|
|
1477
|
+
// 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
|
|
1478
|
+
// open->resolved->closed (no supersede), so linkedMemoryIds replaces
|
|
1479
|
+
// supersedesDecisionId on create.
|
|
1480
|
+
async function handleCreateIncident({ req, res, opts }) {
|
|
1481
|
+
const body = await parseJsonBody(req);
|
|
1482
|
+
const text = body['text'];
|
|
1483
|
+
if (!isJsonString(text) || text.length === 0) {
|
|
1484
|
+
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1485
|
+
}
|
|
1486
|
+
if (text.length > 4096) {
|
|
1487
|
+
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1488
|
+
}
|
|
1489
|
+
const contextRaw = body['context'];
|
|
1490
|
+
let context;
|
|
1491
|
+
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1492
|
+
if (!isJsonString(contextRaw)) {
|
|
1493
|
+
throw new HttpError(400, 'context must be a string');
|
|
1494
|
+
}
|
|
1495
|
+
if (contextRaw.length > 4096) {
|
|
1496
|
+
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1497
|
+
}
|
|
1498
|
+
context = contextRaw;
|
|
1499
|
+
}
|
|
1500
|
+
const linkedRaw = body['linkedMemoryIds'];
|
|
1501
|
+
let linkedMemoryIds;
|
|
1502
|
+
if (linkedRaw !== undefined && linkedRaw !== null) {
|
|
1503
|
+
if (!Array.isArray(linkedRaw)) {
|
|
1504
|
+
throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
|
|
1505
|
+
}
|
|
1506
|
+
if (linkedRaw.length > 256) {
|
|
1507
|
+
throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
|
|
1508
|
+
}
|
|
1509
|
+
const isValidMemoryId = (item) => isJsonString(item) && item.length > 0 && item.length <= 4096;
|
|
1510
|
+
if (!linkedRaw.every(isValidMemoryId)) {
|
|
1511
|
+
throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
|
|
1512
|
+
}
|
|
1513
|
+
linkedMemoryIds = linkedRaw;
|
|
1514
|
+
}
|
|
1515
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1516
|
+
try {
|
|
1517
|
+
const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
|
|
1518
|
+
incidentText: text,
|
|
1519
|
+
context,
|
|
1520
|
+
linkedMemoryIds,
|
|
1521
|
+
}, ctx.actor.subject);
|
|
1522
|
+
sendJson(res, 201, { incident });
|
|
1523
|
+
}
|
|
1524
|
+
catch (e) {
|
|
1525
|
+
// A missing referenced row is a conflict with the create, not a missing target.
|
|
1526
|
+
if (e instanceof NotFoundError)
|
|
1527
|
+
throw new HttpError(409, e.message);
|
|
1528
|
+
throw e;
|
|
1529
|
+
}
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
async function handleListIncidents({ req, res, opts, query }) {
|
|
1533
|
+
const status = query.get('status') ?? 'all';
|
|
1534
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1535
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1536
|
+
let incidents;
|
|
1537
|
+
if (status === 'all') {
|
|
1538
|
+
incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1539
|
+
}
|
|
1540
|
+
else {
|
|
1541
|
+
if (!isSetMember(VALID_INCIDENT_STATES, status)) {
|
|
1542
|
+
throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
|
|
1543
|
+
}
|
|
1544
|
+
incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
|
|
1545
|
+
status,
|
|
1546
|
+
limit,
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
sendJson(res, 200, { incidents });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
async function handleResolveIncident({ req, res, opts }, incidentResolveMatch) {
|
|
1553
|
+
const id = parseInt(incidentResolveMatch[1], 10);
|
|
1554
|
+
const body = await parseJsonBody(req);
|
|
1555
|
+
const resolutionText = body['resolutionText'];
|
|
1556
|
+
if (!isJsonString(resolutionText) || resolutionText.trim().length === 0) {
|
|
1557
|
+
throw new HttpError(400, 'resolutionText is required (non-empty string)');
|
|
1558
|
+
}
|
|
1559
|
+
if (resolutionText.length > 4096) {
|
|
1560
|
+
throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
|
|
1561
|
+
}
|
|
1562
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1563
|
+
const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
|
|
1564
|
+
sendJson(res, 200, { incident });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
async function handleCloseIncident({ req, res, opts }, incidentCloseMatch) {
|
|
1568
|
+
const id = parseInt(incidentCloseMatch[1], 10);
|
|
1569
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1570
|
+
const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1571
|
+
sendJson(res, 200, { incident });
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
async function handleGetIncident({ req, res, opts }, incidentByIdMatch) {
|
|
1575
|
+
const id = parseInt(incidentByIdMatch[1], 10);
|
|
1576
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1577
|
+
const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
|
|
1578
|
+
if (!incident) {
|
|
1579
|
+
throw new HttpError(404, `incident ${id} not found`);
|
|
1580
|
+
}
|
|
1581
|
+
sendJson(res, 200, { incident });
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
// ── processes (E2 first-class object) ──
|
|
1585
|
+
//
|
|
1586
|
+
// 5 routes: POST /v1/processes (new; body processName + steps[] + description),
|
|
1587
|
+
// GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
|
|
1588
|
+
// POST /v1/processes/:id/supersede (active -> superseded by a new version; body
|
|
1589
|
+
// steps[] + changeSummary + description; reuses the predecessor's name),
|
|
1590
|
+
// POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
|
|
1591
|
+
// via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
|
|
1592
|
+
// caps: processName/description/changeSummary 4096, steps 200x2000
|
|
1593
|
+
// (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
|
|
1594
|
+
// decision supersede path.
|
|
1595
|
+
async function handleCreateProcess({ req, res, opts }) {
|
|
1596
|
+
const body = await parseJsonBody(req);
|
|
1597
|
+
const processName = body['processName'];
|
|
1598
|
+
if (!isJsonString(processName) || processName.trim().length === 0) {
|
|
1599
|
+
throw new HttpError(400, 'processName is required (non-empty string)');
|
|
1600
|
+
}
|
|
1601
|
+
if (processName.length > 4096) {
|
|
1602
|
+
throw new HttpError(400, 'processName exceeds 4096-character cap');
|
|
1603
|
+
}
|
|
1604
|
+
const steps = validateProcessStepsBody(body['steps']);
|
|
1605
|
+
const descriptionRaw = body['description'];
|
|
1606
|
+
let description;
|
|
1607
|
+
if (descriptionRaw !== undefined && descriptionRaw !== null) {
|
|
1608
|
+
if (!isJsonString(descriptionRaw)) {
|
|
1609
|
+
throw new HttpError(400, 'description must be a string');
|
|
1610
|
+
}
|
|
1611
|
+
if (descriptionRaw.length > 4096) {
|
|
1612
|
+
throw new HttpError(400, 'description exceeds 4096-character cap');
|
|
1613
|
+
}
|
|
1614
|
+
description = descriptionRaw;
|
|
1615
|
+
}
|
|
1616
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1617
|
+
const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
|
|
1618
|
+
processName,
|
|
1619
|
+
steps,
|
|
1620
|
+
description,
|
|
1621
|
+
}, ctx.actor.subject);
|
|
1622
|
+
sendJson(res, 201, { process });
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
async function handleListProcesses({ req, res, opts, query }) {
|
|
1626
|
+
const status = query.get('status') ?? 'all';
|
|
1627
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1628
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1629
|
+
let processes;
|
|
1630
|
+
if (status === 'all') {
|
|
1631
|
+
processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
if (!isSetMember(VALID_PROCESS_STATES, status)) {
|
|
1635
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1636
|
+
}
|
|
1637
|
+
processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
|
|
1638
|
+
status,
|
|
1639
|
+
limit,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
sendJson(res, 200, { processes });
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
async function handleSupersedeProcess({ req, res, opts }, processSupersedeMatch) {
|
|
1646
|
+
const id = parseInt(processSupersedeMatch[1], 10);
|
|
1647
|
+
const body = await parseJsonBody(req);
|
|
1648
|
+
const steps = validateProcessStepsBody(body['steps']);
|
|
1649
|
+
if (steps.length === 0) {
|
|
1650
|
+
throw new HttpError(400, 'steps is required (at least one step) for a supersession');
|
|
1651
|
+
}
|
|
1652
|
+
const changeRaw = body['changeSummary'];
|
|
1653
|
+
let changeSummary;
|
|
1654
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
1655
|
+
if (!isJsonString(changeRaw)) {
|
|
1656
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
1657
|
+
}
|
|
1658
|
+
if (changeRaw.length > 4096) {
|
|
1659
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
1660
|
+
}
|
|
1661
|
+
changeSummary = changeRaw;
|
|
1662
|
+
}
|
|
1663
|
+
const descRaw = body['description'];
|
|
1664
|
+
let description;
|
|
1665
|
+
if (descRaw !== undefined && descRaw !== null) {
|
|
1666
|
+
if (!isJsonString(descRaw)) {
|
|
1667
|
+
throw new HttpError(400, 'description must be a string');
|
|
1668
|
+
}
|
|
1669
|
+
if (descRaw.length > 4096) {
|
|
1670
|
+
throw new HttpError(400, 'description exceeds 4096-character cap');
|
|
1671
|
+
}
|
|
1672
|
+
description = descRaw;
|
|
1673
|
+
}
|
|
1674
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1675
|
+
// A supersession is a new version of the SAME process: reuse the
|
|
1676
|
+
// predecessor's name. 404 if the target does not exist; saveProcess's
|
|
1677
|
+
// in-SAVEPOINT preflight is the authoritative active-state check (409).
|
|
1678
|
+
const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
|
|
1679
|
+
if (!existing) {
|
|
1680
|
+
throw new HttpError(404, `process ${id} not found`);
|
|
1681
|
+
}
|
|
1682
|
+
const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
|
|
1683
|
+
processName: existing.processName,
|
|
1684
|
+
steps,
|
|
1685
|
+
description,
|
|
1686
|
+
changeSummary,
|
|
1687
|
+
supersedesProcessId: id,
|
|
1688
|
+
}, ctx.actor.subject);
|
|
1689
|
+
sendJson(res, 200, { process });
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
async function handleCloseProcess({ req, res, opts }, processCloseMatch) {
|
|
1693
|
+
const id = parseInt(processCloseMatch[1], 10);
|
|
1694
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1695
|
+
const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1696
|
+
sendJson(res, 200, { process });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
async function handleGetProcess({ req, res, opts }, processByIdMatch) {
|
|
1700
|
+
const id = parseInt(processByIdMatch[1], 10);
|
|
1701
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1702
|
+
const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
|
|
1703
|
+
if (!process) {
|
|
1704
|
+
throw new HttpError(404, `process ${id} not found`);
|
|
1705
|
+
}
|
|
1706
|
+
sendJson(res, 200, { process });
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
// ── policies (E2 first-class object, bi-temporal-first) ──
|
|
1710
|
+
//
|
|
1711
|
+
// 6 routes: POST /v1/policies (new; processName-style body policyName +
|
|
1712
|
+
// policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
|
|
1713
|
+
// GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
|
|
1714
|
+
// placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
|
|
1715
|
+
// /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
|
|
1716
|
+
// Date inputs are normalized + range-validated in the store; an invalid/inverted
|
|
1717
|
+
// date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
|
|
1718
|
+
async function handleCreatePolicy({ req, res, opts }) {
|
|
1719
|
+
const body = await parseJsonBody(req);
|
|
1720
|
+
const policyName = body['policyName'];
|
|
1721
|
+
if (!isJsonString(policyName) || policyName.trim().length === 0) {
|
|
1722
|
+
throw new HttpError(400, 'policyName is required (non-empty string)');
|
|
1723
|
+
}
|
|
1724
|
+
if (policyName.length > 4096) {
|
|
1725
|
+
throw new HttpError(400, 'policyName exceeds 4096-character cap');
|
|
1726
|
+
}
|
|
1727
|
+
const policyText = body['policyText'];
|
|
1728
|
+
if (!isJsonString(policyText) || policyText.trim().length === 0) {
|
|
1729
|
+
throw new HttpError(400, 'policyText is required (non-empty string)');
|
|
1730
|
+
}
|
|
1731
|
+
if (policyText.length > 4096) {
|
|
1732
|
+
throw new HttpError(400, 'policyText exceeds 4096-character cap');
|
|
1733
|
+
}
|
|
1734
|
+
const validFrom = optionalDateField(body['validFrom'], 'validFrom');
|
|
1735
|
+
const validTo = optionalDateField(body['validTo'], 'validTo');
|
|
1736
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1737
|
+
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
1738
|
+
policyName,
|
|
1739
|
+
policyText,
|
|
1740
|
+
validFrom,
|
|
1741
|
+
validTo,
|
|
1742
|
+
}, ctx.actor.subject);
|
|
1743
|
+
sendJson(res, 201, { policy });
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
async function handleListPolicies({ req, res, opts, query }) {
|
|
1747
|
+
const status = query.get('status') ?? 'all';
|
|
1748
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1749
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1750
|
+
let policies;
|
|
1751
|
+
if (status === 'all') {
|
|
1752
|
+
policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
if (!isSetMember(VALID_POLICY_STATES, status)) {
|
|
1756
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1757
|
+
}
|
|
1758
|
+
policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
|
|
1759
|
+
status,
|
|
1760
|
+
limit,
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
sendJson(res, 200, { policies });
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
// The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
|
|
1767
|
+
// the /(\d+)/ route would not match it, but order it first for clarity).
|
|
1768
|
+
async function handlePoliciesAsOf({ req, res, opts, query }) {
|
|
1769
|
+
const date = query.get('date');
|
|
1770
|
+
if (date === null || date.length === 0) {
|
|
1771
|
+
throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
|
|
1772
|
+
}
|
|
1773
|
+
const name = query.get('name') ?? undefined;
|
|
1774
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1775
|
+
const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
|
|
1776
|
+
sendJson(res, 200, { policies });
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
|
|
1780
|
+
const id = parseInt(policySupersedeMatch[1], 10);
|
|
1781
|
+
const body = await parseJsonBody(req);
|
|
1782
|
+
const policyText = body['policyText'];
|
|
1783
|
+
if (!isJsonString(policyText) || policyText.trim().length === 0) {
|
|
1784
|
+
throw new HttpError(400, 'policyText is required (non-empty string)');
|
|
1785
|
+
}
|
|
1786
|
+
if (policyText.length > 4096) {
|
|
1787
|
+
throw new HttpError(400, 'policyText exceeds 4096-character cap');
|
|
1788
|
+
}
|
|
1789
|
+
const validFrom = optionalDateField(body['validFrom'], 'validFrom');
|
|
1790
|
+
const validTo = optionalDateField(body['validTo'], 'validTo');
|
|
1791
|
+
const changeRaw = body['changeSummary'];
|
|
1792
|
+
let changeSummary;
|
|
1793
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
1794
|
+
if (!isJsonString(changeRaw)) {
|
|
1795
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
1796
|
+
}
|
|
1797
|
+
if (changeRaw.length > 4096) {
|
|
1798
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
1799
|
+
}
|
|
1800
|
+
changeSummary = changeRaw;
|
|
1801
|
+
}
|
|
1802
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1803
|
+
const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
|
|
1804
|
+
if (!existing) {
|
|
1805
|
+
throw new HttpError(404, `policy ${id} not found`);
|
|
1806
|
+
}
|
|
1807
|
+
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
1808
|
+
policyName: existing.policyName,
|
|
1809
|
+
policyText,
|
|
1810
|
+
validFrom,
|
|
1811
|
+
validTo,
|
|
1812
|
+
changeSummary,
|
|
1813
|
+
supersedesPolicyId: id,
|
|
1814
|
+
}, ctx.actor.subject);
|
|
1815
|
+
sendJson(res, 200, { policy });
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
async function handleClosePolicy({ req, res, opts }, policyCloseMatch) {
|
|
1819
|
+
const id = parseInt(policyCloseMatch[1], 10);
|
|
1820
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1821
|
+
const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1822
|
+
sendJson(res, 200, { policy });
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
async function handleGetPolicy({ req, res, opts }, policyByIdMatch) {
|
|
1826
|
+
const id = parseInt(policyByIdMatch[1], 10);
|
|
1827
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1828
|
+
const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
|
|
1829
|
+
if (!policy) {
|
|
1830
|
+
throw new HttpError(404, `policy ${id} not found`);
|
|
1831
|
+
}
|
|
1832
|
+
sendJson(res, 200, { policy });
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
// ── skills (E2 first-class object, executable/exportable) ──
|
|
1836
|
+
//
|
|
1837
|
+
// 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
|
|
1838
|
+
// GET /v1/skills (list, status filter; shared parseListLimit), GET
|
|
1839
|
+
// /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
|
|
1840
|
+
// block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
|
|
1841
|
+
// cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
|
|
1842
|
+
// POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
|
|
1843
|
+
// skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
|
|
1844
|
+
// validates + throws; the boundary maps validation -> 400, not-found -> 404,
|
|
1845
|
+
// not-active -> 409. Mirrors /v1/processes; "executable" = exportable
|
|
1846
|
+
// instruction (no code exec).
|
|
1847
|
+
async function handleCreateSkill({ req, res, opts }) {
|
|
1848
|
+
const body = await parseJsonBody(req);
|
|
1849
|
+
const skillName = body['skillName'];
|
|
1850
|
+
if (!isJsonString(skillName) || skillName.trim().length === 0) {
|
|
1851
|
+
throw new HttpError(400, 'skillName is required (non-empty string)');
|
|
1852
|
+
}
|
|
1853
|
+
if (skillName.length > 256) {
|
|
1854
|
+
throw new HttpError(400, 'skillName exceeds 256-character cap');
|
|
1855
|
+
}
|
|
1856
|
+
const instructions = body['instructions'];
|
|
1857
|
+
if (!isJsonString(instructions) || instructions.trim().length === 0) {
|
|
1858
|
+
throw new HttpError(400, 'instructions are required (non-empty string)');
|
|
1859
|
+
}
|
|
1860
|
+
if (instructions.length > 8192) {
|
|
1861
|
+
throw new HttpError(400, 'instructions exceed 8192-character cap');
|
|
1862
|
+
}
|
|
1863
|
+
const triggerRaw = body['trigger'];
|
|
1864
|
+
let trigger;
|
|
1865
|
+
if (triggerRaw !== undefined && triggerRaw !== null) {
|
|
1866
|
+
if (!isJsonString(triggerRaw)) {
|
|
1867
|
+
throw new HttpError(400, 'trigger must be a string');
|
|
1868
|
+
}
|
|
1869
|
+
if (triggerRaw.length > 1024) {
|
|
1870
|
+
throw new HttpError(400, 'trigger exceeds 1024-character cap');
|
|
1871
|
+
}
|
|
1872
|
+
trigger = triggerRaw;
|
|
1873
|
+
}
|
|
1874
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1875
|
+
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
1876
|
+
skillName,
|
|
1877
|
+
instructions,
|
|
1878
|
+
trigger,
|
|
1879
|
+
}, ctx.actor.subject);
|
|
1880
|
+
sendJson(res, 201, { skill });
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
async function handleListSkills({ req, res, opts, query }) {
|
|
1884
|
+
const status = query.get('status') ?? 'all';
|
|
1885
|
+
const limit = parseListLimit(query.get('limit'));
|
|
1886
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1887
|
+
let skills;
|
|
1888
|
+
if (status === 'all') {
|
|
1889
|
+
skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1890
|
+
}
|
|
1891
|
+
else {
|
|
1892
|
+
if (!isSetMember(VALID_SKILL_STATES, status)) {
|
|
1893
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1894
|
+
}
|
|
1895
|
+
skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
|
|
1896
|
+
status,
|
|
1897
|
+
limit,
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
sendJson(res, 200, { skills });
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
// The export renderer: must precede the /:id GET (literal 'export' is
|
|
1904
|
+
// non-numeric so the /(\d+)/ route would not match it, but order it first).
|
|
1905
|
+
async function handleExportSkills({ req, res, opts }) {
|
|
1906
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1907
|
+
const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
|
|
1908
|
+
sendJson(res, 200, { markdown });
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
async function handleSupersedeSkill({ req, res, opts }, skillSupersedeMatch) {
|
|
1912
|
+
const id = parseInt(skillSupersedeMatch[1], 10);
|
|
1913
|
+
const body = await parseJsonBody(req);
|
|
1914
|
+
const instructions = body['instructions'];
|
|
1915
|
+
if (!isJsonString(instructions) || 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 (!isJsonString(triggerRaw)) {
|
|
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 (!isJsonString(changeRaw)) {
|
|
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 = await buildContextWithAuth(req, opts);
|
|
1944
|
+
const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
|
|
1945
|
+
if (!existing) {
|
|
1946
|
+
throw new HttpError(404, `skill ${id} not found`);
|
|
1947
|
+
}
|
|
1948
|
+
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
1949
|
+
skillName: existing.skillName,
|
|
1950
|
+
instructions,
|
|
1951
|
+
trigger,
|
|
1952
|
+
changeSummary,
|
|
1953
|
+
supersedesSkillId: id,
|
|
1954
|
+
}, ctx.actor.subject);
|
|
1955
|
+
sendJson(res, 200, { skill });
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
async function handleCloseSkill({ req, res, opts }, skillCloseMatch) {
|
|
1959
|
+
const id = parseInt(skillCloseMatch[1], 10);
|
|
1960
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1961
|
+
const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1962
|
+
sendJson(res, 200, { skill });
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
async function handleGetSkill({ req, res, opts }, skillByIdMatch) {
|
|
1966
|
+
const id = parseInt(skillByIdMatch[1], 10);
|
|
1967
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
1968
|
+
const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
|
|
1969
|
+
if (!skill) {
|
|
1970
|
+
throw new HttpError(404, `skill ${id} not found`);
|
|
1971
|
+
}
|
|
1972
|
+
sendJson(res, 200, { skill });
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
// ── E2 project_brief routes ──
|
|
1976
|
+
//
|
|
1977
|
+
// 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
|
|
1978
|
+
// /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
|
|
1979
|
+
// /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
|
|
1980
|
+
// from the repo's receipts; dryRun returns {markdown} without writing; ordered
|
|
1981
|
+
// before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
|
|
1982
|
+
// POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
|
|
1983
|
+
// changeSummary 4096. The store validates + throws; the boundary maps validation
|
|
1984
|
+
// -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
|
|
1985
|
+
async function handleCreateProjectBrief({ req, res, opts }) {
|
|
1986
|
+
const body = await parseJsonBody(req);
|
|
1987
|
+
const repo = body['repo'];
|
|
1988
|
+
if (!isJsonString(repo) || repo.trim().length === 0) {
|
|
1989
|
+
throw new HttpError(400, 'repo is required (non-empty string)');
|
|
1990
|
+
}
|
|
1991
|
+
if (repo.length > 256) {
|
|
1992
|
+
throw new HttpError(400, 'repo exceeds 256-character cap');
|
|
1993
|
+
}
|
|
1994
|
+
const summary = body['summary'];
|
|
1995
|
+
if (!isJsonString(summary) || summary.trim().length === 0) {
|
|
1996
|
+
throw new HttpError(400, 'summary is required (non-empty string)');
|
|
1997
|
+
}
|
|
1998
|
+
if (summary.length > 8192) {
|
|
1999
|
+
throw new HttpError(400, 'summary exceeds 8192-character cap');
|
|
2000
|
+
}
|
|
2001
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2002
|
+
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2003
|
+
repo,
|
|
2004
|
+
summary,
|
|
2005
|
+
}, ctx.actor.subject);
|
|
2006
|
+
sendJson(res, 201, { brief });
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
async function handleListProjectBriefs({ req, res, opts, query }) {
|
|
2010
|
+
const status = query.get('status') ?? 'all';
|
|
2011
|
+
const repoFilter = query.get('repo');
|
|
2012
|
+
const limit = parseListLimit(query.get('limit'));
|
|
2013
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2014
|
+
const listOpts = { limit };
|
|
2015
|
+
if (repoFilter !== null && repoFilter.trim().length > 0) {
|
|
2016
|
+
listOpts.repo = repoFilter.trim();
|
|
2017
|
+
}
|
|
2018
|
+
if (status !== 'all') {
|
|
2019
|
+
if (!isSetMember(VALID_BRIEF_STATES, status)) {
|
|
2020
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
2021
|
+
}
|
|
2022
|
+
listOpts.status = status;
|
|
2023
|
+
}
|
|
2024
|
+
const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
|
|
2025
|
+
sendJson(res, 200, { briefs });
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
// The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
|
|
2029
|
+
// so the /(\d+)/ routes would not match it, but order it first).
|
|
2030
|
+
async function handleRefreshProjectBrief({ req, res, opts }) {
|
|
2031
|
+
const body = await parseJsonBody(req);
|
|
2032
|
+
const repo = body['repo'];
|
|
2033
|
+
if (!isJsonString(repo) || repo.trim().length === 0) {
|
|
2034
|
+
throw new HttpError(400, 'repo is required (non-empty string)');
|
|
2035
|
+
}
|
|
2036
|
+
if (repo.length > 256) {
|
|
2037
|
+
throw new HttpError(400, 'repo exceeds 256-character cap');
|
|
2038
|
+
}
|
|
2039
|
+
const dryRun = body['dryRun'] === true;
|
|
2040
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2041
|
+
if (dryRun) {
|
|
2042
|
+
const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
|
|
2043
|
+
sendJson(res, 200, { markdown, receiptCount });
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
|
|
2047
|
+
sendJson(res, 200, { brief });
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMatch) {
|
|
2051
|
+
const id = parseInt(briefSupersedeMatch[1], 10);
|
|
2052
|
+
const body = await parseJsonBody(req);
|
|
2053
|
+
const summary = body['summary'];
|
|
2054
|
+
if (!isJsonString(summary) || summary.trim().length === 0) {
|
|
2055
|
+
throw new HttpError(400, 'summary is required (non-empty string)');
|
|
2056
|
+
}
|
|
2057
|
+
if (summary.length > 8192) {
|
|
2058
|
+
throw new HttpError(400, 'summary exceeds 8192-character cap');
|
|
2059
|
+
}
|
|
2060
|
+
const changeRaw = body['changeSummary'];
|
|
2061
|
+
let changeSummary;
|
|
2062
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2063
|
+
if (!isJsonString(changeRaw)) {
|
|
2064
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
2065
|
+
}
|
|
2066
|
+
if (changeRaw.length > 4096) {
|
|
2067
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2068
|
+
}
|
|
2069
|
+
changeSummary = changeRaw;
|
|
2070
|
+
}
|
|
2071
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2072
|
+
const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
|
|
2073
|
+
if (!existing) {
|
|
2074
|
+
throw new HttpError(404, `project brief ${id} not found`);
|
|
2075
|
+
}
|
|
2076
|
+
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2077
|
+
repo: existing.repo,
|
|
2078
|
+
summary,
|
|
2079
|
+
changeSummary,
|
|
2080
|
+
supersedesBriefId: id,
|
|
2081
|
+
}, ctx.actor.subject);
|
|
2082
|
+
sendJson(res, 200, { brief });
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
async function handleCloseProjectBrief({ req, res, opts }, briefCloseMatch) {
|
|
2086
|
+
const id = parseInt(briefCloseMatch[1], 10);
|
|
2087
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2088
|
+
const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2089
|
+
sendJson(res, 200, { brief });
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
async function handleGetProjectBrief({ req, res, opts }, briefByIdMatch) {
|
|
2093
|
+
const id = parseInt(briefByIdMatch[1], 10);
|
|
2094
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2095
|
+
const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
|
|
2096
|
+
if (!brief) {
|
|
2097
|
+
throw new HttpError(404, `project brief ${id} not found`);
|
|
2098
|
+
}
|
|
2099
|
+
sendJson(res, 200, { brief });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
// ── E2 customer_note routes ──
|
|
2103
|
+
//
|
|
2104
|
+
// 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
|
|
2105
|
+
// note), GET /v1/customer-notes (list; status + customer filter; shared
|
|
2106
|
+
// parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
|
|
2107
|
+
// POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
|
|
2108
|
+
// changeSummary 4096. The store validates + throws; the boundary maps validation ->
|
|
2109
|
+
// 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
|
|
2110
|
+
async function handleCreateCustomerNote({ req, res, opts }) {
|
|
2111
|
+
const body = await parseJsonBody(req);
|
|
2112
|
+
const customer = body['customer'];
|
|
2113
|
+
if (!isJsonString(customer) || customer.trim().length === 0) {
|
|
2114
|
+
throw new HttpError(400, 'customer is required (non-empty string)');
|
|
2115
|
+
}
|
|
2116
|
+
if (customer.length > 256) {
|
|
2117
|
+
throw new HttpError(400, 'customer exceeds 256-character cap');
|
|
2118
|
+
}
|
|
2119
|
+
const note = body['note'];
|
|
2120
|
+
if (!isJsonString(note) || note.trim().length === 0) {
|
|
2121
|
+
throw new HttpError(400, 'note is required (non-empty string)');
|
|
2122
|
+
}
|
|
2123
|
+
if (note.length > 8192) {
|
|
2124
|
+
throw new HttpError(400, 'note exceeds 8192-character cap');
|
|
2125
|
+
}
|
|
2126
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2127
|
+
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2128
|
+
customer,
|
|
2129
|
+
note,
|
|
2130
|
+
}, ctx.actor.subject);
|
|
2131
|
+
sendJson(res, 201, { note: customerNote });
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
async function handleListCustomerNotes({ req, res, opts, query }) {
|
|
2135
|
+
const status = query.get('status') ?? 'all';
|
|
2136
|
+
const customerFilter = query.get('customer');
|
|
2137
|
+
const limit = parseListLimit(query.get('limit'));
|
|
2138
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2139
|
+
const listOpts = { limit };
|
|
2140
|
+
if (customerFilter !== null && customerFilter.trim().length > 0) {
|
|
2141
|
+
listOpts.customer = customerFilter.trim();
|
|
2142
|
+
}
|
|
2143
|
+
if (status !== 'all') {
|
|
2144
|
+
if (!isSetMember(VALID_NOTE_STATES, status)) {
|
|
2145
|
+
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
2146
|
+
}
|
|
2147
|
+
listOpts.status = status;
|
|
2148
|
+
}
|
|
2149
|
+
const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
|
|
2150
|
+
sendJson(res, 200, { notes });
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
async function handleSupersedeCustomerNote({ req, res, opts }, noteSupersedeMatch) {
|
|
2154
|
+
const id = parseInt(noteSupersedeMatch[1], 10);
|
|
2155
|
+
const body = await parseJsonBody(req);
|
|
2156
|
+
const note = body['note'];
|
|
2157
|
+
if (!isJsonString(note) || note.trim().length === 0) {
|
|
2158
|
+
throw new HttpError(400, 'note is required (non-empty string)');
|
|
2159
|
+
}
|
|
2160
|
+
if (note.length > 8192) {
|
|
2161
|
+
throw new HttpError(400, 'note exceeds 8192-character cap');
|
|
2162
|
+
}
|
|
2163
|
+
const changeRaw = body['changeSummary'];
|
|
2164
|
+
let changeSummary;
|
|
2165
|
+
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2166
|
+
if (!isJsonString(changeRaw)) {
|
|
2167
|
+
throw new HttpError(400, 'changeSummary must be a string');
|
|
2168
|
+
}
|
|
2169
|
+
if (changeRaw.length > 4096) {
|
|
2170
|
+
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2171
|
+
}
|
|
2172
|
+
changeSummary = changeRaw;
|
|
2173
|
+
}
|
|
2174
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2175
|
+
const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
|
|
2176
|
+
if (!existing) {
|
|
2177
|
+
throw new HttpError(404, `customer note ${id} not found`);
|
|
2178
|
+
}
|
|
2179
|
+
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2180
|
+
customer: existing.customer,
|
|
2181
|
+
note,
|
|
2182
|
+
changeSummary,
|
|
2183
|
+
supersedesNoteId: id,
|
|
2184
|
+
}, ctx.actor.subject);
|
|
2185
|
+
sendJson(res, 200, { note: customerNote });
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
async function handleCloseCustomerNote({ req, res, opts }, noteCloseMatch) {
|
|
2189
|
+
const id = parseInt(noteCloseMatch[1], 10);
|
|
2190
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2191
|
+
const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2192
|
+
sendJson(res, 200, { note: customerNote });
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
async function handleGetCustomerNote({ req, res, opts }, noteByIdMatch) {
|
|
2196
|
+
const id = parseInt(noteByIdMatch[1], 10);
|
|
2197
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
2198
|
+
const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
|
|
2199
|
+
if (!customerNote) {
|
|
2200
|
+
throw new HttpError(404, `customer note ${id} not found`);
|
|
2201
|
+
}
|
|
2202
|
+
sendJson(res, 200, { note: customerNote });
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
/** The /v1 routes in dispatch order; the first entry whose method and path match handles the request. */
|
|
2206
|
+
const V1_ROUTES = [
|
|
2207
|
+
{ method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
|
|
2208
|
+
{ method: 'GET', path: '/v1/graph', handler: handleGetGraph },
|
|
2209
|
+
{ method: 'GET', path: '/v1/memories', handler: handleRecallMemories },
|
|
2210
|
+
{ method: 'GET', pattern: '/v1/sessions/:id/assemble', handler: handleAssembleSession },
|
|
2211
|
+
{ method: 'GET', pattern: '/v1/recall/drill/:id', handler: handleDrillRecall },
|
|
2212
|
+
{ method: 'POST', pattern: '/v1/memories/:id/archive', handler: handleArchiveMemory },
|
|
2213
|
+
{ method: 'POST', pattern: '/v1/memories/:id/supersede', handler: handleSupersedeMemory },
|
|
2214
|
+
{ method: 'POST', pattern: '/v1/memories/:id/promote', handler: handlePromoteMemory },
|
|
2215
|
+
{ method: 'DELETE', pattern: '/v1/memories/:id', handler: handleForgetMemory },
|
|
2216
|
+
{ method: 'POST', path: '/v1/outcome', handler: handleApplyOutcome },
|
|
2217
|
+
{ method: 'GET', path: '/v1/context', handler: handleGetContext },
|
|
2218
|
+
{ method: 'POST', path: '/v1/sleep', handler: handleSleep },
|
|
2219
|
+
{ method: 'POST', path: '/v1/auth/keys', handler: handleCreateAuthKey },
|
|
2220
|
+
{ method: 'GET', path: '/v1/auth/keys', handler: handleListAuthKeys },
|
|
2221
|
+
{ method: 'DELETE', pattern: '/v1/auth/keys/:keyId', handler: handleRevokeAuthKey },
|
|
2222
|
+
{ method: 'GET', path: '/v1/quarantine', handler: handleListQuarantine },
|
|
2223
|
+
{ method: 'POST', pattern: '/v1/quarantine/:id/approve', handler: handleApproveQuarantine },
|
|
2224
|
+
{ method: 'POST', pattern: '/v1/quarantine/:id/reject', handler: handleRejectQuarantine },
|
|
2225
|
+
{ method: 'GET', path: '/v1/audit', handler: handleListAudit },
|
|
2226
|
+
{ method: 'POST', path: '/v1/predictions', handler: handleCreatePrediction },
|
|
2227
|
+
{ method: 'GET', path: '/v1/predictions', handler: handleListPredictions },
|
|
2228
|
+
{ method: 'GET', path: '/v1/predictions/stats', handler: handlePredictionStats },
|
|
2229
|
+
{ method: 'GET', regex: /^\/v1\/predictions\/(\d+)$/, handler: handleGetPrediction },
|
|
2230
|
+
{ method: 'POST', regex: /^\/v1\/predictions\/(\d+)\/close$/, handler: handleClosePrediction },
|
|
2231
|
+
{ method: 'POST', path: '/v1/decisions', handler: handleCreateDecision },
|
|
2232
|
+
{ method: 'GET', path: '/v1/decisions', handler: handleListDecisions },
|
|
2233
|
+
{ method: 'POST', regex: /^\/v1\/decisions\/(\d+)\/supersede$/, handler: handleSupersedeDecision },
|
|
2234
|
+
{ method: 'POST', regex: /^\/v1\/decisions\/(\d+)\/close$/, handler: handleCloseDecision },
|
|
2235
|
+
{ method: 'GET', regex: /^\/v1\/decisions\/(\d+)$/, handler: handleGetDecision },
|
|
2236
|
+
{ method: 'POST', path: '/v1/incidents', handler: handleCreateIncident },
|
|
2237
|
+
{ method: 'GET', path: '/v1/incidents', handler: handleListIncidents },
|
|
2238
|
+
{ method: 'POST', regex: /^\/v1\/incidents\/(\d+)\/resolve$/, handler: handleResolveIncident },
|
|
2239
|
+
{ method: 'POST', regex: /^\/v1\/incidents\/(\d+)\/close$/, handler: handleCloseIncident },
|
|
2240
|
+
{ method: 'GET', regex: /^\/v1\/incidents\/(\d+)$/, handler: handleGetIncident },
|
|
2241
|
+
{ method: 'POST', path: '/v1/processes', handler: handleCreateProcess },
|
|
2242
|
+
{ method: 'GET', path: '/v1/processes', handler: handleListProcesses },
|
|
2243
|
+
{ method: 'POST', regex: /^\/v1\/processes\/(\d+)\/supersede$/, handler: handleSupersedeProcess },
|
|
2244
|
+
{ method: 'POST', regex: /^\/v1\/processes\/(\d+)\/close$/, handler: handleCloseProcess },
|
|
2245
|
+
{ method: 'GET', regex: /^\/v1\/processes\/(\d+)$/, handler: handleGetProcess },
|
|
2246
|
+
{ method: 'POST', path: '/v1/policies', handler: handleCreatePolicy },
|
|
2247
|
+
{ method: 'GET', path: '/v1/policies', handler: handleListPolicies },
|
|
2248
|
+
{ method: 'GET', path: '/v1/policies/asof', handler: handlePoliciesAsOf },
|
|
2249
|
+
{ method: 'POST', regex: /^\/v1\/policies\/(\d+)\/supersede$/, handler: handleSupersedePolicy },
|
|
2250
|
+
{ method: 'POST', regex: /^\/v1\/policies\/(\d+)\/close$/, handler: handleClosePolicy },
|
|
2251
|
+
{ method: 'GET', regex: /^\/v1\/policies\/(\d+)$/, handler: handleGetPolicy },
|
|
2252
|
+
{ method: 'POST', path: '/v1/skills', handler: handleCreateSkill },
|
|
2253
|
+
{ method: 'GET', path: '/v1/skills', handler: handleListSkills },
|
|
2254
|
+
{ method: 'GET', path: '/v1/skills/export', handler: handleExportSkills },
|
|
2255
|
+
{ method: 'POST', regex: /^\/v1\/skills\/(\d+)\/supersede$/, handler: handleSupersedeSkill },
|
|
2256
|
+
{ method: 'POST', regex: /^\/v1\/skills\/(\d+)\/close$/, handler: handleCloseSkill },
|
|
2257
|
+
{ method: 'GET', regex: /^\/v1\/skills\/(\d+)$/, handler: handleGetSkill },
|
|
2258
|
+
{ method: 'POST', path: '/v1/project-briefs', handler: handleCreateProjectBrief },
|
|
2259
|
+
{ method: 'GET', path: '/v1/project-briefs', handler: handleListProjectBriefs },
|
|
2260
|
+
{ method: 'POST', path: '/v1/project-briefs/refresh', handler: handleRefreshProjectBrief },
|
|
2261
|
+
{ method: 'POST', regex: /^\/v1\/project-briefs\/(\d+)\/supersede$/, handler: handleSupersedeProjectBrief },
|
|
2262
|
+
{ method: 'POST', regex: /^\/v1\/project-briefs\/(\d+)\/close$/, handler: handleCloseProjectBrief },
|
|
2263
|
+
{ method: 'GET', regex: /^\/v1\/project-briefs\/(\d+)$/, handler: handleGetProjectBrief },
|
|
2264
|
+
{ method: 'POST', path: '/v1/customer-notes', handler: handleCreateCustomerNote },
|
|
2265
|
+
{ method: 'GET', path: '/v1/customer-notes', handler: handleListCustomerNotes },
|
|
2266
|
+
{ method: 'POST', regex: /^\/v1\/customer-notes\/(\d+)\/supersede$/, handler: handleSupersedeCustomerNote },
|
|
2267
|
+
{ method: 'POST', regex: /^\/v1\/customer-notes\/(\d+)\/close$/, handler: handleCloseCustomerNote },
|
|
2268
|
+
{ method: 'GET', regex: /^\/v1\/customer-notes\/(\d+)$/, handler: handleGetCustomerNote },
|
|
2269
|
+
];
|
|
2270
|
+
/**
|
|
2271
|
+
* Run the first /v1 route whose method and path match. Each matcher runs before its method check, as the
|
|
2272
|
+
* inline route blocks did, so a malformed `%` escape still throws from matchPath on any method.
|
|
2273
|
+
*/
|
|
2274
|
+
async function dispatchV1Route(r, method, path) {
|
|
2275
|
+
for (const route of V1_ROUTES) {
|
|
2276
|
+
if ('path' in route) {
|
|
2277
|
+
if (method === route.method && path === route.path) {
|
|
2278
|
+
await route.handler(r);
|
|
2279
|
+
return true;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
else if ('pattern' in route) {
|
|
2283
|
+
const params = matchPath(route.pattern, path);
|
|
2284
|
+
if (method === route.method && params) {
|
|
2285
|
+
await route.handler(r, params);
|
|
2286
|
+
return true;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
else {
|
|
2290
|
+
const match = path.match(route.regex);
|
|
2291
|
+
if (method === route.method && match) {
|
|
2292
|
+
await route.handler(r, match);
|
|
2293
|
+
return true;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
return false;
|
|
2298
|
+
}
|
|
612
2299
|
async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
613
2300
|
// v1.6.4: pre-decode raw-URL slash check. Catches `%2F` / `%2f` before
|
|
614
2301
|
// Node's URL parser collapses them and they slip past the route table.
|
|
@@ -625,6 +2312,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
625
2312
|
version: VERSION,
|
|
626
2313
|
started_at: startedAt,
|
|
627
2314
|
pid: process.pid,
|
|
2315
|
+
audit_write_failures: auditWriteFailureCount(),
|
|
628
2316
|
});
|
|
629
2317
|
}
|
|
630
2318
|
else {
|
|
@@ -647,2471 +2335,77 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
647
2335
|
throw new HttpError(429, 'rate limit exceeded');
|
|
648
2336
|
}
|
|
649
2337
|
}
|
|
650
|
-
|
|
651
|
-
if (method === 'POST' && path === '/v1/memories') {
|
|
652
|
-
const body = await parseJsonBody(req);
|
|
653
|
-
const content = getString(body, 'content');
|
|
654
|
-
if (!content) {
|
|
655
|
-
throw new HttpError(400, 'content is required');
|
|
656
|
-
}
|
|
657
|
-
const kindRaw = getString(body, 'kind');
|
|
658
|
-
if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
|
|
659
|
-
throw new HttpError(400, `invalid kind: ${kindRaw}`);
|
|
660
|
-
}
|
|
661
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
662
|
-
const result = remember(ctx, {
|
|
663
|
-
content,
|
|
664
|
-
kind: kindRaw,
|
|
665
|
-
scope: getString(body, 'scope'),
|
|
666
|
-
owner: getString(body, 'owner'),
|
|
667
|
-
artifactRef: getString(body, 'artifactRef'),
|
|
668
|
-
tags: getStringArray(body, 'tags'),
|
|
669
|
-
});
|
|
670
|
-
sendJson(res, 200, result);
|
|
2338
|
+
if (await dispatchV1Route({ req, res, opts, query }, method, path))
|
|
671
2339
|
return;
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
// long decision/policy name remains focusable over HTTP (codex P2).
|
|
678
|
-
if (entityRaw !== null && entityRaw.length > MAX_ENTITY_NAME_LEN) {
|
|
679
|
-
throw new HttpError(400, `entity exceeds the ${MAX_ENTITY_NAME_LEN}-character cap`);
|
|
2340
|
+
if (method === 'POST' && path === '/v1/connectors/slack/events') {
|
|
2341
|
+
// Bearer auth deliberately skipped: this route is in PUBLIC_ROUTES and authenticates via the Slack HMAC signature.
|
|
2342
|
+
if (!isPublicRoute(method, path)) {
|
|
2343
|
+
// Defensive: PUBLIC_ROUTES drift would land here. Fail closed.
|
|
2344
|
+
throw new HttpError(401, 'auth required');
|
|
680
2345
|
}
|
|
681
|
-
|
|
682
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
683
|
-
const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
|
|
684
|
-
entity: entityRaw ?? undefined,
|
|
685
|
-
limit,
|
|
686
|
-
});
|
|
687
|
-
sendJson(res, 200, model);
|
|
2346
|
+
await handleSlackEventsWebhook({ req, res, opts });
|
|
688
2347
|
return;
|
|
689
2348
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (!q) {
|
|
694
|
-
throw new HttpError(400, 'q is required');
|
|
695
|
-
}
|
|
696
|
-
const limitRaw = query.get('limit');
|
|
697
|
-
const limit = limitRaw === null ? undefined : parseListLimit(limitRaw);
|
|
698
|
-
const mode = query.get('mode');
|
|
699
|
-
if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
|
|
700
|
-
throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
|
|
701
|
-
}
|
|
702
|
-
const scope = query.get('scope');
|
|
703
|
-
const includeContinuityRaw = query.get('include_continuity');
|
|
704
|
-
const includeContinuity = includeContinuityRaw === '1'
|
|
705
|
-
|| includeContinuityRaw === 'true';
|
|
706
|
-
// v1.6.2: surface the v1.5.0/v1.5.2 RecallOpts additions to HTTP
|
|
707
|
-
// callers. Pre-v1.6.2 the route silently ignored these so the
|
|
708
|
-
// session-scoped fresh-tail and summary substitution were JS-only.
|
|
709
|
-
const freshTailCountRaw = query.get('fresh_tail_count');
|
|
710
|
-
const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
|
|
711
|
-
if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
|
|
712
|
-
throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
|
|
713
|
-
}
|
|
714
|
-
// v1.6.3 senior-review P1-3: cap session_id length consistent with the
|
|
715
|
-
// rest of the API. Untrimmed strings round-trip through the SQL layer
|
|
716
|
-
// and through any downstream metric/log; 256 is generous for a session
|
|
717
|
-
// id and matches the rest of this file's id-shaped param parsers.
|
|
718
|
-
const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
|
|
719
|
-
if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
|
|
720
|
-
throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
|
|
721
|
-
}
|
|
722
|
-
const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
|
|
723
|
-
? freshTailSessionIdRaw
|
|
724
|
-
: undefined;
|
|
725
|
-
// v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
|
|
726
|
-
// convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
|
|
727
|
-
// so `?summarize_overflow=banana` and `?summarize_overflow=` both
|
|
728
|
-
// turned it on. Surface convention drift fixed.
|
|
729
|
-
const summarizeOverflowRaw = query.get('summarize_overflow');
|
|
730
|
-
const summarizeOverflow = summarizeOverflowRaw === null
|
|
731
|
-
? undefined
|
|
732
|
-
: (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
|
|
733
|
-
// recall() owns the shape rule (NaN, 0 and negatives throw invalid_scorer_window); the transport caps remote cost.
|
|
734
|
-
const scorerWindowRaw = query.get('scorer_window');
|
|
735
|
-
const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
|
|
736
|
-
if (scorerWindow !== undefined && scorerWindow > 1000) {
|
|
737
|
-
throw new HttpError(400, 'scorer_window must be <= 1000');
|
|
738
|
-
}
|
|
739
|
-
// v1.7.4: session_id for the dlPFC goal-stack boost. 256-char cap mirrors
|
|
740
|
-
// fresh_tail_session_id (above). Trim then drop if empty so api.recall
|
|
741
|
-
// sees undefined when the param is omitted or whitespace-only.
|
|
742
|
-
const sessionIdRaw = query.get('session_id');
|
|
743
|
-
if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
|
|
744
|
-
throw new HttpError(400, 'session_id exceeds 256-character cap');
|
|
745
|
-
}
|
|
746
|
-
const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
|
|
747
|
-
? sessionIdRaw.trim()
|
|
748
|
-
: undefined;
|
|
749
|
-
// A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
|
|
750
|
-
// lifecycle re-ranking trace (goal-boost step on the api pipeline) +
|
|
751
|
-
// rerankPipeline:'api' to each result item; the field then rides on the
|
|
752
|
-
// serialized RecallResult. Mirrors the include_continuity convention.
|
|
753
|
-
const explainRaw = query.get('explain');
|
|
754
|
-
const explain = explainRaw === '1' || explainRaw === 'true';
|
|
755
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
756
|
-
// v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
|
|
757
|
-
// ring snapshot via opts.recallHistory so api.recall's own
|
|
758
|
-
// anchoringHint compute path activates. Unlike CLI (which computes
|
|
759
|
-
// its own hint separately because cmdRecall runs its own physics/
|
|
760
|
-
// hybrid pipeline outside api.recall), HTTP's /v1/memories response
|
|
761
|
-
// body IS api.recall's result directly. So the api.recall-computed
|
|
762
|
-
// hint flows through. HIPPO_ANCHORING=off short-circuits.
|
|
763
|
-
let httpRecallHistory;
|
|
764
|
-
let httpRingKey;
|
|
765
|
-
if (process.env.HIPPO_ANCHORING !== 'off') {
|
|
766
|
-
if (sessionId) {
|
|
767
|
-
// Codex round-5 P2 catch: do NOT mutate sessionRecallHistoryHttp
|
|
768
|
-
// before recall() preflight runs. A request with an invalid
|
|
769
|
-
// scorer_window / fresh_tail_count would create-or-touch the
|
|
770
|
-
// session ring (LRU-evicting valid sessions) even though recall
|
|
771
|
-
// throws 400. Snapshot the EXISTING ring if present; only
|
|
772
|
-
// create-or-touch after the recall returns successfully.
|
|
773
|
-
httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
|
|
774
|
-
const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
|
|
775
|
-
httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
|
|
776
|
-
}
|
|
777
|
-
else {
|
|
778
|
-
// Telemetry: caller had no session_id so ring tracking skipped.
|
|
779
|
-
// Per the normal recall-audit convention (api.ts:854 stores
|
|
780
|
-
// SHA-256/16 hash of the query, NOT raw text), avoid retaining
|
|
781
|
-
// prompts in audit_log here too — query content can contain
|
|
782
|
-
// secrets, PII, or RTBF-restricted material. Codex round-2 P2
|
|
783
|
-
// catch: hashQueryText is a 32-bit FNV-1a designed for recall
|
|
784
|
-
// matching, NOT a privacy hash; brute-force trivial for low-
|
|
785
|
-
// entropy queries. Use the same SHA-256/16 truncation as the
|
|
786
|
-
// canonical recall audit.
|
|
787
|
-
const dbForAudit = openHippoDb(opts.hippoRoot);
|
|
788
|
-
try {
|
|
789
|
-
appendAuditEvent(dbForAudit, {
|
|
790
|
-
tenantId: ctx.tenantId,
|
|
791
|
-
actor: ctx.actor.subject,
|
|
792
|
-
op: 'recall_anchor_skipped_no_session',
|
|
793
|
-
targetId: undefined,
|
|
794
|
-
metadata: auditQueryFields(q),
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
finally {
|
|
798
|
-
closeHippoDb(dbForAudit);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
2349
|
+
if (method === 'POST' && path === '/v1/connectors/github/events') {
|
|
2350
|
+
if (!isPublicRoute(method, path)) {
|
|
2351
|
+
throw new HttpError(401, 'auth required');
|
|
801
2352
|
}
|
|
802
|
-
|
|
803
|
-
if (freshTailCount !== undefined)
|
|
804
|
-
recallExtra.freshTailCount = freshTailCount;
|
|
805
|
-
if (freshTailSessionId !== undefined)
|
|
806
|
-
recallExtra.freshTailSessionId = freshTailSessionId;
|
|
807
|
-
if (summarizeOverflow !== undefined)
|
|
808
|
-
recallExtra.summarizeOverflow = summarizeOverflow;
|
|
809
|
-
if (scorerWindow !== undefined)
|
|
810
|
-
recallExtra.scorerWindow = scorerWindow;
|
|
811
|
-
if (sessionId !== undefined)
|
|
812
|
-
recallExtra.sessionId = sessionId;
|
|
813
|
-
if (httpRecallHistory !== undefined)
|
|
814
|
-
recallExtra.recallHistory = httpRecallHistory;
|
|
815
|
-
if (explain)
|
|
816
|
-
recallExtra.explain = explain;
|
|
817
|
-
const result = await retrieve(ctx, {
|
|
818
|
-
query: q,
|
|
819
|
-
limit,
|
|
820
|
-
mode: mode ?? undefined,
|
|
821
|
-
scope: scope ?? undefined,
|
|
822
|
-
includeContinuity,
|
|
823
|
-
...recallExtra,
|
|
824
|
-
});
|
|
825
|
-
// v0.33 / J1 — append AFTER recall completes (snapshot was taken before
|
|
826
|
-
// recall() ran). anchoredOn carries the memoryId of any hint that fired
|
|
827
|
-
// (api.recall computed it from the same snapshot we passed in), feeding
|
|
828
|
-
// the cooldown logic for the NEXT recall on this session.
|
|
829
|
-
// Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
|
|
830
|
-
// returns successfully. Invalid requests that throw 400 in recall()
|
|
831
|
-
// never reach this point, so they cannot LRU-evict valid sessions.
|
|
832
|
-
if (httpRingKey) {
|
|
833
|
-
const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
|
|
834
|
-
const topId = result.results[0]?.id ?? null;
|
|
835
|
-
appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
|
|
836
|
-
}
|
|
837
|
-
// Each recall surface counts its own hits; api.recall is no chokepoint,
|
|
838
|
-
// since the CLI never calls it and MCP shows the user a different band.
|
|
839
|
-
updateStats(opts.hippoRoot, { recalled: result.results.length });
|
|
840
|
-
// Continuity payloads should never be cached. The caller is asking for
|
|
841
|
-
// session-state-aware data; intermediaries must not reuse it across users.
|
|
842
|
-
if (includeContinuity) {
|
|
843
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
844
|
-
}
|
|
845
|
-
recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
|
|
846
|
-
sendJson(res, 200, result);
|
|
2353
|
+
await handleGitHubEventsWebhook({ req, res, opts });
|
|
847
2354
|
return;
|
|
848
2355
|
}
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
: (sumOlderRaw === '1' || sumOlderRaw === 'true');
|
|
874
|
-
const scopeQ = query.get('scope');
|
|
875
|
-
const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
|
|
2356
|
+
// ── MCP-over-HTTP/SSE transport (Task 11) ──
|
|
2357
|
+
//
|
|
2358
|
+
// Two routes implement an MCP HTTP transport alongside the stdio one. Both
|
|
2359
|
+
// dispatch to the same `handleMcpRequest` as the stdio loop in src/mcp/server.ts.
|
|
2360
|
+
//
|
|
2361
|
+
// POST /mcp — Send a JSON-RPC request, get a JSON-RPC response synchronously
|
|
2362
|
+
// in the body. Content-type: application/json both ways.
|
|
2363
|
+
// GET /mcp/stream — Open an SSE stream for server-initiated messages.
|
|
2364
|
+
// v1 simplification: this stream is keepalive-only. Clients
|
|
2365
|
+
// that need server-pushed notifications/progress will see
|
|
2366
|
+
// only `: ping` comments every 30s. All real responses come
|
|
2367
|
+
// back synchronously on POST /mcp. This matches the
|
|
2368
|
+
// "synchronous JSON in body" leg of the MCP HTTP spec and
|
|
2369
|
+
// is enough for `tools/list` / `tools/call` round-trips.
|
|
2370
|
+
// Server-initiated SSE messages will be wired in a later task.
|
|
2371
|
+
//
|
|
2372
|
+
// Auth: same as /v1/* — Bearer token validated via `requireAuth`, with the
|
|
2373
|
+
// loopback no-auth fallback. SSE check runs once at stream-open.
|
|
2374
|
+
if (method === 'POST' && path === '/mcp') {
|
|
2375
|
+
// Build the same Context the /v1/* routes use so MCP tool calls inherit
|
|
2376
|
+
// the server's bound hippoRoot and the auth-resolved tenantId / actor.
|
|
2377
|
+
// Without this, executeTool would walk from cwd via findHippoRoot() and
|
|
2378
|
+
// pull tenant from HIPPO_TENANT, dropping a valid Bearer for tenant B
|
|
2379
|
+
// back to whatever the env says.
|
|
876
2380
|
const ctx = await buildContextWithAuth(req, opts);
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
assembleExtra.freshTailCount = freshTailCount;
|
|
882
|
-
if (summarizeOlder !== undefined)
|
|
883
|
-
assembleExtra.summarizeOlder = summarizeOlder;
|
|
884
|
-
if (scope !== undefined)
|
|
885
|
-
assembleExtra.scope = scope;
|
|
886
|
-
const result = assemble(ctx, assembleMatch.id, { ...assembleExtra, cost: assembleCost(assembleMatch.id) });
|
|
887
|
-
recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
|
|
888
|
-
sendJson(res, 200, result);
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
// GET /v1/recall/drill/:id?limit=N&budget=N
|
|
892
|
-
// Companion to /v1/memories. When recall surfaces a level-2 summary in
|
|
893
|
-
// place of overflowed children (RecallResultItem.isSummary === true), the
|
|
894
|
-
// caller drills into the summary id to recover the originals. Tenant
|
|
895
|
-
// scoped via Bearer; default-deny on private scopes for both summary
|
|
896
|
-
// and children.
|
|
897
|
-
const drillMatch = matchPath('/v1/recall/drill/:id', path);
|
|
898
|
-
if (method === 'GET' && drillMatch) {
|
|
899
|
-
validateIdSegment(drillMatch.id, 'summary id');
|
|
900
|
-
const limitRaw = query.get('limit');
|
|
901
|
-
const limit = limitRaw === null ? undefined : Number(limitRaw);
|
|
902
|
-
if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
|
|
903
|
-
throw new HttpError(400, 'limit must be a positive number');
|
|
2381
|
+
const raw = await readBody(req);
|
|
2382
|
+
let mcpReq;
|
|
2383
|
+
try {
|
|
2384
|
+
mcpReq = JSON.parse(raw);
|
|
904
2385
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
|
|
908
|
-
throw new HttpError(400, 'budget must be a positive number');
|
|
909
|
-
}
|
|
910
|
-
// v0.30 / E5: depth query param walks N levels (default 1, hard cap 10).
|
|
911
|
-
const depthRaw = query.get('depth');
|
|
912
|
-
let depth;
|
|
913
|
-
if (depthRaw !== null) {
|
|
914
|
-
const parsed = Number(depthRaw);
|
|
915
|
-
// L4 fold: reject out-of-range explicitly (no silent clamp).
|
|
916
|
-
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
|
|
917
|
-
throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
|
|
918
|
-
}
|
|
919
|
-
depth = parsed;
|
|
2386
|
+
catch {
|
|
2387
|
+
throw new HttpError(400, 'invalid JSON-RPC body');
|
|
920
2388
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
if (limit !== undefined)
|
|
924
|
-
drillExtra.limit = limit;
|
|
925
|
-
if (budget !== undefined)
|
|
926
|
-
drillExtra.budget = budget;
|
|
927
|
-
if (depth !== undefined)
|
|
928
|
-
drillExtra.depth = depth;
|
|
929
|
-
const result = drillDown(ctx, drillMatch.id, { ...drillExtra, cost: drillCost });
|
|
930
|
-
if ('failure' in result) {
|
|
931
|
-
// v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
|
|
932
|
-
// as 404 to avoid leaking cross-tenant existence or scope grants.
|
|
933
|
-
if (result.failure === 'not_drillable') {
|
|
934
|
-
throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
|
|
935
|
-
}
|
|
936
|
-
throw new HttpError(404, 'No drillable summary at this id');
|
|
2389
|
+
if (!isJsonObjectRecord(mcpReq) || !isJsonString(mcpReq.method)) {
|
|
2390
|
+
throw new HttpError(400, 'JSON-RPC body must include a method string');
|
|
937
2391
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const supersedeMatch = matchPath('/v1/memories/:id/supersede', path);
|
|
956
|
-
if (method === 'POST' && supersedeMatch) {
|
|
957
|
-
validateIdSegment(supersedeMatch.id, 'memory id');
|
|
958
|
-
const body = await parseJsonBody(req);
|
|
959
|
-
const content = getString(body, 'content');
|
|
960
|
-
if (!content) {
|
|
961
|
-
throw new HttpError(400, 'content is required');
|
|
962
|
-
}
|
|
963
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
964
|
-
const result = supersede(ctx, supersedeMatch.id, content);
|
|
965
|
-
sendJson(res, 200, result);
|
|
966
|
-
return;
|
|
967
|
-
}
|
|
968
|
-
const promoteMatch = matchPath('/v1/memories/:id/promote', path);
|
|
969
|
-
if (method === 'POST' && promoteMatch) {
|
|
970
|
-
validateIdSegment(promoteMatch.id, 'memory id');
|
|
971
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
972
|
-
const result = promote(ctx, promoteMatch.id);
|
|
973
|
-
sendJson(res, 200, result);
|
|
974
|
-
return;
|
|
975
|
-
}
|
|
976
|
-
const idMatch = matchPath('/v1/memories/:id', path);
|
|
977
|
-
if (method === 'DELETE' && idMatch) {
|
|
978
|
-
validateIdSegment(idMatch.id, 'memory id');
|
|
979
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
980
|
-
const result = forget(ctx, idMatch.id);
|
|
981
|
-
sendJson(res, 200, result);
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
// POST /v1/outcome — apply a positive/negative outcome to memory ids.
|
|
985
|
-
// Body: {ids?: string[], good: boolean}. If ids omitted, falls back to
|
|
986
|
-
// the last-recall path (api.outcomeForLastRecall); returned shape is
|
|
987
|
-
// {applied, ids} in that case so callers can disambiguate "no recent
|
|
988
|
-
// recall" from "all ids skipped". Each applied id writes one audit_log
|
|
989
|
-
// row (op='outcome', actor from Bearer).
|
|
990
|
-
if (method === 'POST' && path === '/v1/outcome') {
|
|
991
|
-
const body = await parseJsonBody(req);
|
|
992
|
-
const good = body['good'];
|
|
993
|
-
if (!isJsonBoolean(good)) {
|
|
994
|
-
throw new HttpError(400, 'good is required (boolean)');
|
|
995
|
-
}
|
|
996
|
-
const idsRaw = body['ids'];
|
|
997
|
-
let ids;
|
|
998
|
-
if (idsRaw !== undefined) {
|
|
999
|
-
if (!Array.isArray(idsRaw)) {
|
|
1000
|
-
throw new HttpError(400, 'ids must be an array of non-empty strings');
|
|
1001
|
-
}
|
|
1002
|
-
const isNonEmptyId = (item) => isJsonString(item) && item.length > 0;
|
|
1003
|
-
if (!idsRaw.every(isNonEmptyId)) {
|
|
1004
|
-
throw new HttpError(400, 'ids must be an array of non-empty strings');
|
|
1005
|
-
}
|
|
1006
|
-
// v1.11.5: DoS cap on ids.length. Each id triggers ~3 DB ops (readEntry +
|
|
1007
|
-
// writeEntry + appendAuditEvent). N=1000 keeps per-request work bounded
|
|
1008
|
-
// to sub-second wall time on SQLite hot path. Cap BEFORE buildContextWithAuth
|
|
1009
|
-
// so attack traffic doesn't pay the api-key lookup cost.
|
|
1010
|
-
if (idsRaw.length > 1000) {
|
|
1011
|
-
throw new HttpError(400, 'ids exceeds 1000-id cap');
|
|
1012
|
-
}
|
|
1013
|
-
ids = idsRaw;
|
|
1014
|
-
}
|
|
1015
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1016
|
-
if (ids !== undefined) {
|
|
1017
|
-
const { applied } = outcome(ctx, ids, good);
|
|
1018
|
-
sendJson(res, 200, { applied });
|
|
1019
|
-
}
|
|
1020
|
-
else {
|
|
1021
|
-
const result = outcomeForLastRecall(ctx, good);
|
|
1022
|
-
sendJson(res, 200, result);
|
|
1023
|
-
}
|
|
1024
|
-
return;
|
|
1025
|
-
}
|
|
1026
|
-
// GET /v1/context — assemble a budget-bounded context bundle. Returns
|
|
1027
|
-
// ContextResult JSON (entries + tokens + activeSnapshot + sessionHandoff
|
|
1028
|
-
// + recentEvents). No server-side rendering; clients render. Tenant-scoped
|
|
1029
|
-
// via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
|
|
1030
|
-
// (matches cmdContext); real-query hybrid search emits one 'recall' row.
|
|
1031
|
-
if (method === 'GET' && path === '/v1/context') {
|
|
1032
|
-
const q = query.get('q') ?? undefined;
|
|
1033
|
-
// v1.11.5: DoS cap on q-param length. 1024 covers real multi-clause queries
|
|
1034
|
-
// (pasted error messages, multi-stem searches) while bounding BM25
|
|
1035
|
-
// tokenisation cost (~150 tokens worst case at 1024 chars).
|
|
1036
|
-
if (q !== undefined && q.length > 1024) {
|
|
1037
|
-
throw new HttpError(400, 'q exceeds 1024-character cap');
|
|
1038
|
-
}
|
|
1039
|
-
const budgetRaw = query.get('budget');
|
|
1040
|
-
let budget;
|
|
1041
|
-
if (budgetRaw !== null) {
|
|
1042
|
-
budget = Number(budgetRaw);
|
|
1043
|
-
if (!Number.isFinite(budget) || budget < 0) {
|
|
1044
|
-
throw new HttpError(400, 'budget must be a non-negative number');
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
const limitRaw = query.get('limit');
|
|
1048
|
-
let limit;
|
|
1049
|
-
if (limitRaw !== null) {
|
|
1050
|
-
limit = Number(limitRaw);
|
|
1051
|
-
if (!Number.isFinite(limit) || limit <= 0) {
|
|
1052
|
-
throw new HttpError(400, 'limit must be a positive number');
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
const pinnedOnlyRaw = query.get('pinned_only');
|
|
1056
|
-
const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
|
|
1057
|
-
const scopeRaw = query.get('scope');
|
|
1058
|
-
if (scopeRaw !== null && scopeRaw.length > 256) {
|
|
1059
|
-
throw new HttpError(400, 'scope exceeds 256-character cap');
|
|
1060
|
-
}
|
|
1061
|
-
const scope = scopeRaw === null ? undefined : scopeRaw;
|
|
1062
|
-
const includeRecentRaw = query.get('include_recent');
|
|
1063
|
-
let includeRecent;
|
|
1064
|
-
if (includeRecentRaw !== null) {
|
|
1065
|
-
includeRecent = Number(includeRecentRaw);
|
|
1066
|
-
if (!Number.isFinite(includeRecent) || includeRecent < 0) {
|
|
1067
|
-
throw new HttpError(400, 'include_recent must be a non-negative number');
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
// v39 memory scope isolation: cross_project=1|true re-includes
|
|
1071
|
-
// other-project rows (tagged category 'cross-project' in the response).
|
|
1072
|
-
// The partition identity comes from the SERVED STORE's location, not the
|
|
1073
|
-
// daemon's process cwd - a daemon started from anywhere still isolates
|
|
1074
|
-
// the project it serves.
|
|
1075
|
-
const crossProjectRaw = query.get('cross_project');
|
|
1076
|
-
const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
|
|
1077
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1078
|
-
const result = await getContext(ctx, {
|
|
1079
|
-
q,
|
|
1080
|
-
budget,
|
|
1081
|
-
limit,
|
|
1082
|
-
pinnedOnly,
|
|
1083
|
-
scope,
|
|
1084
|
-
includeRecent,
|
|
1085
|
-
crossProject,
|
|
1086
|
-
currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
|
|
1087
|
-
cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
|
|
1088
|
-
});
|
|
1089
|
-
recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
|
|
1090
|
-
sendJson(res, 200, result);
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
// POST /v1/sleep — host-wide consolidation pipeline (consolidate + dedup +
|
|
1094
|
-
// audit + share + ambient). serve() refuses non-loopback hosts at boot, AND
|
|
1095
|
-
// this per-request loopback assertion makes the host-wide semantic fail-
|
|
1096
|
-
// closed regardless of any future serve() boot-config change. Body:
|
|
1097
|
-
// {dry_run?, no_share?}. Returns SleepResult JSON.
|
|
1098
|
-
//
|
|
1099
|
-
// Tenant scope (Episode A follow-up tracked in TODOS.md): api.sleep operates
|
|
1100
|
-
// on the WHOLE hippoRoot (cross-tenant by design, matching CLI cmdSleep).
|
|
1101
|
-
// The loopback-only guard is the trust boundary today. Future non-loopback
|
|
1102
|
-
// serving must also zero the cross-tenant counters for other tenants
|
|
1103
|
-
// (D1 in docs/decisions/2026-05-24-blocked-items.md).
|
|
1104
|
-
if (method === 'POST' && path === '/v1/sleep') {
|
|
1105
|
-
// Defensive per-request loopback guard. Uses the canonical isLoopback()
|
|
1106
|
-
// helper above so any future extension (additional mapped/IPv6 forms,
|
|
1107
|
-
// NAT64 prefixes) flows through without drift. serve()'s boot-time host
|
|
1108
|
-
// check is the primary trust boundary; this is belt-and-suspenders.
|
|
1109
|
-
if (!isLoopback(req.socket.remoteAddress)) {
|
|
1110
|
-
throw new HttpError(403, '/v1/sleep is loopback-only (host-wide consolidation; see CHANGELOG v1.11.4)');
|
|
1111
|
-
}
|
|
1112
|
-
// v1.12.0 A5 v2 sub-1: admin-role gate. Forward-defensive — exists today
|
|
1113
|
-
// under loopback-only enforcement (loopback fallback is admin by default;
|
|
1114
|
-
// any Bearer-authed caller now carries an explicit role from the api_keys
|
|
1115
|
-
// row). When non-loopback serving lands, this gate is the actual auth
|
|
1116
|
-
// boundary on host-wide sleep.
|
|
1117
|
-
const sleepCtx = await buildContextWithAuth(req, opts);
|
|
1118
|
-
// Sleep consolidates every tenant under hippoRoot, so it is a cross-tenant action.
|
|
1119
|
-
assertCrossTenantAdmin(sleepCtx, '/v1/sleep');
|
|
1120
|
-
const body = await parseJsonBody(req);
|
|
1121
|
-
const dryRunRaw = body['dry_run'];
|
|
1122
|
-
if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
|
|
1123
|
-
throw new HttpError(400, 'dry_run must be a boolean');
|
|
1124
|
-
}
|
|
1125
|
-
const noShareRaw = body['no_share'];
|
|
1126
|
-
if (noShareRaw !== undefined && !isJsonBoolean(noShareRaw)) {
|
|
1127
|
-
throw new HttpError(400, 'no_share must be a boolean');
|
|
1128
|
-
}
|
|
1129
|
-
// v1.12.0: sleepCtx already built above for the admin-role gate; reuse.
|
|
1130
|
-
const result = await sleep(sleepCtx, {
|
|
1131
|
-
dryRun: dryRunRaw === true,
|
|
1132
|
-
noShare: noShareRaw === true,
|
|
1133
|
-
});
|
|
1134
|
-
sendJson(res, 200, result);
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
// POST /v1/auth/keys — mint a new API key. Plaintext lands in the response
|
|
1138
|
-
// body (Task 8): the HTTP layer hands it to the client; the user-facing
|
|
1139
|
-
// "store this somewhere safe" warning belongs in the CLI client, not here.
|
|
1140
|
-
if (method === 'POST' && path === '/v1/auth/keys') {
|
|
1141
|
-
const body = await parseJsonBody(req);
|
|
1142
|
-
const labelRaw = body['label'];
|
|
1143
|
-
if (labelRaw !== undefined && !isJsonString(labelRaw)) {
|
|
1144
|
-
throw new HttpError(400, 'label must be a string');
|
|
1145
|
-
}
|
|
1146
|
-
// v1.12.3: optional body.role mirrors the --role CLI flag. Validated
|
|
1147
|
-
// strictly — anything other than 'admin'|'member' is a 400 (no silent
|
|
1148
|
-
// fallback to admin). authCreate refuses a member caller with a 403.
|
|
1149
|
-
const roleRaw = body['role'];
|
|
1150
|
-
let role;
|
|
1151
|
-
if (roleRaw !== undefined) {
|
|
1152
|
-
if (roleRaw !== 'admin' && roleRaw !== 'member') {
|
|
1153
|
-
throw new HttpError(400, "role must be 'admin' or 'member'");
|
|
1154
|
-
}
|
|
1155
|
-
role = roleRaw;
|
|
1156
|
-
}
|
|
1157
|
-
// Security: any `tenantId` in the body is IGNORED. The minted key is
|
|
1158
|
-
// bound to the caller's authenticated tenant (ctx.tenantId, resolved
|
|
1159
|
-
// from the Bearer token). Forwarding body.tenantId here would let
|
|
1160
|
-
// tenant A mint a key for tenant B — see authCreate doc comment.
|
|
1161
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1162
|
-
const result = authCreate(ctx, {
|
|
1163
|
-
label: labelRaw,
|
|
1164
|
-
role,
|
|
1165
|
-
});
|
|
1166
|
-
sendJson(res, 200, result);
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
// GET /v1/auth/keys?active=true — list keys visible to ctx.tenantId.
|
|
1170
|
-
// `active` defaults to true so the common case (show me usable keys) is
|
|
1171
|
-
// a single GET; ?active=false includes revoked rows.
|
|
1172
|
-
if (method === 'GET' && path === '/v1/auth/keys') {
|
|
1173
|
-
const activeRaw = query.get('active');
|
|
1174
|
-
let active = true;
|
|
1175
|
-
if (activeRaw !== null) {
|
|
1176
|
-
if (activeRaw === 'true')
|
|
1177
|
-
active = true;
|
|
1178
|
-
else if (activeRaw === 'false')
|
|
1179
|
-
active = false;
|
|
1180
|
-
else
|
|
1181
|
-
throw new HttpError(400, "active must be 'true' or 'false'");
|
|
1182
|
-
}
|
|
1183
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1184
|
-
const result = authList(ctx, { active });
|
|
1185
|
-
sendJson(res, 200, result);
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
// DELETE /v1/auth/keys/:keyId — revoke. Missing or cross-tenant keys are 404
|
|
1189
|
-
// (no info leak); a member key targeting any key but its own is 403.
|
|
1190
|
-
// 200 with the body rather than 204 so the caller sees revokedAt.
|
|
1191
|
-
const keyMatch = matchPath('/v1/auth/keys/:keyId', path);
|
|
1192
|
-
if (method === 'DELETE' && keyMatch) {
|
|
1193
|
-
validateIdSegment(keyMatch.keyId, 'key id');
|
|
1194
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1195
|
-
const result = authRevoke(ctx, keyMatch.keyId);
|
|
1196
|
-
sendJson(res, 200, result);
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
// GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
|
|
1200
|
-
if (method === 'GET' && path === '/v1/quarantine') {
|
|
1201
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1202
|
-
if (ctx.actor.role !== 'admin') {
|
|
1203
|
-
throw new HttpError(403, '/v1/quarantine requires admin role');
|
|
1204
|
-
}
|
|
1205
|
-
const statusRaw = query.get('status');
|
|
1206
|
-
let status = 'pending';
|
|
1207
|
-
if (statusRaw !== null) {
|
|
1208
|
-
if (statusRaw !== 'pending' && statusRaw !== 'approved' && statusRaw !== 'rejected' && statusRaw !== 'all') {
|
|
1209
|
-
throw new HttpError(400, 'status must be one of: pending | approved | rejected | all');
|
|
1210
|
-
}
|
|
1211
|
-
status = statusRaw;
|
|
1212
|
-
}
|
|
1213
|
-
sendJson(res, 200, { quarantine: quarantineList(ctx, { status }) });
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
// POST /v1/quarantine/:id/approve: admin only; ForbiddenError falls through to mapApiError's 403.
|
|
1217
|
-
const quarantineApproveMatch = matchPath('/v1/quarantine/:id/approve', path);
|
|
1218
|
-
if (method === 'POST' && quarantineApproveMatch) {
|
|
1219
|
-
validateIdSegment(quarantineApproveMatch.id, 'memory id');
|
|
1220
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1221
|
-
try {
|
|
1222
|
-
quarantineApprove(ctx, quarantineApproveMatch.id);
|
|
1223
|
-
sendJson(res, 200, { approved: quarantineApproveMatch.id });
|
|
1224
|
-
}
|
|
1225
|
-
catch (e) {
|
|
1226
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1227
|
-
if (msg.includes('not quarantined'))
|
|
1228
|
-
throw new HttpError(404, msg);
|
|
1229
|
-
if (msg.includes('is already') || msg.includes('scope changed'))
|
|
1230
|
-
throw new HttpError(409, msg);
|
|
1231
|
-
throw e;
|
|
1232
|
-
}
|
|
1233
|
-
return;
|
|
1234
|
-
}
|
|
1235
|
-
// POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
|
|
1236
|
-
const quarantineRejectMatch = matchPath('/v1/quarantine/:id/reject', path);
|
|
1237
|
-
if (method === 'POST' && quarantineRejectMatch) {
|
|
1238
|
-
validateIdSegment(quarantineRejectMatch.id, 'memory id');
|
|
1239
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1240
|
-
try {
|
|
1241
|
-
quarantineReject(ctx, quarantineRejectMatch.id);
|
|
1242
|
-
sendJson(res, 200, { rejected: quarantineRejectMatch.id });
|
|
1243
|
-
}
|
|
1244
|
-
catch (e) {
|
|
1245
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1246
|
-
if (msg.includes('not quarantined'))
|
|
1247
|
-
throw new HttpError(404, msg);
|
|
1248
|
-
if (msg.includes('is already'))
|
|
1249
|
-
throw new HttpError(409, msg);
|
|
1250
|
-
throw e;
|
|
1251
|
-
}
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
|
-
// GET /v1/audit?op=&since=&limit= — read audit events. All three filters
|
|
1255
|
-
// validated at the route boundary so an invalid value lands a 400 before
|
|
1256
|
-
// we hit the DB.
|
|
1257
|
-
if (method === 'GET' && path === '/v1/audit') {
|
|
1258
|
-
const opRaw = query.get('op');
|
|
1259
|
-
let op;
|
|
1260
|
-
if (opRaw !== null) {
|
|
1261
|
-
if (!isSetMember(VALID_AUDIT_OPS, opRaw)) {
|
|
1262
|
-
throw new HttpError(400, `invalid op: ${opRaw}`);
|
|
1263
|
-
}
|
|
1264
|
-
op = opRaw;
|
|
1265
|
-
}
|
|
1266
|
-
const sinceRaw = query.get('since');
|
|
1267
|
-
let since;
|
|
1268
|
-
if (sinceRaw !== null) {
|
|
1269
|
-
const parsed = Date.parse(sinceRaw);
|
|
1270
|
-
if (!Number.isFinite(parsed)) {
|
|
1271
|
-
throw new HttpError(400, `invalid since: ${sinceRaw}`);
|
|
1272
|
-
}
|
|
1273
|
-
since = sinceRaw;
|
|
1274
|
-
}
|
|
1275
|
-
const limitRaw = query.get('limit');
|
|
1276
|
-
let limit;
|
|
1277
|
-
if (limitRaw !== null) {
|
|
1278
|
-
const parsed = Number(limitRaw);
|
|
1279
|
-
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > MAX_AUDIT_LIMIT) {
|
|
1280
|
-
throw new HttpError(400, `limit must be an integer between 1 and ${MAX_AUDIT_LIMIT}`);
|
|
1281
|
-
}
|
|
1282
|
-
limit = parsed;
|
|
1283
|
-
}
|
|
1284
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1285
|
-
// ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
|
|
1286
|
-
const tenantOverride = query.get('tenant');
|
|
1287
|
-
const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
|
|
1288
|
-
if (crossTenant)
|
|
1289
|
-
assertCrossTenantAdmin(ctx, '/v1/audit?tenant= for another tenant');
|
|
1290
|
-
const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
|
|
1291
|
-
const result = auditList(effectiveCtx, { op, since, limit });
|
|
1292
|
-
sendJson(res, 200, result);
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
// ── E2 prediction first-class object (v0.31) ──
|
|
1296
|
-
// docs/plans/2026-05-26-e2-prediction-object.md
|
|
1297
|
-
//
|
|
1298
|
-
// 4 routes: POST /v1/predictions (create), GET /v1/predictions (list),
|
|
1299
|
-
// GET /v1/predictions/:id (show), POST /v1/predictions/:id/close (close).
|
|
1300
|
-
// All Bearer-authed + tenant-scoped via buildContextWithAuth. closure_state
|
|
1301
|
-
// validated against VALID_CLOSURE_STATES (3 states). DoS caps on claim
|
|
1302
|
-
// (4096 chars) + closureNote (2048 chars) per v1.11.4 pattern.
|
|
1303
|
-
if (method === 'POST' && path === '/v1/predictions') {
|
|
1304
|
-
const body = await parseJsonBody(req);
|
|
1305
|
-
const claim = body['claim'];
|
|
1306
|
-
if (!isJsonString(claim) || claim.length === 0) {
|
|
1307
|
-
throw new HttpError(400, 'claim is required (non-empty string)');
|
|
1308
|
-
}
|
|
1309
|
-
if (claim.length > 4096) {
|
|
1310
|
-
throw new HttpError(400, 'claim exceeds 4096-character cap');
|
|
1311
|
-
}
|
|
1312
|
-
const classTag = body['classTag'];
|
|
1313
|
-
if (!isJsonString(classTag) || classTag.length === 0) {
|
|
1314
|
-
throw new HttpError(400, 'classTag is required (non-empty string)');
|
|
1315
|
-
}
|
|
1316
|
-
const estimate = body['estimate'];
|
|
1317
|
-
let estimateValue;
|
|
1318
|
-
if (estimate !== undefined && estimate !== null) {
|
|
1319
|
-
if (!isJsonNumber(estimate) || !Number.isFinite(estimate)) {
|
|
1320
|
-
throw new HttpError(400, 'estimate must be a finite number');
|
|
1321
|
-
}
|
|
1322
|
-
estimateValue = estimate;
|
|
1323
|
-
}
|
|
1324
|
-
const unit = body['unit'];
|
|
1325
|
-
let estimateUnit;
|
|
1326
|
-
if (unit !== undefined && unit !== null) {
|
|
1327
|
-
if (!isJsonString(unit)) {
|
|
1328
|
-
throw new HttpError(400, 'unit must be a string');
|
|
1329
|
-
}
|
|
1330
|
-
estimateUnit = unit;
|
|
1331
|
-
}
|
|
1332
|
-
const targetDate = body['targetDate'];
|
|
1333
|
-
let targetDateValue;
|
|
1334
|
-
if (targetDate !== undefined && targetDate !== null) {
|
|
1335
|
-
if (!isJsonString(targetDate)) {
|
|
1336
|
-
throw new HttpError(400, 'targetDate must be an ISO date string');
|
|
1337
|
-
}
|
|
1338
|
-
targetDateValue = targetDate;
|
|
1339
|
-
}
|
|
1340
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1341
|
-
const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
|
|
1342
|
-
classTag,
|
|
1343
|
-
claimText: claim,
|
|
1344
|
-
estimateValue,
|
|
1345
|
-
estimateUnit,
|
|
1346
|
-
targetDate: targetDateValue,
|
|
1347
|
-
}, ctx.actor.subject);
|
|
1348
|
-
sendJson(res, 201, { prediction });
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
if (method === 'GET' && path === '/v1/predictions') {
|
|
1352
|
-
const classTag = query.get('class') ?? undefined;
|
|
1353
|
-
const status = query.get('status') ?? 'all';
|
|
1354
|
-
const limit = parseListLimit(query.get('limit'));
|
|
1355
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1356
|
-
let predictions;
|
|
1357
|
-
if (status === 'all') {
|
|
1358
|
-
if (classTag) {
|
|
1359
|
-
predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, { limit });
|
|
1360
|
-
}
|
|
1361
|
-
else {
|
|
1362
|
-
predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
else if (status === 'open') {
|
|
1366
|
-
predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, {
|
|
1367
|
-
classTag: classTag || undefined,
|
|
1368
|
-
limit,
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
else {
|
|
1372
|
-
if (!isSetMember(VALID_CLOSURE_STATES, status)) {
|
|
1373
|
-
throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
|
|
1374
|
-
}
|
|
1375
|
-
if (!classTag) {
|
|
1376
|
-
throw new HttpError(400, 'status filter (non-open) requires class param');
|
|
1377
|
-
}
|
|
1378
|
-
predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, {
|
|
1379
|
-
closureState: status,
|
|
1380
|
-
limit,
|
|
1381
|
-
});
|
|
1382
|
-
}
|
|
1383
|
-
sendJson(res, 200, { predictions });
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
// J3 reference-class / planning-fallacy detector (v0.31).
|
|
1387
|
-
// Order matters: this must match BEFORE /v1/predictions/:id since 'stats'
|
|
1388
|
-
// is not a number — the :id regex requires \d+ so they don't conflict,
|
|
1389
|
-
// but routing this first avoids the dispatch order risk.
|
|
1390
|
-
if (method === 'GET' && path === '/v1/predictions/stats') {
|
|
1391
|
-
const classTag = query.get('class');
|
|
1392
|
-
if (!classTag || classTag.length === 0) {
|
|
1393
|
-
throw new HttpError(400, 'class param is required');
|
|
1394
|
-
}
|
|
1395
|
-
if (classTag.length > 256) {
|
|
1396
|
-
throw new HttpError(400, 'class exceeds 256-character cap');
|
|
1397
|
-
}
|
|
1398
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1399
|
-
const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
|
|
1400
|
-
sendJson(res, 200, { baserate });
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
const predictionByIdMatch = path.match(/^\/v1\/predictions\/(\d+)$/);
|
|
1404
|
-
if (method === 'GET' && predictionByIdMatch) {
|
|
1405
|
-
const id = parseInt(predictionByIdMatch[1], 10);
|
|
1406
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1407
|
-
const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
|
|
1408
|
-
if (!prediction) {
|
|
1409
|
-
throw new HttpError(404, `prediction ${id} not found`);
|
|
1410
|
-
}
|
|
1411
|
-
sendJson(res, 200, { prediction });
|
|
1412
|
-
return;
|
|
1413
|
-
}
|
|
1414
|
-
const predictionCloseMatch = path.match(/^\/v1\/predictions\/(\d+)\/close$/);
|
|
1415
|
-
if (method === 'POST' && predictionCloseMatch) {
|
|
1416
|
-
const id = parseInt(predictionCloseMatch[1], 10);
|
|
1417
|
-
const body = await parseJsonBody(req);
|
|
1418
|
-
const state = body['state'];
|
|
1419
|
-
if (!isJsonString(state) || !isSetMember(VALID_CLOSURE_STATES, state) || state === 'open') {
|
|
1420
|
-
throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
|
|
1421
|
-
}
|
|
1422
|
-
const actual = body['actual'];
|
|
1423
|
-
let actualValue;
|
|
1424
|
-
if (actual !== undefined && actual !== null) {
|
|
1425
|
-
if (!isJsonNumber(actual) || !Number.isFinite(actual)) {
|
|
1426
|
-
throw new HttpError(400, 'actual must be a finite number');
|
|
1427
|
-
}
|
|
1428
|
-
actualValue = actual;
|
|
1429
|
-
}
|
|
1430
|
-
const note = body['note'];
|
|
1431
|
-
let closureNote;
|
|
1432
|
-
if (note !== undefined && note !== null) {
|
|
1433
|
-
if (!isJsonString(note)) {
|
|
1434
|
-
throw new HttpError(400, 'note must be a string');
|
|
1435
|
-
}
|
|
1436
|
-
if (note.length > 2048) {
|
|
1437
|
-
throw new HttpError(400, 'note exceeds 2048-character cap');
|
|
1438
|
-
}
|
|
1439
|
-
closureNote = note;
|
|
1440
|
-
}
|
|
1441
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1442
|
-
try {
|
|
1443
|
-
const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
|
|
1444
|
-
closureState: state,
|
|
1445
|
-
actualValue,
|
|
1446
|
-
closureNote,
|
|
1447
|
-
}, ctx.actor.subject);
|
|
1448
|
-
sendJson(res, 200, { prediction });
|
|
1449
|
-
}
|
|
1450
|
-
catch (e) {
|
|
1451
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1452
|
-
if (msg.includes('not found')) {
|
|
1453
|
-
throw new HttpError(404, msg);
|
|
1454
|
-
}
|
|
1455
|
-
throw e;
|
|
1456
|
-
}
|
|
1457
|
-
return;
|
|
1458
|
-
}
|
|
1459
|
-
// ── decisions (E2 first-class object) ──
|
|
1460
|
-
//
|
|
1461
|
-
// 5 routes: POST /v1/decisions (create, optional supersedesDecisionId),
|
|
1462
|
-
// GET /v1/decisions (list, status filter), GET /v1/decisions/:id (show),
|
|
1463
|
-
// POST /v1/decisions/:id/supersede (create a successor + supersede :id),
|
|
1464
|
-
// POST /v1/decisions/:id/close (retire). Bearer-authed + tenant-scoped via
|
|
1465
|
-
// buildContextWithAuth. status validated against VALID_DECISION_STATES.
|
|
1466
|
-
// DoS caps: text 4096, context 4096 (v1.11.4 pattern). The HTTP surface is
|
|
1467
|
-
// new (no legacy --supersedes <memory-id> constraint), so it supersedes by
|
|
1468
|
-
// table id and never weakens a memory mirror.
|
|
1469
|
-
if (method === 'POST' && path === '/v1/decisions') {
|
|
1470
|
-
const body = await parseJsonBody(req);
|
|
1471
|
-
const text = body['text'];
|
|
1472
|
-
if (!isJsonString(text) || text.length === 0) {
|
|
1473
|
-
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1474
|
-
}
|
|
1475
|
-
if (text.length > 4096) {
|
|
1476
|
-
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1477
|
-
}
|
|
1478
|
-
const contextRaw = body['context'];
|
|
1479
|
-
let context;
|
|
1480
|
-
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1481
|
-
if (!isJsonString(contextRaw)) {
|
|
1482
|
-
throw new HttpError(400, 'context must be a string');
|
|
1483
|
-
}
|
|
1484
|
-
if (contextRaw.length > 4096) {
|
|
1485
|
-
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1486
|
-
}
|
|
1487
|
-
context = contextRaw;
|
|
1488
|
-
}
|
|
1489
|
-
const supRaw = body['supersedesDecisionId'];
|
|
1490
|
-
let supersedesDecisionId;
|
|
1491
|
-
if (supRaw !== undefined && supRaw !== null) {
|
|
1492
|
-
if (!isJsonNumber(supRaw) || !Number.isInteger(supRaw) || supRaw <= 0) {
|
|
1493
|
-
throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
|
|
1494
|
-
}
|
|
1495
|
-
supersedesDecisionId = supRaw;
|
|
1496
|
-
}
|
|
1497
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1498
|
-
try {
|
|
1499
|
-
const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
|
|
1500
|
-
decisionText: text,
|
|
1501
|
-
context,
|
|
1502
|
-
supersedesDecisionId,
|
|
1503
|
-
}, ctx.actor.subject);
|
|
1504
|
-
sendJson(res, 201, { decision });
|
|
1505
|
-
}
|
|
1506
|
-
catch (e) {
|
|
1507
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1508
|
-
if (msg.includes('not found') || msg.includes('not active')) {
|
|
1509
|
-
throw new HttpError(409, msg);
|
|
1510
|
-
}
|
|
1511
|
-
throw e;
|
|
1512
|
-
}
|
|
1513
|
-
return;
|
|
1514
|
-
}
|
|
1515
|
-
if (method === 'GET' && path === '/v1/decisions') {
|
|
1516
|
-
const status = query.get('status') ?? 'all';
|
|
1517
|
-
const limit = parseListLimit(query.get('limit'));
|
|
1518
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1519
|
-
let decisions;
|
|
1520
|
-
if (status === 'all') {
|
|
1521
|
-
decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1522
|
-
}
|
|
1523
|
-
else {
|
|
1524
|
-
if (!isSetMember(VALID_DECISION_STATES, status)) {
|
|
1525
|
-
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1526
|
-
}
|
|
1527
|
-
decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
|
|
1528
|
-
status,
|
|
1529
|
-
limit,
|
|
1530
|
-
});
|
|
1531
|
-
}
|
|
1532
|
-
sendJson(res, 200, { decisions });
|
|
1533
|
-
return;
|
|
1534
|
-
}
|
|
1535
|
-
const decisionSupersedeMatch = path.match(/^\/v1\/decisions\/(\d+)\/supersede$/);
|
|
1536
|
-
if (method === 'POST' && decisionSupersedeMatch) {
|
|
1537
|
-
const oldId = parseInt(decisionSupersedeMatch[1], 10);
|
|
1538
|
-
const body = await parseJsonBody(req);
|
|
1539
|
-
const text = body['text'];
|
|
1540
|
-
if (!isJsonString(text) || text.length === 0) {
|
|
1541
|
-
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1542
|
-
}
|
|
1543
|
-
if (text.length > 4096) {
|
|
1544
|
-
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1545
|
-
}
|
|
1546
|
-
const contextRaw = body['context'];
|
|
1547
|
-
let context;
|
|
1548
|
-
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1549
|
-
if (!isJsonString(contextRaw)) {
|
|
1550
|
-
throw new HttpError(400, 'context must be a string');
|
|
1551
|
-
}
|
|
1552
|
-
if (contextRaw.length > 4096) {
|
|
1553
|
-
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1554
|
-
}
|
|
1555
|
-
context = contextRaw;
|
|
1556
|
-
}
|
|
1557
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1558
|
-
try {
|
|
1559
|
-
const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
|
|
1560
|
-
decisionText: text,
|
|
1561
|
-
context,
|
|
1562
|
-
supersedesDecisionId: oldId,
|
|
1563
|
-
}, ctx.actor.subject);
|
|
1564
|
-
sendJson(res, 201, { decision });
|
|
1565
|
-
}
|
|
1566
|
-
catch (e) {
|
|
1567
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1568
|
-
if (msg.includes('not found')) {
|
|
1569
|
-
throw new HttpError(404, msg);
|
|
1570
|
-
}
|
|
1571
|
-
if (msg.includes('not active')) {
|
|
1572
|
-
throw new HttpError(409, msg);
|
|
1573
|
-
}
|
|
1574
|
-
throw e;
|
|
1575
|
-
}
|
|
1576
|
-
return;
|
|
1577
|
-
}
|
|
1578
|
-
const decisionCloseMatch = path.match(/^\/v1\/decisions\/(\d+)\/close$/);
|
|
1579
|
-
if (method === 'POST' && decisionCloseMatch) {
|
|
1580
|
-
const id = parseInt(decisionCloseMatch[1], 10);
|
|
1581
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1582
|
-
try {
|
|
1583
|
-
const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1584
|
-
sendJson(res, 200, { decision });
|
|
1585
|
-
}
|
|
1586
|
-
catch (e) {
|
|
1587
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1588
|
-
if (msg.includes('not found')) {
|
|
1589
|
-
throw new HttpError(404, msg);
|
|
1590
|
-
}
|
|
1591
|
-
if (msg.includes('not active')) {
|
|
1592
|
-
throw new HttpError(409, msg);
|
|
1593
|
-
}
|
|
1594
|
-
throw e;
|
|
1595
|
-
}
|
|
1596
|
-
return;
|
|
1597
|
-
}
|
|
1598
|
-
const decisionByIdMatch = path.match(/^\/v1\/decisions\/(\d+)$/);
|
|
1599
|
-
if (method === 'GET' && decisionByIdMatch) {
|
|
1600
|
-
const id = parseInt(decisionByIdMatch[1], 10);
|
|
1601
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1602
|
-
const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
|
|
1603
|
-
if (!decision) {
|
|
1604
|
-
throw new HttpError(404, `decision ${id} not found`);
|
|
1605
|
-
}
|
|
1606
|
-
sendJson(res, 200, { decision });
|
|
1607
|
-
return;
|
|
1608
|
-
}
|
|
1609
|
-
// ── incidents (E2 first-class object) ──
|
|
1610
|
-
//
|
|
1611
|
-
// 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
|
|
1612
|
-
// GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
|
|
1613
|
-
// POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
|
|
1614
|
-
// POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
|
|
1615
|
-
// tenant-scoped via buildContextWithAuth. status validated against
|
|
1616
|
-
// VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
|
|
1617
|
-
// 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
|
|
1618
|
-
// open->resolved->closed (no supersede), so linkedMemoryIds replaces
|
|
1619
|
-
// supersedesDecisionId on create.
|
|
1620
|
-
if (method === 'POST' && path === '/v1/incidents') {
|
|
1621
|
-
const body = await parseJsonBody(req);
|
|
1622
|
-
const text = body['text'];
|
|
1623
|
-
if (!isJsonString(text) || text.length === 0) {
|
|
1624
|
-
throw new HttpError(400, 'text is required (non-empty string)');
|
|
1625
|
-
}
|
|
1626
|
-
if (text.length > 4096) {
|
|
1627
|
-
throw new HttpError(400, 'text exceeds 4096-character cap');
|
|
1628
|
-
}
|
|
1629
|
-
const contextRaw = body['context'];
|
|
1630
|
-
let context;
|
|
1631
|
-
if (contextRaw !== undefined && contextRaw !== null) {
|
|
1632
|
-
if (!isJsonString(contextRaw)) {
|
|
1633
|
-
throw new HttpError(400, 'context must be a string');
|
|
1634
|
-
}
|
|
1635
|
-
if (contextRaw.length > 4096) {
|
|
1636
|
-
throw new HttpError(400, 'context exceeds 4096-character cap');
|
|
1637
|
-
}
|
|
1638
|
-
context = contextRaw;
|
|
1639
|
-
}
|
|
1640
|
-
const linkedRaw = body['linkedMemoryIds'];
|
|
1641
|
-
let linkedMemoryIds;
|
|
1642
|
-
if (linkedRaw !== undefined && linkedRaw !== null) {
|
|
1643
|
-
if (!Array.isArray(linkedRaw)) {
|
|
1644
|
-
throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
|
|
1645
|
-
}
|
|
1646
|
-
if (linkedRaw.length > 256) {
|
|
1647
|
-
throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
|
|
1648
|
-
}
|
|
1649
|
-
const isValidMemoryId = (item) => isJsonString(item) && item.length > 0 && item.length <= 4096;
|
|
1650
|
-
if (!linkedRaw.every(isValidMemoryId)) {
|
|
1651
|
-
throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
|
|
1652
|
-
}
|
|
1653
|
-
linkedMemoryIds = linkedRaw;
|
|
1654
|
-
}
|
|
1655
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1656
|
-
try {
|
|
1657
|
-
const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
|
|
1658
|
-
incidentText: text,
|
|
1659
|
-
context,
|
|
1660
|
-
linkedMemoryIds,
|
|
1661
|
-
}, ctx.actor.subject);
|
|
1662
|
-
sendJson(res, 201, { incident });
|
|
1663
|
-
}
|
|
1664
|
-
catch (e) {
|
|
1665
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1666
|
-
if (msg.includes('not found')) {
|
|
1667
|
-
throw new HttpError(409, msg);
|
|
1668
|
-
}
|
|
1669
|
-
throw e;
|
|
1670
|
-
}
|
|
1671
|
-
return;
|
|
1672
|
-
}
|
|
1673
|
-
if (method === 'GET' && path === '/v1/incidents') {
|
|
1674
|
-
const status = query.get('status') ?? 'all';
|
|
1675
|
-
const limit = parseListLimit(query.get('limit'));
|
|
1676
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1677
|
-
let incidents;
|
|
1678
|
-
if (status === 'all') {
|
|
1679
|
-
incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1680
|
-
}
|
|
1681
|
-
else {
|
|
1682
|
-
if (!isSetMember(VALID_INCIDENT_STATES, status)) {
|
|
1683
|
-
throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
|
|
1684
|
-
}
|
|
1685
|
-
incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
|
|
1686
|
-
status,
|
|
1687
|
-
limit,
|
|
1688
|
-
});
|
|
1689
|
-
}
|
|
1690
|
-
sendJson(res, 200, { incidents });
|
|
1691
|
-
return;
|
|
1692
|
-
}
|
|
1693
|
-
const incidentResolveMatch = path.match(/^\/v1\/incidents\/(\d+)\/resolve$/);
|
|
1694
|
-
if (method === 'POST' && incidentResolveMatch) {
|
|
1695
|
-
const id = parseInt(incidentResolveMatch[1], 10);
|
|
1696
|
-
const body = await parseJsonBody(req);
|
|
1697
|
-
const resolutionText = body['resolutionText'];
|
|
1698
|
-
if (!isJsonString(resolutionText) || resolutionText.trim().length === 0) {
|
|
1699
|
-
throw new HttpError(400, 'resolutionText is required (non-empty string)');
|
|
1700
|
-
}
|
|
1701
|
-
if (resolutionText.length > 4096) {
|
|
1702
|
-
throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
|
|
1703
|
-
}
|
|
1704
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1705
|
-
try {
|
|
1706
|
-
const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
|
|
1707
|
-
sendJson(res, 200, { incident });
|
|
1708
|
-
}
|
|
1709
|
-
catch (e) {
|
|
1710
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1711
|
-
if (msg.includes('not found')) {
|
|
1712
|
-
throw new HttpError(404, msg);
|
|
1713
|
-
}
|
|
1714
|
-
if (msg.includes('not open')) {
|
|
1715
|
-
throw new HttpError(409, msg);
|
|
1716
|
-
}
|
|
1717
|
-
throw e;
|
|
1718
|
-
}
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
const incidentCloseMatch = path.match(/^\/v1\/incidents\/(\d+)\/close$/);
|
|
1722
|
-
if (method === 'POST' && incidentCloseMatch) {
|
|
1723
|
-
const id = parseInt(incidentCloseMatch[1], 10);
|
|
1724
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1725
|
-
try {
|
|
1726
|
-
const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1727
|
-
sendJson(res, 200, { incident });
|
|
1728
|
-
}
|
|
1729
|
-
catch (e) {
|
|
1730
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1731
|
-
if (msg.includes('not found')) {
|
|
1732
|
-
throw new HttpError(404, msg);
|
|
1733
|
-
}
|
|
1734
|
-
if (msg.includes('already closed')) {
|
|
1735
|
-
throw new HttpError(409, msg);
|
|
1736
|
-
}
|
|
1737
|
-
throw e;
|
|
1738
|
-
}
|
|
1739
|
-
return;
|
|
1740
|
-
}
|
|
1741
|
-
const incidentByIdMatch = path.match(/^\/v1\/incidents\/(\d+)$/);
|
|
1742
|
-
if (method === 'GET' && incidentByIdMatch) {
|
|
1743
|
-
const id = parseInt(incidentByIdMatch[1], 10);
|
|
1744
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1745
|
-
const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
|
|
1746
|
-
if (!incident) {
|
|
1747
|
-
throw new HttpError(404, `incident ${id} not found`);
|
|
1748
|
-
}
|
|
1749
|
-
sendJson(res, 200, { incident });
|
|
1750
|
-
return;
|
|
1751
|
-
}
|
|
1752
|
-
// ── processes (E2 first-class object) ──
|
|
1753
|
-
//
|
|
1754
|
-
// 5 routes: POST /v1/processes (new; body processName + steps[] + description),
|
|
1755
|
-
// GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
|
|
1756
|
-
// POST /v1/processes/:id/supersede (active -> superseded by a new version; body
|
|
1757
|
-
// steps[] + changeSummary + description; reuses the predecessor's name),
|
|
1758
|
-
// POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
|
|
1759
|
-
// via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
|
|
1760
|
-
// caps: processName/description/changeSummary 4096, steps 200x2000
|
|
1761
|
-
// (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
|
|
1762
|
-
// decision supersede path.
|
|
1763
|
-
if (method === 'POST' && path === '/v1/processes') {
|
|
1764
|
-
const body = await parseJsonBody(req);
|
|
1765
|
-
const processName = body['processName'];
|
|
1766
|
-
if (!isJsonString(processName) || processName.trim().length === 0) {
|
|
1767
|
-
throw new HttpError(400, 'processName is required (non-empty string)');
|
|
1768
|
-
}
|
|
1769
|
-
if (processName.length > 4096) {
|
|
1770
|
-
throw new HttpError(400, 'processName exceeds 4096-character cap');
|
|
1771
|
-
}
|
|
1772
|
-
const steps = validateProcessStepsBody(body['steps']);
|
|
1773
|
-
const descriptionRaw = body['description'];
|
|
1774
|
-
let description;
|
|
1775
|
-
if (descriptionRaw !== undefined && descriptionRaw !== null) {
|
|
1776
|
-
if (!isJsonString(descriptionRaw)) {
|
|
1777
|
-
throw new HttpError(400, 'description must be a string');
|
|
1778
|
-
}
|
|
1779
|
-
if (descriptionRaw.length > 4096) {
|
|
1780
|
-
throw new HttpError(400, 'description exceeds 4096-character cap');
|
|
1781
|
-
}
|
|
1782
|
-
description = descriptionRaw;
|
|
1783
|
-
}
|
|
1784
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1785
|
-
const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
|
|
1786
|
-
processName,
|
|
1787
|
-
steps,
|
|
1788
|
-
description,
|
|
1789
|
-
}, ctx.actor.subject);
|
|
1790
|
-
sendJson(res, 201, { process });
|
|
1791
|
-
return;
|
|
1792
|
-
}
|
|
1793
|
-
if (method === 'GET' && path === '/v1/processes') {
|
|
1794
|
-
const status = query.get('status') ?? 'all';
|
|
1795
|
-
const limit = parseListLimit(query.get('limit'));
|
|
1796
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1797
|
-
let processes;
|
|
1798
|
-
if (status === 'all') {
|
|
1799
|
-
processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1800
|
-
}
|
|
1801
|
-
else {
|
|
1802
|
-
if (!isSetMember(VALID_PROCESS_STATES, status)) {
|
|
1803
|
-
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1804
|
-
}
|
|
1805
|
-
processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
|
|
1806
|
-
status,
|
|
1807
|
-
limit,
|
|
1808
|
-
});
|
|
1809
|
-
}
|
|
1810
|
-
sendJson(res, 200, { processes });
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
const processSupersedeMatch = path.match(/^\/v1\/processes\/(\d+)\/supersede$/);
|
|
1814
|
-
if (method === 'POST' && processSupersedeMatch) {
|
|
1815
|
-
const id = parseInt(processSupersedeMatch[1], 10);
|
|
1816
|
-
const body = await parseJsonBody(req);
|
|
1817
|
-
const steps = validateProcessStepsBody(body['steps']);
|
|
1818
|
-
if (steps.length === 0) {
|
|
1819
|
-
throw new HttpError(400, 'steps is required (at least one step) for a supersession');
|
|
1820
|
-
}
|
|
1821
|
-
const changeRaw = body['changeSummary'];
|
|
1822
|
-
let changeSummary;
|
|
1823
|
-
if (changeRaw !== undefined && changeRaw !== null) {
|
|
1824
|
-
if (!isJsonString(changeRaw)) {
|
|
1825
|
-
throw new HttpError(400, 'changeSummary must be a string');
|
|
1826
|
-
}
|
|
1827
|
-
if (changeRaw.length > 4096) {
|
|
1828
|
-
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
1829
|
-
}
|
|
1830
|
-
changeSummary = changeRaw;
|
|
1831
|
-
}
|
|
1832
|
-
const descRaw = body['description'];
|
|
1833
|
-
let description;
|
|
1834
|
-
if (descRaw !== undefined && descRaw !== null) {
|
|
1835
|
-
if (!isJsonString(descRaw)) {
|
|
1836
|
-
throw new HttpError(400, 'description must be a string');
|
|
1837
|
-
}
|
|
1838
|
-
if (descRaw.length > 4096) {
|
|
1839
|
-
throw new HttpError(400, 'description exceeds 4096-character cap');
|
|
1840
|
-
}
|
|
1841
|
-
description = descRaw;
|
|
1842
|
-
}
|
|
1843
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1844
|
-
// A supersession is a new version of the SAME process: reuse the
|
|
1845
|
-
// predecessor's name. 404 if the target does not exist; saveProcess's
|
|
1846
|
-
// in-SAVEPOINT preflight is the authoritative active-state check (409).
|
|
1847
|
-
const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
|
|
1848
|
-
if (!existing) {
|
|
1849
|
-
throw new HttpError(404, `process ${id} not found`);
|
|
1850
|
-
}
|
|
1851
|
-
try {
|
|
1852
|
-
const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
|
|
1853
|
-
processName: existing.processName,
|
|
1854
|
-
steps,
|
|
1855
|
-
description,
|
|
1856
|
-
changeSummary,
|
|
1857
|
-
supersedesProcessId: id,
|
|
1858
|
-
}, ctx.actor.subject);
|
|
1859
|
-
sendJson(res, 200, { process });
|
|
1860
|
-
}
|
|
1861
|
-
catch (e) {
|
|
1862
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1863
|
-
if (msg.includes('not found')) {
|
|
1864
|
-
throw new HttpError(404, msg);
|
|
1865
|
-
}
|
|
1866
|
-
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
1867
|
-
throw new HttpError(409, msg);
|
|
1868
|
-
}
|
|
1869
|
-
throw e;
|
|
1870
|
-
}
|
|
1871
|
-
return;
|
|
1872
|
-
}
|
|
1873
|
-
const processCloseMatch = path.match(/^\/v1\/processes\/(\d+)\/close$/);
|
|
1874
|
-
if (method === 'POST' && processCloseMatch) {
|
|
1875
|
-
const id = parseInt(processCloseMatch[1], 10);
|
|
1876
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1877
|
-
try {
|
|
1878
|
-
const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
1879
|
-
sendJson(res, 200, { process });
|
|
1880
|
-
}
|
|
1881
|
-
catch (e) {
|
|
1882
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1883
|
-
if (msg.includes('not found')) {
|
|
1884
|
-
throw new HttpError(404, msg);
|
|
1885
|
-
}
|
|
1886
|
-
if (msg.includes('not active')) {
|
|
1887
|
-
throw new HttpError(409, msg);
|
|
1888
|
-
}
|
|
1889
|
-
throw e;
|
|
1890
|
-
}
|
|
1891
|
-
return;
|
|
1892
|
-
}
|
|
1893
|
-
const processByIdMatch = path.match(/^\/v1\/processes\/(\d+)$/);
|
|
1894
|
-
if (method === 'GET' && processByIdMatch) {
|
|
1895
|
-
const id = parseInt(processByIdMatch[1], 10);
|
|
1896
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1897
|
-
const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
|
|
1898
|
-
if (!process) {
|
|
1899
|
-
throw new HttpError(404, `process ${id} not found`);
|
|
1900
|
-
}
|
|
1901
|
-
sendJson(res, 200, { process });
|
|
1902
|
-
return;
|
|
1903
|
-
}
|
|
1904
|
-
// ── policies (E2 first-class object, bi-temporal-first) ──
|
|
1905
|
-
//
|
|
1906
|
-
// 6 routes: POST /v1/policies (new; processName-style body policyName +
|
|
1907
|
-
// policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
|
|
1908
|
-
// GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
|
|
1909
|
-
// placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
|
|
1910
|
-
// /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
|
|
1911
|
-
// Date inputs are normalized + range-validated in the store; an invalid/inverted
|
|
1912
|
-
// date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
|
|
1913
|
-
if (method === 'POST' && path === '/v1/policies') {
|
|
1914
|
-
const body = await parseJsonBody(req);
|
|
1915
|
-
const policyName = body['policyName'];
|
|
1916
|
-
if (!isJsonString(policyName) || policyName.trim().length === 0) {
|
|
1917
|
-
throw new HttpError(400, 'policyName is required (non-empty string)');
|
|
1918
|
-
}
|
|
1919
|
-
if (policyName.length > 4096) {
|
|
1920
|
-
throw new HttpError(400, 'policyName exceeds 4096-character cap');
|
|
1921
|
-
}
|
|
1922
|
-
const policyText = body['policyText'];
|
|
1923
|
-
if (!isJsonString(policyText) || policyText.trim().length === 0) {
|
|
1924
|
-
throw new HttpError(400, 'policyText is required (non-empty string)');
|
|
1925
|
-
}
|
|
1926
|
-
if (policyText.length > 4096) {
|
|
1927
|
-
throw new HttpError(400, 'policyText exceeds 4096-character cap');
|
|
1928
|
-
}
|
|
1929
|
-
const validFrom = optionalDateField(body['validFrom'], 'validFrom');
|
|
1930
|
-
const validTo = optionalDateField(body['validTo'], 'validTo');
|
|
1931
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1932
|
-
try {
|
|
1933
|
-
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
1934
|
-
policyName,
|
|
1935
|
-
policyText,
|
|
1936
|
-
validFrom,
|
|
1937
|
-
validTo,
|
|
1938
|
-
}, ctx.actor.subject);
|
|
1939
|
-
sendJson(res, 201, { policy });
|
|
1940
|
-
}
|
|
1941
|
-
catch (e) {
|
|
1942
|
-
// savePolicy throws on invalid/inverted dates (validation) -> 400.
|
|
1943
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
1944
|
-
}
|
|
1945
|
-
return;
|
|
1946
|
-
}
|
|
1947
|
-
if (method === 'GET' && path === '/v1/policies') {
|
|
1948
|
-
const status = query.get('status') ?? 'all';
|
|
1949
|
-
const limit = parseListLimit(query.get('limit'));
|
|
1950
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1951
|
-
let policies;
|
|
1952
|
-
if (status === 'all') {
|
|
1953
|
-
policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
|
|
1954
|
-
}
|
|
1955
|
-
else {
|
|
1956
|
-
if (!isSetMember(VALID_POLICY_STATES, status)) {
|
|
1957
|
-
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
1958
|
-
}
|
|
1959
|
-
policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
|
|
1960
|
-
status,
|
|
1961
|
-
limit,
|
|
1962
|
-
});
|
|
1963
|
-
}
|
|
1964
|
-
sendJson(res, 200, { policies });
|
|
1965
|
-
return;
|
|
1966
|
-
}
|
|
1967
|
-
// The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
|
|
1968
|
-
// the /(\d+)/ route would not match it, but order it first for clarity).
|
|
1969
|
-
if (method === 'GET' && path === '/v1/policies/asof') {
|
|
1970
|
-
const date = query.get('date');
|
|
1971
|
-
if (date === null || date.length === 0) {
|
|
1972
|
-
throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
|
|
1973
|
-
}
|
|
1974
|
-
const name = query.get('name') ?? undefined;
|
|
1975
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
1976
|
-
try {
|
|
1977
|
-
const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
|
|
1978
|
-
sendJson(res, 200, { policies });
|
|
1979
|
-
}
|
|
1980
|
-
catch (e) {
|
|
1981
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
1982
|
-
}
|
|
1983
|
-
return;
|
|
1984
|
-
}
|
|
1985
|
-
const policySupersedeMatch = path.match(/^\/v1\/policies\/(\d+)\/supersede$/);
|
|
1986
|
-
if (method === 'POST' && policySupersedeMatch) {
|
|
1987
|
-
const id = parseInt(policySupersedeMatch[1], 10);
|
|
1988
|
-
const body = await parseJsonBody(req);
|
|
1989
|
-
const policyText = body['policyText'];
|
|
1990
|
-
if (!isJsonString(policyText) || policyText.trim().length === 0) {
|
|
1991
|
-
throw new HttpError(400, 'policyText is required (non-empty string)');
|
|
1992
|
-
}
|
|
1993
|
-
if (policyText.length > 4096) {
|
|
1994
|
-
throw new HttpError(400, 'policyText exceeds 4096-character cap');
|
|
1995
|
-
}
|
|
1996
|
-
const validFrom = optionalDateField(body['validFrom'], 'validFrom');
|
|
1997
|
-
const validTo = optionalDateField(body['validTo'], 'validTo');
|
|
1998
|
-
const changeRaw = body['changeSummary'];
|
|
1999
|
-
let changeSummary;
|
|
2000
|
-
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2001
|
-
if (!isJsonString(changeRaw)) {
|
|
2002
|
-
throw new HttpError(400, 'changeSummary must be a string');
|
|
2003
|
-
}
|
|
2004
|
-
if (changeRaw.length > 4096) {
|
|
2005
|
-
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2006
|
-
}
|
|
2007
|
-
changeSummary = changeRaw;
|
|
2008
|
-
}
|
|
2009
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2010
|
-
const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
|
|
2011
|
-
if (!existing) {
|
|
2012
|
-
throw new HttpError(404, `policy ${id} not found`);
|
|
2013
|
-
}
|
|
2014
|
-
try {
|
|
2015
|
-
const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
|
|
2016
|
-
policyName: existing.policyName,
|
|
2017
|
-
policyText,
|
|
2018
|
-
validFrom,
|
|
2019
|
-
validTo,
|
|
2020
|
-
changeSummary,
|
|
2021
|
-
supersedesPolicyId: id,
|
|
2022
|
-
}, ctx.actor.subject);
|
|
2023
|
-
sendJson(res, 200, { policy });
|
|
2024
|
-
}
|
|
2025
|
-
catch (e) {
|
|
2026
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2027
|
-
if (msg.includes('not found')) {
|
|
2028
|
-
throw new HttpError(404, msg);
|
|
2029
|
-
}
|
|
2030
|
-
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2031
|
-
throw new HttpError(409, msg);
|
|
2032
|
-
}
|
|
2033
|
-
// invalid/inverted date or missing field -> validation.
|
|
2034
|
-
throw new HttpError(400, msg);
|
|
2035
|
-
}
|
|
2036
|
-
return;
|
|
2037
|
-
}
|
|
2038
|
-
const policyCloseMatch = path.match(/^\/v1\/policies\/(\d+)\/close$/);
|
|
2039
|
-
if (method === 'POST' && policyCloseMatch) {
|
|
2040
|
-
const id = parseInt(policyCloseMatch[1], 10);
|
|
2041
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2042
|
-
try {
|
|
2043
|
-
const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2044
|
-
sendJson(res, 200, { policy });
|
|
2045
|
-
}
|
|
2046
|
-
catch (e) {
|
|
2047
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2048
|
-
if (msg.includes('not found')) {
|
|
2049
|
-
throw new HttpError(404, msg);
|
|
2050
|
-
}
|
|
2051
|
-
if (msg.includes('not active')) {
|
|
2052
|
-
throw new HttpError(409, msg);
|
|
2053
|
-
}
|
|
2054
|
-
throw e;
|
|
2055
|
-
}
|
|
2056
|
-
return;
|
|
2057
|
-
}
|
|
2058
|
-
const policyByIdMatch = path.match(/^\/v1\/policies\/(\d+)$/);
|
|
2059
|
-
if (method === 'GET' && policyByIdMatch) {
|
|
2060
|
-
const id = parseInt(policyByIdMatch[1], 10);
|
|
2061
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2062
|
-
const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
|
|
2063
|
-
if (!policy) {
|
|
2064
|
-
throw new HttpError(404, `policy ${id} not found`);
|
|
2065
|
-
}
|
|
2066
|
-
sendJson(res, 200, { policy });
|
|
2067
|
-
return;
|
|
2068
|
-
}
|
|
2069
|
-
// ── skills (E2 first-class object, executable/exportable) ──
|
|
2070
|
-
//
|
|
2071
|
-
// 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
|
|
2072
|
-
// GET /v1/skills (list, status filter; shared parseListLimit), GET
|
|
2073
|
-
// /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
|
|
2074
|
-
// block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
|
|
2075
|
-
// cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
|
|
2076
|
-
// POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
|
|
2077
|
-
// skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
|
|
2078
|
-
// validates + throws; the boundary maps validation -> 400, not-found -> 404,
|
|
2079
|
-
// not-active -> 409. Mirrors /v1/processes; "executable" = exportable
|
|
2080
|
-
// instruction (no code exec).
|
|
2081
|
-
if (method === 'POST' && path === '/v1/skills') {
|
|
2082
|
-
const body = await parseJsonBody(req);
|
|
2083
|
-
const skillName = body['skillName'];
|
|
2084
|
-
if (!isJsonString(skillName) || skillName.trim().length === 0) {
|
|
2085
|
-
throw new HttpError(400, 'skillName is required (non-empty string)');
|
|
2086
|
-
}
|
|
2087
|
-
if (skillName.length > 256) {
|
|
2088
|
-
throw new HttpError(400, 'skillName exceeds 256-character cap');
|
|
2089
|
-
}
|
|
2090
|
-
const instructions = body['instructions'];
|
|
2091
|
-
if (!isJsonString(instructions) || instructions.trim().length === 0) {
|
|
2092
|
-
throw new HttpError(400, 'instructions are required (non-empty string)');
|
|
2093
|
-
}
|
|
2094
|
-
if (instructions.length > 8192) {
|
|
2095
|
-
throw new HttpError(400, 'instructions exceed 8192-character cap');
|
|
2096
|
-
}
|
|
2097
|
-
const triggerRaw = body['trigger'];
|
|
2098
|
-
let trigger;
|
|
2099
|
-
if (triggerRaw !== undefined && triggerRaw !== null) {
|
|
2100
|
-
if (!isJsonString(triggerRaw)) {
|
|
2101
|
-
throw new HttpError(400, 'trigger must be a string');
|
|
2102
|
-
}
|
|
2103
|
-
if (triggerRaw.length > 1024) {
|
|
2104
|
-
throw new HttpError(400, 'trigger exceeds 1024-character cap');
|
|
2105
|
-
}
|
|
2106
|
-
trigger = triggerRaw;
|
|
2107
|
-
}
|
|
2108
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2109
|
-
try {
|
|
2110
|
-
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
2111
|
-
skillName,
|
|
2112
|
-
instructions,
|
|
2113
|
-
trigger,
|
|
2114
|
-
}, ctx.actor.subject);
|
|
2115
|
-
sendJson(res, 201, { skill });
|
|
2116
|
-
}
|
|
2117
|
-
catch (e) {
|
|
2118
|
-
// saveSkill throws on validation (single-line name etc.) -> 400.
|
|
2119
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
2120
|
-
}
|
|
2121
|
-
return;
|
|
2122
|
-
}
|
|
2123
|
-
if (method === 'GET' && path === '/v1/skills') {
|
|
2124
|
-
const status = query.get('status') ?? 'all';
|
|
2125
|
-
const limit = parseListLimit(query.get('limit'));
|
|
2126
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2127
|
-
let skills;
|
|
2128
|
-
if (status === 'all') {
|
|
2129
|
-
skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
|
|
2130
|
-
}
|
|
2131
|
-
else {
|
|
2132
|
-
if (!isSetMember(VALID_SKILL_STATES, status)) {
|
|
2133
|
-
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
2134
|
-
}
|
|
2135
|
-
skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
|
|
2136
|
-
status,
|
|
2137
|
-
limit,
|
|
2138
|
-
});
|
|
2139
|
-
}
|
|
2140
|
-
sendJson(res, 200, { skills });
|
|
2141
|
-
return;
|
|
2142
|
-
}
|
|
2143
|
-
// The export renderer: must precede the /:id GET (literal 'export' is
|
|
2144
|
-
// non-numeric so the /(\d+)/ route would not match it, but order it first).
|
|
2145
|
-
if (method === 'GET' && path === '/v1/skills/export') {
|
|
2146
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2147
|
-
const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
|
|
2148
|
-
sendJson(res, 200, { markdown });
|
|
2149
|
-
return;
|
|
2150
|
-
}
|
|
2151
|
-
const skillSupersedeMatch = path.match(/^\/v1\/skills\/(\d+)\/supersede$/);
|
|
2152
|
-
if (method === 'POST' && skillSupersedeMatch) {
|
|
2153
|
-
const id = parseInt(skillSupersedeMatch[1], 10);
|
|
2154
|
-
const body = await parseJsonBody(req);
|
|
2155
|
-
const instructions = body['instructions'];
|
|
2156
|
-
if (!isJsonString(instructions) || instructions.trim().length === 0) {
|
|
2157
|
-
throw new HttpError(400, 'instructions are required (non-empty string)');
|
|
2158
|
-
}
|
|
2159
|
-
if (instructions.length > 8192) {
|
|
2160
|
-
throw new HttpError(400, 'instructions exceed 8192-character cap');
|
|
2161
|
-
}
|
|
2162
|
-
const triggerRaw = body['trigger'];
|
|
2163
|
-
let trigger;
|
|
2164
|
-
if (triggerRaw !== undefined && triggerRaw !== null) {
|
|
2165
|
-
if (!isJsonString(triggerRaw)) {
|
|
2166
|
-
throw new HttpError(400, 'trigger must be a string');
|
|
2167
|
-
}
|
|
2168
|
-
if (triggerRaw.length > 1024) {
|
|
2169
|
-
throw new HttpError(400, 'trigger exceeds 1024-character cap');
|
|
2170
|
-
}
|
|
2171
|
-
trigger = triggerRaw;
|
|
2172
|
-
}
|
|
2173
|
-
const changeRaw = body['changeSummary'];
|
|
2174
|
-
let changeSummary;
|
|
2175
|
-
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2176
|
-
if (!isJsonString(changeRaw)) {
|
|
2177
|
-
throw new HttpError(400, 'changeSummary must be a string');
|
|
2178
|
-
}
|
|
2179
|
-
if (changeRaw.length > 4096) {
|
|
2180
|
-
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2181
|
-
}
|
|
2182
|
-
changeSummary = changeRaw;
|
|
2183
|
-
}
|
|
2184
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2185
|
-
const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
|
|
2186
|
-
if (!existing) {
|
|
2187
|
-
throw new HttpError(404, `skill ${id} not found`);
|
|
2188
|
-
}
|
|
2189
|
-
try {
|
|
2190
|
-
const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
|
|
2191
|
-
skillName: existing.skillName,
|
|
2192
|
-
instructions,
|
|
2193
|
-
trigger,
|
|
2194
|
-
changeSummary,
|
|
2195
|
-
supersedesSkillId: id,
|
|
2196
|
-
}, ctx.actor.subject);
|
|
2197
|
-
sendJson(res, 200, { skill });
|
|
2198
|
-
}
|
|
2199
|
-
catch (e) {
|
|
2200
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2201
|
-
if (msg.includes('not found')) {
|
|
2202
|
-
throw new HttpError(404, msg);
|
|
2203
|
-
}
|
|
2204
|
-
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2205
|
-
throw new HttpError(409, msg);
|
|
2206
|
-
}
|
|
2207
|
-
throw new HttpError(400, msg);
|
|
2208
|
-
}
|
|
2209
|
-
return;
|
|
2210
|
-
}
|
|
2211
|
-
const skillCloseMatch = path.match(/^\/v1\/skills\/(\d+)\/close$/);
|
|
2212
|
-
if (method === 'POST' && skillCloseMatch) {
|
|
2213
|
-
const id = parseInt(skillCloseMatch[1], 10);
|
|
2214
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2215
|
-
try {
|
|
2216
|
-
const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2217
|
-
sendJson(res, 200, { skill });
|
|
2218
|
-
}
|
|
2219
|
-
catch (e) {
|
|
2220
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2221
|
-
if (msg.includes('not found')) {
|
|
2222
|
-
throw new HttpError(404, msg);
|
|
2223
|
-
}
|
|
2224
|
-
if (msg.includes('not active')) {
|
|
2225
|
-
throw new HttpError(409, msg);
|
|
2226
|
-
}
|
|
2227
|
-
throw e;
|
|
2228
|
-
}
|
|
2229
|
-
return;
|
|
2230
|
-
}
|
|
2231
|
-
const skillByIdMatch = path.match(/^\/v1\/skills\/(\d+)$/);
|
|
2232
|
-
if (method === 'GET' && skillByIdMatch) {
|
|
2233
|
-
const id = parseInt(skillByIdMatch[1], 10);
|
|
2234
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2235
|
-
const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
|
|
2236
|
-
if (!skill) {
|
|
2237
|
-
throw new HttpError(404, `skill ${id} not found`);
|
|
2238
|
-
}
|
|
2239
|
-
sendJson(res, 200, { skill });
|
|
2240
|
-
return;
|
|
2241
|
-
}
|
|
2242
|
-
// ── E2 project_brief routes ──
|
|
2243
|
-
//
|
|
2244
|
-
// 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
|
|
2245
|
-
// /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
|
|
2246
|
-
// /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
|
|
2247
|
-
// from the repo's receipts; dryRun returns {markdown} without writing; ordered
|
|
2248
|
-
// before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
|
|
2249
|
-
// POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
|
|
2250
|
-
// changeSummary 4096. The store validates + throws; the boundary maps validation
|
|
2251
|
-
// -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
|
|
2252
|
-
if (method === 'POST' && path === '/v1/project-briefs') {
|
|
2253
|
-
const body = await parseJsonBody(req);
|
|
2254
|
-
const repo = body['repo'];
|
|
2255
|
-
if (!isJsonString(repo) || repo.trim().length === 0) {
|
|
2256
|
-
throw new HttpError(400, 'repo is required (non-empty string)');
|
|
2257
|
-
}
|
|
2258
|
-
if (repo.length > 256) {
|
|
2259
|
-
throw new HttpError(400, 'repo exceeds 256-character cap');
|
|
2260
|
-
}
|
|
2261
|
-
const summary = body['summary'];
|
|
2262
|
-
if (!isJsonString(summary) || summary.trim().length === 0) {
|
|
2263
|
-
throw new HttpError(400, 'summary is required (non-empty string)');
|
|
2264
|
-
}
|
|
2265
|
-
if (summary.length > 8192) {
|
|
2266
|
-
throw new HttpError(400, 'summary exceeds 8192-character cap');
|
|
2267
|
-
}
|
|
2268
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2269
|
-
try {
|
|
2270
|
-
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2271
|
-
repo,
|
|
2272
|
-
summary,
|
|
2273
|
-
}, ctx.actor.subject);
|
|
2274
|
-
sendJson(res, 201, { brief });
|
|
2275
|
-
}
|
|
2276
|
-
catch (e) {
|
|
2277
|
-
// saveProjectBrief throws on validation (single-line repo etc.) -> 400.
|
|
2278
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
2279
|
-
}
|
|
2280
|
-
return;
|
|
2281
|
-
}
|
|
2282
|
-
if (method === 'GET' && path === '/v1/project-briefs') {
|
|
2283
|
-
const status = query.get('status') ?? 'all';
|
|
2284
|
-
const repoFilter = query.get('repo');
|
|
2285
|
-
const limit = parseListLimit(query.get('limit'));
|
|
2286
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2287
|
-
const listOpts = { limit };
|
|
2288
|
-
if (repoFilter !== null && repoFilter.trim().length > 0) {
|
|
2289
|
-
listOpts.repo = repoFilter.trim();
|
|
2290
|
-
}
|
|
2291
|
-
if (status !== 'all') {
|
|
2292
|
-
if (!isSetMember(VALID_BRIEF_STATES, status)) {
|
|
2293
|
-
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
2294
|
-
}
|
|
2295
|
-
listOpts.status = status;
|
|
2296
|
-
}
|
|
2297
|
-
const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
|
|
2298
|
-
sendJson(res, 200, { briefs });
|
|
2299
|
-
return;
|
|
2300
|
-
}
|
|
2301
|
-
// The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
|
|
2302
|
-
// so the /(\d+)/ routes would not match it, but order it first).
|
|
2303
|
-
if (method === 'POST' && path === '/v1/project-briefs/refresh') {
|
|
2304
|
-
const body = await parseJsonBody(req);
|
|
2305
|
-
const repo = body['repo'];
|
|
2306
|
-
if (!isJsonString(repo) || repo.trim().length === 0) {
|
|
2307
|
-
throw new HttpError(400, 'repo is required (non-empty string)');
|
|
2308
|
-
}
|
|
2309
|
-
if (repo.length > 256) {
|
|
2310
|
-
throw new HttpError(400, 'repo exceeds 256-character cap');
|
|
2311
|
-
}
|
|
2312
|
-
const dryRun = body['dryRun'] === true;
|
|
2313
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2314
|
-
try {
|
|
2315
|
-
if (dryRun) {
|
|
2316
|
-
const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
|
|
2317
|
-
sendJson(res, 200, { markdown, receiptCount });
|
|
2318
|
-
return;
|
|
2319
|
-
}
|
|
2320
|
-
const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
|
|
2321
|
-
sendJson(res, 200, { brief });
|
|
2322
|
-
}
|
|
2323
|
-
catch (e) {
|
|
2324
|
-
// A refresh race (the active brief is closed/superseded between
|
|
2325
|
-
// loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
|
|
2326
|
-
// validation error — map it to 409 like the explicit supersede route
|
|
2327
|
-
// (codex-review 2026-05-30, P3).
|
|
2328
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2329
|
-
if (msg.includes('not found')) {
|
|
2330
|
-
throw new HttpError(404, msg);
|
|
2331
|
-
}
|
|
2332
|
-
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2333
|
-
throw new HttpError(409, msg);
|
|
2334
|
-
}
|
|
2335
|
-
throw new HttpError(400, msg);
|
|
2336
|
-
}
|
|
2337
|
-
return;
|
|
2338
|
-
}
|
|
2339
|
-
const briefSupersedeMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/supersede$/);
|
|
2340
|
-
if (method === 'POST' && briefSupersedeMatch) {
|
|
2341
|
-
const id = parseInt(briefSupersedeMatch[1], 10);
|
|
2342
|
-
const body = await parseJsonBody(req);
|
|
2343
|
-
const summary = body['summary'];
|
|
2344
|
-
if (!isJsonString(summary) || summary.trim().length === 0) {
|
|
2345
|
-
throw new HttpError(400, 'summary is required (non-empty string)');
|
|
2346
|
-
}
|
|
2347
|
-
if (summary.length > 8192) {
|
|
2348
|
-
throw new HttpError(400, 'summary exceeds 8192-character cap');
|
|
2349
|
-
}
|
|
2350
|
-
const changeRaw = body['changeSummary'];
|
|
2351
|
-
let changeSummary;
|
|
2352
|
-
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2353
|
-
if (!isJsonString(changeRaw)) {
|
|
2354
|
-
throw new HttpError(400, 'changeSummary must be a string');
|
|
2355
|
-
}
|
|
2356
|
-
if (changeRaw.length > 4096) {
|
|
2357
|
-
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2358
|
-
}
|
|
2359
|
-
changeSummary = changeRaw;
|
|
2360
|
-
}
|
|
2361
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2362
|
-
const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
|
|
2363
|
-
if (!existing) {
|
|
2364
|
-
throw new HttpError(404, `project brief ${id} not found`);
|
|
2365
|
-
}
|
|
2366
|
-
try {
|
|
2367
|
-
const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
|
|
2368
|
-
repo: existing.repo,
|
|
2369
|
-
summary,
|
|
2370
|
-
changeSummary,
|
|
2371
|
-
supersedesBriefId: id,
|
|
2372
|
-
}, ctx.actor.subject);
|
|
2373
|
-
sendJson(res, 200, { brief });
|
|
2374
|
-
}
|
|
2375
|
-
catch (e) {
|
|
2376
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2377
|
-
if (msg.includes('not found')) {
|
|
2378
|
-
throw new HttpError(404, msg);
|
|
2379
|
-
}
|
|
2380
|
-
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2381
|
-
throw new HttpError(409, msg);
|
|
2382
|
-
}
|
|
2383
|
-
throw new HttpError(400, msg);
|
|
2384
|
-
}
|
|
2385
|
-
return;
|
|
2386
|
-
}
|
|
2387
|
-
const briefCloseMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/close$/);
|
|
2388
|
-
if (method === 'POST' && briefCloseMatch) {
|
|
2389
|
-
const id = parseInt(briefCloseMatch[1], 10);
|
|
2390
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2391
|
-
try {
|
|
2392
|
-
const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2393
|
-
sendJson(res, 200, { brief });
|
|
2394
|
-
}
|
|
2395
|
-
catch (e) {
|
|
2396
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2397
|
-
if (msg.includes('not found')) {
|
|
2398
|
-
throw new HttpError(404, msg);
|
|
2399
|
-
}
|
|
2400
|
-
if (msg.includes('not active')) {
|
|
2401
|
-
throw new HttpError(409, msg);
|
|
2402
|
-
}
|
|
2403
|
-
throw e;
|
|
2404
|
-
}
|
|
2405
|
-
return;
|
|
2406
|
-
}
|
|
2407
|
-
const briefByIdMatch = path.match(/^\/v1\/project-briefs\/(\d+)$/);
|
|
2408
|
-
if (method === 'GET' && briefByIdMatch) {
|
|
2409
|
-
const id = parseInt(briefByIdMatch[1], 10);
|
|
2410
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2411
|
-
const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
|
|
2412
|
-
if (!brief) {
|
|
2413
|
-
throw new HttpError(404, `project brief ${id} not found`);
|
|
2414
|
-
}
|
|
2415
|
-
sendJson(res, 200, { brief });
|
|
2416
|
-
return;
|
|
2417
|
-
}
|
|
2418
|
-
// ── E2 customer_note routes ──
|
|
2419
|
-
//
|
|
2420
|
-
// 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
|
|
2421
|
-
// note), GET /v1/customer-notes (list; status + customer filter; shared
|
|
2422
|
-
// parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
|
|
2423
|
-
// POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
|
|
2424
|
-
// changeSummary 4096. The store validates + throws; the boundary maps validation ->
|
|
2425
|
-
// 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
|
|
2426
|
-
if (method === 'POST' && path === '/v1/customer-notes') {
|
|
2427
|
-
const body = await parseJsonBody(req);
|
|
2428
|
-
const customer = body['customer'];
|
|
2429
|
-
if (!isJsonString(customer) || customer.trim().length === 0) {
|
|
2430
|
-
throw new HttpError(400, 'customer is required (non-empty string)');
|
|
2431
|
-
}
|
|
2432
|
-
if (customer.length > 256) {
|
|
2433
|
-
throw new HttpError(400, 'customer exceeds 256-character cap');
|
|
2434
|
-
}
|
|
2435
|
-
const note = body['note'];
|
|
2436
|
-
if (!isJsonString(note) || note.trim().length === 0) {
|
|
2437
|
-
throw new HttpError(400, 'note is required (non-empty string)');
|
|
2438
|
-
}
|
|
2439
|
-
if (note.length > 8192) {
|
|
2440
|
-
throw new HttpError(400, 'note exceeds 8192-character cap');
|
|
2441
|
-
}
|
|
2442
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2443
|
-
try {
|
|
2444
|
-
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2445
|
-
customer,
|
|
2446
|
-
note,
|
|
2447
|
-
}, ctx.actor.subject);
|
|
2448
|
-
sendJson(res, 201, { note: customerNote });
|
|
2449
|
-
}
|
|
2450
|
-
catch (e) {
|
|
2451
|
-
// saveCustomerNote throws on validation (single-line customer etc.) -> 400.
|
|
2452
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
2453
|
-
}
|
|
2454
|
-
return;
|
|
2455
|
-
}
|
|
2456
|
-
if (method === 'GET' && path === '/v1/customer-notes') {
|
|
2457
|
-
const status = query.get('status') ?? 'all';
|
|
2458
|
-
const customerFilter = query.get('customer');
|
|
2459
|
-
const limit = parseListLimit(query.get('limit'));
|
|
2460
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2461
|
-
const listOpts = { limit };
|
|
2462
|
-
if (customerFilter !== null && customerFilter.trim().length > 0) {
|
|
2463
|
-
listOpts.customer = customerFilter.trim();
|
|
2464
|
-
}
|
|
2465
|
-
if (status !== 'all') {
|
|
2466
|
-
if (!isSetMember(VALID_NOTE_STATES, status)) {
|
|
2467
|
-
throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
|
|
2468
|
-
}
|
|
2469
|
-
listOpts.status = status;
|
|
2470
|
-
}
|
|
2471
|
-
const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
|
|
2472
|
-
sendJson(res, 200, { notes });
|
|
2473
|
-
return;
|
|
2474
|
-
}
|
|
2475
|
-
const noteSupersedeMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/supersede$/);
|
|
2476
|
-
if (method === 'POST' && noteSupersedeMatch) {
|
|
2477
|
-
const id = parseInt(noteSupersedeMatch[1], 10);
|
|
2478
|
-
const body = await parseJsonBody(req);
|
|
2479
|
-
const note = body['note'];
|
|
2480
|
-
if (!isJsonString(note) || note.trim().length === 0) {
|
|
2481
|
-
throw new HttpError(400, 'note is required (non-empty string)');
|
|
2482
|
-
}
|
|
2483
|
-
if (note.length > 8192) {
|
|
2484
|
-
throw new HttpError(400, 'note exceeds 8192-character cap');
|
|
2485
|
-
}
|
|
2486
|
-
const changeRaw = body['changeSummary'];
|
|
2487
|
-
let changeSummary;
|
|
2488
|
-
if (changeRaw !== undefined && changeRaw !== null) {
|
|
2489
|
-
if (!isJsonString(changeRaw)) {
|
|
2490
|
-
throw new HttpError(400, 'changeSummary must be a string');
|
|
2491
|
-
}
|
|
2492
|
-
if (changeRaw.length > 4096) {
|
|
2493
|
-
throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
|
|
2494
|
-
}
|
|
2495
|
-
changeSummary = changeRaw;
|
|
2496
|
-
}
|
|
2497
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2498
|
-
const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
|
|
2499
|
-
if (!existing) {
|
|
2500
|
-
throw new HttpError(404, `customer note ${id} not found`);
|
|
2501
|
-
}
|
|
2502
|
-
try {
|
|
2503
|
-
const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
|
|
2504
|
-
customer: existing.customer,
|
|
2505
|
-
note,
|
|
2506
|
-
changeSummary,
|
|
2507
|
-
supersedesNoteId: id,
|
|
2508
|
-
}, ctx.actor.subject);
|
|
2509
|
-
sendJson(res, 200, { note: customerNote });
|
|
2510
|
-
}
|
|
2511
|
-
catch (e) {
|
|
2512
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2513
|
-
if (msg.includes('not found')) {
|
|
2514
|
-
throw new HttpError(404, msg);
|
|
2515
|
-
}
|
|
2516
|
-
if (msg.includes('not active') || msg.includes('could not be superseded')) {
|
|
2517
|
-
throw new HttpError(409, msg);
|
|
2518
|
-
}
|
|
2519
|
-
throw new HttpError(400, msg);
|
|
2520
|
-
}
|
|
2521
|
-
return;
|
|
2522
|
-
}
|
|
2523
|
-
const noteCloseMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/close$/);
|
|
2524
|
-
if (method === 'POST' && noteCloseMatch) {
|
|
2525
|
-
const id = parseInt(noteCloseMatch[1], 10);
|
|
2526
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2527
|
-
try {
|
|
2528
|
-
const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
|
|
2529
|
-
sendJson(res, 200, { note: customerNote });
|
|
2530
|
-
}
|
|
2531
|
-
catch (e) {
|
|
2532
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2533
|
-
if (msg.includes('not found')) {
|
|
2534
|
-
throw new HttpError(404, msg);
|
|
2535
|
-
}
|
|
2536
|
-
if (msg.includes('not active')) {
|
|
2537
|
-
throw new HttpError(409, msg);
|
|
2538
|
-
}
|
|
2539
|
-
throw e;
|
|
2540
|
-
}
|
|
2541
|
-
return;
|
|
2542
|
-
}
|
|
2543
|
-
const noteByIdMatch = path.match(/^\/v1\/customer-notes\/(\d+)$/);
|
|
2544
|
-
if (method === 'GET' && noteByIdMatch) {
|
|
2545
|
-
const id = parseInt(noteByIdMatch[1], 10);
|
|
2546
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
2547
|
-
const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
|
|
2548
|
-
if (!customerNote) {
|
|
2549
|
-
throw new HttpError(404, `customer note ${id} not found`);
|
|
2550
|
-
}
|
|
2551
|
-
sendJson(res, 200, { note: customerNote });
|
|
2552
|
-
return;
|
|
2553
|
-
}
|
|
2554
|
-
// ── POST /v1/connectors/slack/events ──
|
|
2555
|
-
//
|
|
2556
|
-
// Slack Events API webhook. Auth is signature-based (HMAC over the raw
|
|
2557
|
-
// body with SLACK_SIGNING_SECRET); Bearer is NOT required, which is why
|
|
2558
|
-
// this route is in PUBLIC_ROUTES. The route is responsible for:
|
|
2559
|
-
// 1. Echoing the one-time url_verification challenge.
|
|
2560
|
-
// 2. Verifying the HMAC on every other inbound payload.
|
|
2561
|
-
// 3. Resolving body.team_id → tenantId via slack_workspaces, falling
|
|
2562
|
-
// back to HIPPO_TENANT then 'default'.
|
|
2563
|
-
// 4. Dispatching event_callback envelopes to ingestMessage /
|
|
2564
|
-
// handleMessageDeleted.
|
|
2565
|
-
// 5. Parking malformed or unhandled payloads in slack_dlq and STILL
|
|
2566
|
-
// ACKing 200 — Slack retries forever otherwise.
|
|
2567
|
-
//
|
|
2568
|
-
// Review patch #7: when SLACK_SIGNING_SECRET is unset we return 404, not
|
|
2569
|
-
// 503, so an external probe cannot distinguish "route gated off by config"
|
|
2570
|
-
// from "route does not exist on this build".
|
|
2571
|
-
if (method === 'POST' && path === '/v1/connectors/slack/events') {
|
|
2572
|
-
// Bearer auth deliberately skipped — this route is in PUBLIC_ROUTES
|
|
2573
|
-
// and authenticates via the Slack HMAC signature instead.
|
|
2574
|
-
if (!isPublicRoute(method, path)) {
|
|
2575
|
-
// Defensive: PUBLIC_ROUTES drift would land here. Fail closed.
|
|
2576
|
-
throw new HttpError(401, 'auth required');
|
|
2577
|
-
}
|
|
2578
|
-
const rawBody = await readBody(req);
|
|
2579
|
-
const secret = process.env.SLACK_SIGNING_SECRET;
|
|
2580
|
-
if (!secret) {
|
|
2581
|
-
res.writeHead(404, JSON_HEADERS);
|
|
2582
|
-
res.end(JSON.stringify({ error: 'not found' }));
|
|
2583
|
-
return;
|
|
2584
|
-
}
|
|
2585
|
-
const previousSecret = process.env.SLACK_SIGNING_SECRET_PREVIOUS;
|
|
2586
|
-
const sig = req.headers['x-slack-signature'];
|
|
2587
|
-
const tsHdr = req.headers['x-slack-request-timestamp'];
|
|
2588
|
-
const sigStr = isHeaderString(sig) ? sig : null;
|
|
2589
|
-
const tsStr = isHeaderString(tsHdr) ? tsHdr : null;
|
|
2590
|
-
if (sigStr === null ||
|
|
2591
|
-
tsStr === null ||
|
|
2592
|
-
!verifySlackSignature({
|
|
2593
|
-
rawBody,
|
|
2594
|
-
timestamp: tsStr,
|
|
2595
|
-
signature: sigStr,
|
|
2596
|
-
signingSecret: secret,
|
|
2597
|
-
previousSecret,
|
|
2598
|
-
})) {
|
|
2599
|
-
throw new HttpError(401, 'invalid Slack signature');
|
|
2600
|
-
}
|
|
2601
|
-
// Cheap regex extracts team_id from a (possibly malformed) raw body so the
|
|
2602
|
-
// DLQ row carries it for triage even when JSON.parse fails.
|
|
2603
|
-
const teamIdFromRaw = (() => {
|
|
2604
|
-
const m = rawBody.match(/"team_id"\s*:\s*"([^"]+)"/);
|
|
2605
|
-
return m ? m[1] : null;
|
|
2606
|
-
})();
|
|
2607
|
-
let body;
|
|
2608
|
-
try {
|
|
2609
|
-
body = JSON.parse(rawBody);
|
|
2610
|
-
}
|
|
2611
|
-
catch {
|
|
2612
|
-
// v1.12.6 (B4): parse-failure tenant attribution. Pre-fix this path
|
|
2613
|
-
// wrote tenant_id=HIPPO_TENANT regardless of the originating workspace,
|
|
2614
|
-
// silently routing parse failures from workspace A into the deployment's
|
|
2615
|
-
// tenant DLQ. Fix: use the regex-extracted teamIdFromRaw to resolve
|
|
2616
|
-
// tenant via the same slack_workspaces table the happy path uses
|
|
2617
|
-
// (resolveTenantForTeam at line ~1044). When teamIdFromRaw is null
|
|
2618
|
-
// (totally unparseable body) OR the team is unknown, write with
|
|
2619
|
-
// tenantId=null so the row lands as '__unroutable__' (matching the
|
|
2620
|
-
// existing unroutable bucket convention).
|
|
2621
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2622
|
-
try {
|
|
2623
|
-
const parseFailTenant = teamIdFromRaw !== null ? resolveTenantForTeam(db, teamIdFromRaw) : null;
|
|
2624
|
-
writeToDlq(db, {
|
|
2625
|
-
tenantId: parseFailTenant, // null → '__unroutable__' sentinel
|
|
2626
|
-
teamId: teamIdFromRaw,
|
|
2627
|
-
rawPayload: rawBody,
|
|
2628
|
-
error: 'invalid JSON',
|
|
2629
|
-
bucket: 'parse_error',
|
|
2630
|
-
signature: sigStr,
|
|
2631
|
-
slackTimestamp: tsStr,
|
|
2632
|
-
});
|
|
2633
|
-
}
|
|
2634
|
-
finally {
|
|
2635
|
-
closeHippoDb(db);
|
|
2636
|
-
}
|
|
2637
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2638
|
-
return;
|
|
2639
|
-
}
|
|
2640
|
-
if (isJsonObjectRecord(body)) {
|
|
2641
|
-
const bodyRecord = body;
|
|
2642
|
-
if (bodyRecord.type === 'url_verification') {
|
|
2643
|
-
sendJson(res, 200, {
|
|
2644
|
-
challenge: String(bodyRecord.challenge ?? ''),
|
|
2645
|
-
});
|
|
2646
|
-
return;
|
|
2647
|
-
}
|
|
2648
|
-
}
|
|
2649
|
-
// Resolve tenant. v0.39 fail-closed: when slack_workspaces is non-empty
|
|
2650
|
-
// and the team_id is unknown, resolveTenantForTeam returns null and we
|
|
2651
|
-
// park the envelope in slack_dlq with bucket='unroutable'. Mandatory ACK
|
|
2652
|
-
// 200 so Slack stops retrying; do NOT call ingest.
|
|
2653
|
-
let resolvedTenant = null;
|
|
2654
|
-
if (body !== undefined && isSlackEventEnvelope(body)) {
|
|
2655
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2656
|
-
try {
|
|
2657
|
-
resolvedTenant = resolveTenantForTeam(db, body.team_id);
|
|
2658
|
-
}
|
|
2659
|
-
finally {
|
|
2660
|
-
closeHippoDb(db);
|
|
2661
|
-
}
|
|
2662
|
-
if (resolvedTenant === null) {
|
|
2663
|
-
const db2 = openHippoDb(opts.hippoRoot);
|
|
2664
|
-
try {
|
|
2665
|
-
writeToDlq(db2, {
|
|
2666
|
-
tenantId: null, // unroutable — stored as '__unroutable__'
|
|
2667
|
-
teamId: body.team_id,
|
|
2668
|
-
rawPayload: rawBody,
|
|
2669
|
-
error: `unroutable team_id: ${body.team_id}`,
|
|
2670
|
-
bucket: 'unroutable',
|
|
2671
|
-
signature: sigStr,
|
|
2672
|
-
slackTimestamp: tsStr,
|
|
2673
|
-
});
|
|
2674
|
-
}
|
|
2675
|
-
finally {
|
|
2676
|
-
closeHippoDb(db2);
|
|
2677
|
-
}
|
|
2678
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2679
|
-
return;
|
|
2680
|
-
}
|
|
2681
|
-
}
|
|
2682
|
-
else {
|
|
2683
|
-
// Non-envelope payload: use env tenant for the DLQ row's bookkeeping.
|
|
2684
|
-
resolvedTenant = process.env.HIPPO_TENANT ?? 'default';
|
|
2685
|
-
}
|
|
2686
|
-
const ctx = {
|
|
2687
|
-
hippoRoot: opts.hippoRoot,
|
|
2688
|
-
tenantId: resolvedTenant,
|
|
2689
|
-
actor: adminActor('connector:slack'),
|
|
2690
|
-
};
|
|
2691
|
-
if (body === undefined || !isSlackEventEnvelope(body)) {
|
|
2692
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2693
|
-
try {
|
|
2694
|
-
writeToDlq(db, {
|
|
2695
|
-
tenantId: ctx.tenantId,
|
|
2696
|
-
teamId: teamIdFromRaw,
|
|
2697
|
-
rawPayload: rawBody,
|
|
2698
|
-
error: 'not an event_callback envelope',
|
|
2699
|
-
bucket: 'parse_error',
|
|
2700
|
-
signature: sigStr,
|
|
2701
|
-
slackTimestamp: tsStr,
|
|
2702
|
-
});
|
|
2703
|
-
}
|
|
2704
|
-
finally {
|
|
2705
|
-
closeHippoDb(db);
|
|
2706
|
-
}
|
|
2707
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2708
|
-
return;
|
|
2709
|
-
}
|
|
2710
|
-
const inner = body.event;
|
|
2711
|
-
if (isSlackMessageEvent(inner)) {
|
|
2712
|
-
if (inner.subtype === 'message_deleted' && inner.deleted_ts) {
|
|
2713
|
-
const r = handleMessageDeleted(ctx, {
|
|
2714
|
-
teamId: body.team_id,
|
|
2715
|
-
channelId: inner.channel,
|
|
2716
|
-
deletedTs: inner.deleted_ts,
|
|
2717
|
-
eventId: body.event_id,
|
|
2718
|
-
});
|
|
2719
|
-
sendJson(res, 200, { ok: true, status: r.status });
|
|
2720
|
-
return;
|
|
2721
|
-
}
|
|
2722
|
-
const r = ingestMessage(ctx, {
|
|
2723
|
-
teamId: body.team_id,
|
|
2724
|
-
// channel privacy isn't on the inner event; use channel_type as a
|
|
2725
|
-
// proxy. 'group'|'im'|'mpim' → private. 'channel' → public. Unknown
|
|
2726
|
-
// → private (fail closed).
|
|
2727
|
-
channel: {
|
|
2728
|
-
id: inner.channel,
|
|
2729
|
-
is_private: inner.channel_type !== 'channel',
|
|
2730
|
-
is_im: inner.channel_type === 'im',
|
|
2731
|
-
is_mpim: inner.channel_type === 'mpim',
|
|
2732
|
-
},
|
|
2733
|
-
message: inner,
|
|
2734
|
-
eventId: body.event_id,
|
|
2735
|
-
});
|
|
2736
|
-
sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
|
|
2737
|
-
return;
|
|
2738
|
-
}
|
|
2739
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2740
|
-
try {
|
|
2741
|
-
writeToDlq(db, {
|
|
2742
|
-
tenantId: ctx.tenantId,
|
|
2743
|
-
teamId: body.team_id,
|
|
2744
|
-
rawPayload: rawBody,
|
|
2745
|
-
error: `unhandled event type: ${inner.type ?? 'unknown'}`,
|
|
2746
|
-
bucket: 'parse_error',
|
|
2747
|
-
signature: sigStr,
|
|
2748
|
-
slackTimestamp: tsStr,
|
|
2749
|
-
});
|
|
2750
|
-
}
|
|
2751
|
-
finally {
|
|
2752
|
-
closeHippoDb(db);
|
|
2753
|
-
}
|
|
2754
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2755
|
-
return;
|
|
2756
|
-
}
|
|
2757
|
-
// ── POST /v1/connectors/github/events ──
|
|
2758
|
-
//
|
|
2759
|
-
// GitHub webhook receiver. Mirrors the Slack route shape but with
|
|
2760
|
-
// GitHub-specific idioms:
|
|
2761
|
-
// 1. HMAC SHA-256 over the raw body (X-Hub-Signature-256), no timestamp.
|
|
2762
|
-
// 2. Event type discriminated by the X-GitHub-Event header (not body.type).
|
|
2763
|
-
// 3. X-GitHub-Delivery is required audit metadata (NOT the dedupe seam — see
|
|
2764
|
-
// computeIdempotencyKey, which folds the signed body into the key so a
|
|
2765
|
-
// replayed body with a fresh delivery UUID still dedupes).
|
|
2766
|
-
// 4. Tenant resolved by installation.id → github_installations, then by
|
|
2767
|
-
// repository.full_name → github_repositories (PAT-mode multi-tenant).
|
|
2768
|
-
// 5. ALWAYS ACK 200 on signed envelopes (DLQ included). 401 only on bad
|
|
2769
|
-
// signature; 404 only when GITHUB_WEBHOOK_SECRET is unset (don't expose
|
|
2770
|
-
// the route's existence on builds where it's gated off).
|
|
2771
|
-
if (method === 'POST' && path === '/v1/connectors/github/events') {
|
|
2772
|
-
if (!isPublicRoute(method, path)) {
|
|
2773
|
-
throw new HttpError(401, 'auth required');
|
|
2774
|
-
}
|
|
2775
|
-
const rawBody = await readBody(req);
|
|
2776
|
-
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
|
2777
|
-
if (!secret) {
|
|
2778
|
-
res.writeHead(404, JSON_HEADERS);
|
|
2779
|
-
res.end(JSON.stringify({ error: 'not found' }));
|
|
2780
|
-
return;
|
|
2781
|
-
}
|
|
2782
|
-
const previousSecret = process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS;
|
|
2783
|
-
const sigHdr = req.headers['x-hub-signature-256'];
|
|
2784
|
-
const eventHdr = req.headers['x-github-event'];
|
|
2785
|
-
const deliveryHdr = req.headers['x-github-delivery'];
|
|
2786
|
-
const sigStr = isHeaderString(sigHdr) ? sigHdr : null;
|
|
2787
|
-
const eventName = isHeaderString(eventHdr) ? eventHdr : null;
|
|
2788
|
-
const deliveryId = isHeaderString(deliveryHdr) ? deliveryHdr : null;
|
|
2789
|
-
if (sigStr === null ||
|
|
2790
|
-
!verifyGitHubSignature({
|
|
2791
|
-
rawBody,
|
|
2792
|
-
signature: sigStr,
|
|
2793
|
-
webhookSecret: secret,
|
|
2794
|
-
previousSecret,
|
|
2795
|
-
})) {
|
|
2796
|
-
throw new HttpError(401, 'invalid GitHub signature');
|
|
2797
|
-
}
|
|
2798
|
-
// Signature OK from here on. Everything else is ACK-200; bad envelopes go
|
|
2799
|
-
// to the DLQ and a human can replay later.
|
|
2800
|
-
// Cheap regex extraction of installation_id / repo for DLQ rows that fail
|
|
2801
|
-
// to JSON.parse — gives operators something to triage.
|
|
2802
|
-
const installationFromRaw = (() => {
|
|
2803
|
-
const m = rawBody.match(/"installation"\s*:\s*\{[^}]*"id"\s*:\s*(\d+)/);
|
|
2804
|
-
return m ? m[1] : null;
|
|
2805
|
-
})();
|
|
2806
|
-
const repoFromRaw = (() => {
|
|
2807
|
-
const m = rawBody.match(/"full_name"\s*:\s*"([^"]+)"/);
|
|
2808
|
-
return m ? m[1] : null;
|
|
2809
|
-
})();
|
|
2810
|
-
if (deliveryId === null) {
|
|
2811
|
-
// Body was signed but caller omitted the audit header. Park.
|
|
2812
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2813
|
-
try {
|
|
2814
|
-
writeToGitHubDlq(db, {
|
|
2815
|
-
tenantId: process.env.HIPPO_TENANT ?? 'default',
|
|
2816
|
-
rawPayload: rawBody,
|
|
2817
|
-
error: 'missing X-GitHub-Delivery header',
|
|
2818
|
-
bucket: 'parse_error',
|
|
2819
|
-
eventName,
|
|
2820
|
-
deliveryId: null,
|
|
2821
|
-
signature: sigStr,
|
|
2822
|
-
installationId: installationFromRaw,
|
|
2823
|
-
repoFullName: repoFromRaw,
|
|
2824
|
-
});
|
|
2825
|
-
}
|
|
2826
|
-
finally {
|
|
2827
|
-
closeHippoDb(db);
|
|
2828
|
-
}
|
|
2829
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2830
|
-
return;
|
|
2831
|
-
}
|
|
2832
|
-
// Ping fires once at hook creation. Don't ingest, don't DLQ — just pong.
|
|
2833
|
-
if (eventName === 'ping') {
|
|
2834
|
-
sendJson(res, 200, { pong: true });
|
|
2835
|
-
return;
|
|
2836
|
-
}
|
|
2837
|
-
const ALLOWED_EVENTS = new Set([
|
|
2838
|
-
'issues',
|
|
2839
|
-
'issue_comment',
|
|
2840
|
-
'pull_request',
|
|
2841
|
-
'pull_request_review_comment',
|
|
2842
|
-
]);
|
|
2843
|
-
if (eventName === null || !ALLOWED_EVENTS.has(eventName)) {
|
|
2844
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2845
|
-
try {
|
|
2846
|
-
writeToGitHubDlq(db, {
|
|
2847
|
-
tenantId: process.env.HIPPO_TENANT ?? 'default',
|
|
2848
|
-
rawPayload: rawBody,
|
|
2849
|
-
error: `unhandled event: ${eventName ?? '(missing X-GitHub-Event)'}`,
|
|
2850
|
-
bucket: 'unhandled',
|
|
2851
|
-
eventName,
|
|
2852
|
-
deliveryId,
|
|
2853
|
-
signature: sigStr,
|
|
2854
|
-
installationId: installationFromRaw,
|
|
2855
|
-
repoFullName: repoFromRaw,
|
|
2856
|
-
});
|
|
2857
|
-
}
|
|
2858
|
-
finally {
|
|
2859
|
-
closeHippoDb(db);
|
|
2860
|
-
}
|
|
2861
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2862
|
-
return;
|
|
2863
|
-
}
|
|
2864
|
-
let body;
|
|
2865
|
-
try {
|
|
2866
|
-
body = JSON.parse(rawBody);
|
|
2867
|
-
}
|
|
2868
|
-
catch {
|
|
2869
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2870
|
-
try {
|
|
2871
|
-
writeToGitHubDlq(db, {
|
|
2872
|
-
tenantId: process.env.HIPPO_TENANT ?? 'default',
|
|
2873
|
-
rawPayload: rawBody,
|
|
2874
|
-
error: 'invalid JSON',
|
|
2875
|
-
bucket: 'parse_error',
|
|
2876
|
-
eventName,
|
|
2877
|
-
deliveryId,
|
|
2878
|
-
signature: sigStr,
|
|
2879
|
-
installationId: installationFromRaw,
|
|
2880
|
-
repoFullName: repoFromRaw,
|
|
2881
|
-
});
|
|
2882
|
-
}
|
|
2883
|
-
finally {
|
|
2884
|
-
closeHippoDb(db);
|
|
2885
|
-
}
|
|
2886
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2887
|
-
return;
|
|
2888
|
-
}
|
|
2889
|
-
if (body === undefined || !isGitHubWebhookEnvelope(body)) {
|
|
2890
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2891
|
-
try {
|
|
2892
|
-
writeToGitHubDlq(db, {
|
|
2893
|
-
tenantId: process.env.HIPPO_TENANT ?? 'default',
|
|
2894
|
-
rawPayload: rawBody,
|
|
2895
|
-
error: 'not a GitHub webhook envelope',
|
|
2896
|
-
bucket: 'parse_error',
|
|
2897
|
-
eventName,
|
|
2898
|
-
deliveryId,
|
|
2899
|
-
signature: sigStr,
|
|
2900
|
-
installationId: installationFromRaw,
|
|
2901
|
-
repoFullName: repoFromRaw,
|
|
2902
|
-
});
|
|
2903
|
-
}
|
|
2904
|
-
finally {
|
|
2905
|
-
closeHippoDb(db);
|
|
2906
|
-
}
|
|
2907
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2908
|
-
return;
|
|
2909
|
-
}
|
|
2910
|
-
const installationId = body.installation?.id != null ? String(body.installation.id) : null;
|
|
2911
|
-
const repoFullName = body.repository?.full_name ?? null;
|
|
2912
|
-
// Tenant resolution. Fail closed on multi-tenant installs with unknown
|
|
2913
|
-
// routing — same policy as Slack.
|
|
2914
|
-
let resolvedTenant;
|
|
2915
|
-
{
|
|
2916
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2917
|
-
try {
|
|
2918
|
-
resolvedTenant = resolveTenantForGitHub(db, {
|
|
2919
|
-
installationId,
|
|
2920
|
-
repoFullName,
|
|
2921
|
-
});
|
|
2922
|
-
}
|
|
2923
|
-
finally {
|
|
2924
|
-
closeHippoDb(db);
|
|
2925
|
-
}
|
|
2926
|
-
}
|
|
2927
|
-
if (resolvedTenant === null) {
|
|
2928
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2929
|
-
try {
|
|
2930
|
-
writeToGitHubDlq(db, {
|
|
2931
|
-
tenantId: null,
|
|
2932
|
-
rawPayload: rawBody,
|
|
2933
|
-
error: `unroutable: installation_id=${installationId ?? '(none)'} repo=${repoFullName ?? '(none)'}`,
|
|
2934
|
-
bucket: 'unroutable',
|
|
2935
|
-
eventName,
|
|
2936
|
-
deliveryId,
|
|
2937
|
-
signature: sigStr,
|
|
2938
|
-
installationId,
|
|
2939
|
-
repoFullName,
|
|
2940
|
-
});
|
|
2941
|
-
}
|
|
2942
|
-
finally {
|
|
2943
|
-
closeHippoDb(db);
|
|
2944
|
-
}
|
|
2945
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2946
|
-
return;
|
|
2947
|
-
}
|
|
2948
|
-
const ctx = {
|
|
2949
|
-
hippoRoot: opts.hippoRoot,
|
|
2950
|
-
tenantId: resolvedTenant,
|
|
2951
|
-
actor: adminActor('connector:github'),
|
|
2952
|
-
};
|
|
2953
|
-
// Dispatch by event header. Type guards cross-check the body shape against
|
|
2954
|
-
// the header so a payload of one event type cannot satisfy another's guard.
|
|
2955
|
-
if (eventName === 'issues' && isGitHubIssueEvent(body, 'issues')) {
|
|
2956
|
-
if (body.action === 'deleted') {
|
|
2957
|
-
// GitHub does fire issues.deleted (admin-initiated). Don't archive — V1
|
|
2958
|
-
// policy is to log and let an operator decide. Archive could lose the
|
|
2959
|
-
// memory if the issue is being moved between accounts.
|
|
2960
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
2961
|
-
try {
|
|
2962
|
-
writeToGitHubDlq(db, {
|
|
2963
|
-
tenantId: resolvedTenant,
|
|
2964
|
-
rawPayload: rawBody,
|
|
2965
|
-
error: 'issues.deleted requires manual review',
|
|
2966
|
-
bucket: 'unhandled',
|
|
2967
|
-
eventName,
|
|
2968
|
-
deliveryId,
|
|
2969
|
-
signature: sigStr,
|
|
2970
|
-
installationId,
|
|
2971
|
-
repoFullName,
|
|
2972
|
-
});
|
|
2973
|
-
}
|
|
2974
|
-
finally {
|
|
2975
|
-
closeHippoDb(db);
|
|
2976
|
-
}
|
|
2977
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
2978
|
-
return;
|
|
2979
|
-
}
|
|
2980
|
-
const ingestInput = { eventName: 'issues', payload: body };
|
|
2981
|
-
const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
|
|
2982
|
-
sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
|
|
2983
|
-
return;
|
|
2984
|
-
}
|
|
2985
|
-
if (eventName === 'issue_comment' && isGitHubIssueCommentEvent(body, 'issue_comment')) {
|
|
2986
|
-
if (body.action === 'deleted') {
|
|
2987
|
-
const repo = body.repository?.full_name ?? '';
|
|
2988
|
-
const artifactRef = `github://${repo}/issue/${body.issue.number}/comment/${body.comment.id}`;
|
|
2989
|
-
// v1.3.2: deletion key uses a 'deleted:' namespace so it doesn't collide
|
|
2990
|
-
// with the ingest path's key for the same artifact. Without the prefix,
|
|
2991
|
-
// a previously-ingested comment's log row would make hasSeenKey return
|
|
2992
|
-
// true on the first deletion, short-circuiting archive. Codex round 3
|
|
2993
|
-
// P0 fix evolved through two iterations to land here.
|
|
2994
|
-
const idempotencyKey = computeGitHubDeletionKey(artifactRef, body.comment.updated_at ?? null);
|
|
2995
|
-
const r = handleGitHubCommentDeleted(ctx, {
|
|
2996
|
-
artifactRef,
|
|
2997
|
-
idempotencyKey,
|
|
2998
|
-
deliveryId,
|
|
2999
|
-
eventName,
|
|
3000
|
-
});
|
|
3001
|
-
sendJson(res, 200, { ok: true, status: r.status, archivedCount: r.archivedCount });
|
|
3002
|
-
return;
|
|
3003
|
-
}
|
|
3004
|
-
const ingestInput = { eventName: 'issue_comment', payload: body };
|
|
3005
|
-
const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
|
|
3006
|
-
sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
|
|
3007
|
-
return;
|
|
3008
|
-
}
|
|
3009
|
-
if (eventName === 'pull_request' && isGitHubPullRequestEvent(body, 'pull_request')) {
|
|
3010
|
-
const ingestInput = { eventName: 'pull_request', payload: body };
|
|
3011
|
-
const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
|
|
3012
|
-
sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
|
|
3013
|
-
return;
|
|
3014
|
-
}
|
|
3015
|
-
if (eventName === 'pull_request_review_comment' &&
|
|
3016
|
-
isGitHubPullRequestReviewCommentEvent(body, 'pull_request_review_comment')) {
|
|
3017
|
-
if (body.action === 'deleted') {
|
|
3018
|
-
const repo = body.repository?.full_name ?? '';
|
|
3019
|
-
const artifactRef = `github://${repo}/pull/${body.pull_request.number}/review_comment/${body.comment.id}`;
|
|
3020
|
-
// v1.3.2: see issue_comment branch comment above for the namespace rationale.
|
|
3021
|
-
const idempotencyKey = computeGitHubDeletionKey(artifactRef, body.comment.updated_at ?? null);
|
|
3022
|
-
const r = handleGitHubCommentDeleted(ctx, {
|
|
3023
|
-
artifactRef,
|
|
3024
|
-
idempotencyKey,
|
|
3025
|
-
deliveryId,
|
|
3026
|
-
eventName,
|
|
3027
|
-
});
|
|
3028
|
-
sendJson(res, 200, { ok: true, status: r.status, archivedCount: r.archivedCount });
|
|
3029
|
-
return;
|
|
3030
|
-
}
|
|
3031
|
-
const ingestInput = {
|
|
3032
|
-
eventName: 'pull_request_review_comment',
|
|
3033
|
-
payload: body,
|
|
3034
|
-
};
|
|
3035
|
-
const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
|
|
3036
|
-
sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
|
|
3037
|
-
return;
|
|
3038
|
-
}
|
|
3039
|
-
// Header allow-listed but body shape didn't satisfy the matching guard.
|
|
3040
|
-
{
|
|
3041
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
3042
|
-
try {
|
|
3043
|
-
writeToGitHubDlq(db, {
|
|
3044
|
-
tenantId: resolvedTenant,
|
|
3045
|
-
rawPayload: rawBody,
|
|
3046
|
-
error: `body shape did not match X-GitHub-Event=${eventName}`,
|
|
3047
|
-
bucket: 'parse_error',
|
|
3048
|
-
eventName,
|
|
3049
|
-
deliveryId,
|
|
3050
|
-
signature: sigStr,
|
|
3051
|
-
installationId,
|
|
3052
|
-
repoFullName,
|
|
3053
|
-
});
|
|
3054
|
-
}
|
|
3055
|
-
finally {
|
|
3056
|
-
closeHippoDb(db);
|
|
3057
|
-
}
|
|
3058
|
-
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
3059
|
-
return;
|
|
3060
|
-
}
|
|
3061
|
-
}
|
|
3062
|
-
// ── MCP-over-HTTP/SSE transport (Task 11) ──
|
|
3063
|
-
//
|
|
3064
|
-
// Two routes implement an MCP HTTP transport alongside the stdio one. Both
|
|
3065
|
-
// dispatch to the same `handleMcpRequest` as the stdio loop in src/mcp/server.ts.
|
|
3066
|
-
//
|
|
3067
|
-
// POST /mcp — Send a JSON-RPC request, get a JSON-RPC response synchronously
|
|
3068
|
-
// in the body. Content-type: application/json both ways.
|
|
3069
|
-
// GET /mcp/stream — Open an SSE stream for server-initiated messages.
|
|
3070
|
-
// v1 simplification: this stream is keepalive-only. Clients
|
|
3071
|
-
// that need server-pushed notifications/progress will see
|
|
3072
|
-
// only `: ping` comments every 30s. All real responses come
|
|
3073
|
-
// back synchronously on POST /mcp. This matches the
|
|
3074
|
-
// "synchronous JSON in body" leg of the MCP HTTP spec and
|
|
3075
|
-
// is enough for `tools/list` / `tools/call` round-trips.
|
|
3076
|
-
// Server-initiated SSE messages will be wired in a later task.
|
|
3077
|
-
//
|
|
3078
|
-
// Auth: same as /v1/* — Bearer token validated via `requireAuth`, with the
|
|
3079
|
-
// loopback no-auth fallback. SSE check runs once at stream-open.
|
|
3080
|
-
if (method === 'POST' && path === '/mcp') {
|
|
3081
|
-
// Build the same Context the /v1/* routes use so MCP tool calls inherit
|
|
3082
|
-
// the server's bound hippoRoot and the auth-resolved tenantId / actor.
|
|
3083
|
-
// Without this, executeTool would walk from cwd via findHippoRoot() and
|
|
3084
|
-
// pull tenant from HIPPO_TENANT, dropping a valid Bearer for tenant B
|
|
3085
|
-
// back to whatever the env says.
|
|
3086
|
-
const ctx = await buildContextWithAuth(req, opts);
|
|
3087
|
-
const raw = await readBody(req);
|
|
3088
|
-
let mcpReq;
|
|
3089
|
-
try {
|
|
3090
|
-
mcpReq = JSON.parse(raw);
|
|
3091
|
-
}
|
|
3092
|
-
catch {
|
|
3093
|
-
throw new HttpError(400, 'invalid JSON-RPC body');
|
|
3094
|
-
}
|
|
3095
|
-
if (!isJsonObjectRecord(mcpReq) || !isJsonString(mcpReq.method)) {
|
|
3096
|
-
throw new HttpError(400, 'JSON-RPC body must include a method string');
|
|
3097
|
-
}
|
|
3098
|
-
// SAFETY: validated above as a plain JSON object carrying a string method;
|
|
3099
|
-
// the remaining McpRequest wire fields (jsonrpc, id, params) are checked or
|
|
3100
|
-
// safely defaulted inside handleMcpRequest's JSON-RPC dispatch.
|
|
3101
|
-
const rpcReq = mcpReq;
|
|
3102
|
-
let mcpRes;
|
|
3103
|
-
try {
|
|
3104
|
-
mcpRes = await handleMcpRequest(rpcReq, {
|
|
3105
|
-
hippoRoot: ctx.hippoRoot,
|
|
3106
|
-
tenantId: ctx.tenantId,
|
|
3107
|
-
// v1.12.0: McpContext.actor stays string; extract subject at the boundary.
|
|
3108
|
-
actor: ctx.actor.subject,
|
|
3109
|
-
// The caller's real role: MCP tools must not run a member key as admin.
|
|
3110
|
-
role: ctx.actor.role,
|
|
3111
|
-
scopes: ctx.actor.scopes,
|
|
3112
|
-
viaAuthResolver: ctx.actor.viaAuthResolver,
|
|
3113
|
-
clientKey: buildMcpClientKey(req),
|
|
3114
|
-
});
|
|
2392
|
+
// SAFETY: validated above as a plain JSON object carrying a string method;
|
|
2393
|
+
// the remaining McpRequest wire fields (jsonrpc, id, params) are checked or
|
|
2394
|
+
// safely defaulted inside handleMcpRequest's JSON-RPC dispatch.
|
|
2395
|
+
const rpcReq = mcpReq;
|
|
2396
|
+
let mcpRes;
|
|
2397
|
+
try {
|
|
2398
|
+
mcpRes = await handleMcpRequest(rpcReq, {
|
|
2399
|
+
hippoRoot: ctx.hippoRoot,
|
|
2400
|
+
tenantId: ctx.tenantId,
|
|
2401
|
+
// v1.12.0: McpContext.actor stays string; extract subject at the boundary.
|
|
2402
|
+
actor: ctx.actor.subject,
|
|
2403
|
+
// The caller's real role: MCP tools must not run a member key as admin.
|
|
2404
|
+
role: ctx.actor.role,
|
|
2405
|
+
scopes: ctx.actor.scopes,
|
|
2406
|
+
viaAuthResolver: ctx.actor.viaAuthResolver,
|
|
2407
|
+
clientKey: buildMcpClientKey(req),
|
|
2408
|
+
});
|
|
3115
2409
|
}
|
|
3116
2410
|
catch (err) {
|
|
3117
2411
|
mcpRes = {
|
|
@@ -3221,7 +2515,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
3221
2515
|
* webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
|
|
3222
2516
|
* secrets and 404 when those secrets are unset. But the loopback
|
|
3223
2517
|
* no-auth fallback inside buildContextWithAuth still admits unauthenticated
|
|
3224
|
-
* requests from a loopback remote address
|
|
2518
|
+
* requests from a loopback remote address (unless they carry Forwarded,
|
|
2519
|
+
* X-Forwarded-For/-Host/-Proto, X-Real-IP, Cf-Connecting-Ip or True-Client-Ip, which mark a same-host proxy and get
|
|
2520
|
+
* a 401 like any keyless remote request), so binding to a non-loopback host
|
|
3225
2521
|
* is only safe once that fallback is disabled with HIPPO_REQUIRE_AUTH=1,
|
|
3226
2522
|
* which forces every request (loopback or not) through Bearer-token
|
|
3227
2523
|
* validation. Without that env var set, a non-loopback bind would expose the
|
|
@@ -3260,7 +2556,12 @@ export async function serve(opts) {
|
|
|
3260
2556
|
? createRateLimiter({ ratePerSec: v1Rps, burst: v1Rps * 2, idleEvictMs: 60000, maxKeys: 10000 })
|
|
3261
2557
|
: undefined;
|
|
3262
2558
|
const server = createServer((req, res) => {
|
|
2559
|
+
const requestId = resolveRequestId(req.headers['x-request-id']);
|
|
2560
|
+
requestIds.set(req, requestId);
|
|
2561
|
+
res.setHeader('X-Request-Id', requestId);
|
|
3263
2562
|
handleRequest(req, res, opts, startedAt, limiter).catch((err) => {
|
|
2563
|
+
const mapped = mapApiError(err);
|
|
2564
|
+
logRequestFailure(req, err, requestId, mapped.status);
|
|
3264
2565
|
if (res.headersSent) {
|
|
3265
2566
|
try {
|
|
3266
2567
|
res.end();
|
|
@@ -3268,33 +2569,20 @@ export async function serve(opts) {
|
|
|
3268
2569
|
catch { /* socket already gone */ }
|
|
3269
2570
|
return;
|
|
3270
2571
|
}
|
|
3271
|
-
if (
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
// only partially consumed. Destroy the socket rather than let the
|
|
3275
|
-
// client's remaining (unbounded) bytes drain into an exchange we have
|
|
3276
|
-
// already answered.
|
|
3277
|
-
req.destroy();
|
|
3278
|
-
return;
|
|
3279
|
-
}
|
|
3280
|
-
if (err instanceof HttpError) {
|
|
3281
|
-
sendError(res, err.status, err.message);
|
|
2572
|
+
if (mapped.status === 500) {
|
|
2573
|
+
// The id lets an operator find the logged cause without the client seeing internal text.
|
|
2574
|
+
sendJson(res, 500, { error: mapped.message, requestId });
|
|
3282
2575
|
return;
|
|
3283
2576
|
}
|
|
3284
|
-
//
|
|
3285
|
-
// at 400 with {error: <message>, code: <code>}. The `error` field
|
|
3286
|
-
// matches `sendError`'s shape (human message, used by HttpError /
|
|
3287
|
-
// BodyTooLargeError / mapApiError). The `code` field is the typed
|
|
3288
|
-
// discriminator — clients can branch on `body.code` without parsing
|
|
3289
|
-
// prose. Earlier draft used {error: code, message: text} but that
|
|
3290
|
-
// diverged from the rest of v1/* and forced clients to special-case
|
|
3291
|
-
// the error path.
|
|
2577
|
+
// RecallContractError keeps the shared {error} shape and adds `code` so clients branch without parsing prose.
|
|
3292
2578
|
if (err instanceof RecallContractError) {
|
|
3293
2579
|
sendJson(res, 400, { error: err.message, code: err.code });
|
|
3294
2580
|
return;
|
|
3295
2581
|
}
|
|
3296
|
-
const mapped = mapApiError(err);
|
|
3297
2582
|
sendError(res, mapped.status, mapped.message);
|
|
2583
|
+
// M3: readBody hit the 1 MB cap mid-stream, so drop the socket rather than drain unbounded bytes.
|
|
2584
|
+
if (err instanceof BodyTooLargeError)
|
|
2585
|
+
req.destroy();
|
|
3298
2586
|
});
|
|
3299
2587
|
});
|
|
3300
2588
|
// T3b capture (v1.26.2): tests/server-concurrency.test.ts's ECONNRESET flake
|