server.mjs
4,571 lines 181.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Knowtation Hub — REST API + OAuth + JWT. Phase 11.
3 * Run from repo root: node hub/server.mjs
4 * Env: KNOWTATION_VAULT_PATH, HUB_JWT_SECRET, HUB_PORT; optional HUB_CORS_ORIGIN, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, HUB_BASE_URL, HUB_PROPOSAL_EVALUATION_REQUIRED, KNOWTATION_HUB_PROPOSAL_REVIEW_HINTS, KNOWTATION_HUB_PROPOSAL_ENRICH (see lib/hub-proposal-policy.mjs; explicit 0/1 or false/true overrides data/hub_proposal_policy.json), HUB_EVALUATOR_MAY_APPROVE=1 (fallback when no per-user row in data/hub_evaluator_may_approve.json).
5 */
6
7 import path from 'path';
8 import os from 'os';
9 import { execFileSync } from 'child_process';
10 import { fileURLToPath } from 'url';
11 import crypto from 'crypto';
12 import fs from 'fs';
13 import multer from 'multer';
14 import AdmZip from 'adm-zip';
15 import dotenv from 'dotenv';
16 import express from 'express';
17 import cors from 'cors';
18 import cookieParser from 'cookie-parser';
19 import rateLimit from 'express-rate-limit';
20 import jwt from 'jsonwebtoken';
21 import passport from 'passport';
22 import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
23 import { Strategy as GitHubStrategy } from 'passport-github2';
24
25 import { loadConfig, CHAT_PROVIDERS, normalizeChatProviderInput } from '../lib/config.mjs';
26 import { runListNotes, runFacets } from '../lib/list-notes.mjs';
27 import {
28 readNote,
29 normalizeSlug,
30 normalizeMetadataFacets,
31 resolveVaultRelativePath,
32 noteFileExistsInVault,
33 listVaultFolderOptions,
34 } from '../lib/vault.mjs';
35 import { buildNoteOutline } from '../lib/note-outline.mjs';
36 import { buildDocumentTree } from '../lib/document-tree.mjs';
37 import { readSectionSource } from '../lib/section-source-note.mjs';
38 import { writeNote, deleteNote, deleteNotesByPrefix } from '../lib/write.mjs';
39 import { deleteNotesByProjectSlug, renameProjectSlugInVault } from '../lib/hub-bulk-metadata.mjs';
40 import { mergeProvenanceFrontmatter } from '../lib/hub-provenance.mjs';
41 import { runSearch } from '../lib/search.mjs';
42 import { runKeywordSearch } from '../lib/keyword-search.mjs';
43 import { exportNoteToContent } from '../lib/export.mjs';
44 import { runImport } from '../lib/import.mjs';
45 import { IMPORT_SOURCE_TYPES } from '../lib/import-source-types.mjs';
46 import { noteStateIdFromParts, absentNoteStateId } from '../lib/note-state-id.mjs';
47 import { buildApprovalLogWrite } from '../lib/approval-log.mjs';
48 import { completeChat } from '../lib/llm-complete.mjs';
49 import {
50 listProposals,
51 getProposal,
52 createProposal,
53 updateProposalStatus,
54 updateProposalEnrichment,
55 discardProposalsUnderPathPrefix,
56 discardProposalsAtPaths,
57 submitProposalEvaluation,
58 mergeEvaluationChecklist,
59 evaluationAllowsApprove,
60 patchProposalTaskMetaCascade,
61 } from './proposals-store.mjs';
62 import { loadProposalRubric } from '../lib/hub-proposal-rubric.mjs';
63 import {
64 getProposalEvaluationRequired,
65 getProposalReviewHintsEnabled,
66 getProposalEnrichEnabled,
67 proposalPolicyEnvLocked,
68 readProposalPolicyFile,
69 writeProposalPolicyMerge,
70 } from '../lib/hub-proposal-policy.mjs';
71 import { loadReviewTriggers, applyReviewTriggers } from '../lib/hub-proposal-review-triggers.mjs';
72 import { runProposalReviewHintsJob } from '../lib/hub-proposal-review-hints-job.mjs';
73 import { appendAudit } from './audit-log.mjs';
74 import { maybeAutoSync, runVaultSync } from '../lib/vault-git-sync.mjs';
75 import { readHubSetup, writeHubSetup } from '../lib/hub-setup.mjs';
76 import { readConnection as readGitHubConnection, writeConnection as writeGitHubConnection } from '../lib/github-connection.mjs';
77 import { commitImageToRepo, parseGitHubRepoUrl, validateImageExtension, validateMagicBytes } from '../lib/github-commit-image.mjs';
78 import {
79 loadRoleMap,
80 getRole,
81 readRolesObject,
82 writeRolesFile,
83 ensureActorAdminOnFirstRolesPopulation,
84 } from './roles.mjs';
85 import { createInvite, consumeInvite, revokeInvite, listInvites } from './invites.mjs';
86 import { getAllowedVaultIds, readVaultAccess, writeVaultAccess } from './hub_vault_access.mjs';
87 import { getScopeForUserVault, readScope, writeScope } from './hub_scope.mjs';
88 import {
89 issueRefreshToken,
90 rotateRefreshToken,
91 revokeRefreshToken,
92 pruneRefreshTokens,
93 } from './refresh-tokens.mjs';
94 import {
95 refreshCookieOptions,
96 issueRefreshCookie,
97 createRefreshHandler,
98 createLogoutHandler,
99 } from './auth-session.mjs';
100 import { readHubVaults, writeHubVaults } from '../lib/hub-vaults.mjs';
101 import { deleteSelfHostedVault } from './hub-delete-vault.mjs';
102 import { applyScopeFilterToNotes as applyScopeFilter } from './lib/scope-filter.mjs';
103 import { materializeListFrontmatter } from './gateway/note-facets.mjs';
104 import {
105 readEvaluatorMayApprove,
106 writeEvaluatorMayApprove,
107 actorMayApproveProposals,
108 } from './lib/hub-evaluator-may-approve.mjs';
109 import {
110 personalSelfApplyRefusalReason,
111 isHttpVisibleSelfApplySeamCode,
112 SELF_APPLY_SEAM_ERROR_MESSAGES,
113 } from '../lib/hub-proposal-personal-self-apply.mjs';
114 import {
115 isSessionBoundActor,
116 isAgentAccessPayload,
117 resolveActorTokenClass,
118 assertAgentVaultAllowed,
119 } from './gateway/access-token-authz.mjs';
120 import { agentScopesPermitMethod } from './lib/agent-credential-core.mjs';
121 import { effectiveRequestPath } from './gateway/request-path.mjs';
122 import { augmentProposalCreateRequestBody } from '../lib/hub-proposal-create-augment.mjs';
123 import {
124 processAutomationIngest,
125 sendIngestError,
126 isIngestContractBody,
127 normalizeRuleForSave,
128 mintRuleId,
129 MAX_USER_RULES,
130 listPackTemplates,
131 } from '../lib/automation-ingest-policy.mjs';
132 import {
133 loadIngestRulesForSub,
134 saveIngestRulesForSub,
135 getIngestIdempotency,
136 putIngestIdempotency,
137 } from './gateway/automation-ingest-store.mjs';
138 import {
139 parseMuseConfigFromEnv,
140 resolveExternalRefForApprove,
141 fetchMuseProxiedGet,
142 } from '../lib/muse-thin-bridge.mjs';
143 import {
144 buildCalendarTimeline,
145 listSourceCalendarsForClient,
146 } from '../lib/calendar/timeline.mjs';
147 import { importIcsIntoVault } from '../lib/calendar/event-store.mjs';
148 import { patchSourceCalendar, parseSourceCalendarPatchBody } from '../lib/calendar/source-calendar-patch.mjs';
149 import { retrieveAgentCalendarContext } from '../lib/calendar/agent-retrieval.mjs';
150 import {
151 handleBeginGoogleConnector,
152 handleListGoogleConnectors,
153 } from '../lib/calendar/google-oauth-connector.mjs';
154 import {
155 createProductionGoogleDriveClient,
156 createProductionNotionClient,
157 handleBeginDocsProvider,
158 handleDocsConnectorAction,
159 handleDocsConnectorCallbackUnified,
160 handleListAllDocsConnectors,
161 } from '../lib/docs/docs-api.mjs';
162 import { handleFlowListRequest, handleFlowGetRequest, handleFlowProjectRequest } from '../lib/flow/flow-handlers.mjs';
163 import { handleTaskListRequest, handleTaskGetRequest } from '../lib/task/task-handlers.mjs';
164 import {
165 handleAttachmentListRequest,
166 handleAttachmentGetRequest,
167 } from '../lib/attachments/attachment-handlers.mjs';
168 import {
169 handleMediaLinkProposeRequest,
170 handleMediaAttachProposeRequest,
171 handleMediaImportConsentGrantRequest,
172 handleMediaImportConsentListRequest,
173 handleMediaImportConsentRevokeRequest,
174 precheckApprovedMediaProposal,
175 reconcileApprovedMediaProposal,
176 MEDIA_PROPOSAL_SOURCE,
177 } from '../lib/attachments/attachment-write.mjs';
178 import {
179 handleTaskLoopListRequest,
180 handleTaskLoopGetRequest,
181 } from '../lib/task/task-loop-handlers.mjs';
182 import { handleLoopPassAuditAppendRequest } from '../lib/task/loop-pass-audit.mjs';
183 import {
184 handleTaskProposeRequest,
185 handleTaskLoopProposeRequest,
186 handleTaskInstanceMaterializeRequest,
187 precheckApprovedTaskProposal,
188 reconcileApprovedTaskProposal,
189 TASK_PROPOSAL_SOURCE,
190 } from '../lib/task/task-write.mjs';
191 import {
192 handlePathListRequest,
193 handlePathGetRequest,
194 } from '../lib/path/path-handlers.mjs';
195 import {
196 handlePathProposeRequest,
197 precheckApprovedPathProposal,
198 reconcileApprovedPathProposal,
199 PATH_PROPOSAL_SOURCE,
200 } from '../lib/path/path-write.mjs';
201 import {
202 handleFlowExternalGrantMintRequest,
203 handleFlowExternalGrantRevokeRequest,
204 handleFlowExternalGrantListRequest,
205 handleFlowExternalToolInvokeRequest,
206 } from '../lib/flow/external-agent.mjs';
207 import {
208 handleFlowProposeRequest,
209 precheckApprovedFlowProposal,
210 applyFlowProposalToIndex,
211 FLOW_PROPOSAL_SOURCE,
212 } from '../lib/flow/flow-authoring.mjs';
213 import {
214 handleFlowCaptureObserveRequest,
215 handleFlowCaptureListRequest,
216 handleFlowCaptureProposeRequest,
217 handleFlowCaptureDismissRequest,
218 precheckApprovedCaptureProposal,
219 applyCaptureProposal,
220 FLOW_CAPTURE_PROPOSAL_SOURCE,
221 } from '../lib/flow/flow-capture.mjs';
222 import {
223 handleFlowRunStartRequest,
224 handleFlowRunGetRequest,
225 handleFlowRunListRequest,
226 handleFlowRunAdvanceRequest,
227 handleFlowRunEvidenceRequest,
228 handleFlowRunExecuteAutomatableRequest,
229 handleFlowRunSubmitReviewRequest,
230 handleFlowExecutionConsentMintRequest,
231 } from '../lib/flow/flow-execution.mjs';
232 import {
233 handleAgentIdentityRegisterProposeRequest,
234 handleAgentIdentityListRequest,
235 handleDelegationConsentProposeRequest,
236 handleDelegationConsentRevokeRequest,
237 handleDelegationGrantMintRequest,
238 handleDelegationGrantRevokeRequest,
239 handleDelegationGrantListRequest,
240 handleDelegationAuditAppendRequest,
241 precheckApprovedDelegationProposal,
242 applyDelegationProposalToIndex,
243 DELEGATION_PROPOSAL_SOURCE,
244 hashPrincipalRef,
245 } from '../lib/agent/delegation.mjs';
246 import { resolveOfflineLockedAuthPosture } from './lib/local-auth-gate.mjs';
247 import { oauthDisabledGuard, logBootstrapInstructionOnce } from './lib/local-auth-oauth-guard.mjs';
248 import { registerLocalAuthRoutes, credentialStoreHasAdmin } from './lib/local-auth-routes.mjs';
249 import { pruneExpiredBootstrapRecord } from './lib/local-auth-bootstrap.mjs';
250 import { effectiveRoleForHub } from './lib/local-auth-role.mjs';
251
252 const __dirname = path.dirname(fileURLToPath(import.meta.url));
253 const projectRoot = path.resolve(__dirname, '..');
254 // Load .env from project root
255 const envPath = path.join(projectRoot, '.env');
256 if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
257
258 const PORT = parseInt(process.env.HUB_PORT || '3333', 10);
259 const isProduction = process.env.NODE_ENV === 'production';
260 const JWT_SECRET = process.env.HUB_JWT_SECRET || (isProduction ? null : 'change-me-in-production');
261 if (isProduction && !process.env.HUB_JWT_SECRET) {
262 console.error('Hub: HUB_JWT_SECRET is required in production. Set in .env.');
263 process.exit(1);
264 }
265 const BASE_URL = process.env.HUB_BASE_URL || `http://localhost:${PORT}`;
266 const JWT_EXPIRY = process.env.HUB_JWT_EXPIRY || '1h';
267
268 let config;
269 try {
270 config = loadConfig(projectRoot);
271 } catch (e) {
272 console.error('Hub: config load failed. Set KNOWTATION_VAULT_PATH.', e.message);
273 process.exit(1);
274 }
275
276 /** Muse bridge: use merged `config.muse.url` (local.yaml + env) when parsing bridge options. */
277 function museEnvForBridge() {
278 const u = config?.muse?.url;
279 if (u != null && String(u).trim() !== '') {
280 return { ...process.env, MUSE_URL: String(u).trim().replace(/\/+$/, '') };
281 }
282 return process.env;
283 }
284
285 function museBridgePublicSettings() {
286 const envOverride = process.env.MUSE_URL != null && String(process.env.MUSE_URL).trim() !== '';
287 const mc = parseMuseConfigFromEnv(museEnvForBridge());
288 let origin = null;
289 if (mc) {
290 try {
291 origin = new URL(mc.baseUrl).origin;
292 } catch (_) {
293 /* ignore */
294 }
295 }
296 const yamlOnly = !envOverride && Boolean(config.muse?.url);
297 return {
298 enabled: Boolean(mc),
299 origin,
300 source: envOverride ? 'env' : yamlOnly ? 'yaml' : 'none',
301 env_override_active: envOverride,
302 url_editable: !envOverride,
303 yaml_url_for_edit: envOverride ? '' : String(config.muse?.url || ''),
304 };
305 }
306
307 /** Phase 13: role store (data/hub_roles.json). Reloaded when config is reloaded (e.g. after POST setup). */
308 let roleMap = loadRoleMap(config.data_dir);
309
310 /** Phase 8 P1b-b: offline-locked auth posture (env gate read once at boot, §2.2). */
311 const offlineLockedPosture = resolveOfflineLockedAuthPosture();
312 const offlineLockedActive = offlineLockedPosture.active;
313 pruneExpiredBootstrapRecord(config.data_dir);
314 logBootstrapInstructionOnce(offlineLockedActive, credentialStoreHasAdmin(config.data_dir));
315
316 passport.serializeUser((user, done) => done(null, user));
317 passport.deserializeUser((obj, done) => done(null, obj));
318
319 if (!offlineLockedActive && process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
320 passport.use(
321 new GoogleStrategy(
322 {
323 clientID: process.env.GOOGLE_CLIENT_ID,
324 clientSecret: process.env.GOOGLE_CLIENT_SECRET,
325 callbackURL: `${BASE_URL}/api/v1/auth/callback/google`,
326 },
327 (_accessToken, _refreshToken, profile, done) => {
328 return done(null, { provider: 'google', id: profile.id, displayName: profile.displayName });
329 }
330 )
331 );
332 }
333 if (!offlineLockedActive && process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
334 passport.use(
335 new GitHubStrategy(
336 {
337 clientID: process.env.GITHUB_CLIENT_ID,
338 clientSecret: process.env.GITHUB_CLIENT_SECRET,
339 callbackURL: `${BASE_URL}/api/v1/auth/callback/github`,
340 },
341 (_accessToken, _refreshToken, profile, done) => {
342 return done(null, { provider: 'github', id: profile.id, displayName: profile.username });
343 }
344 )
345 );
346 }
347
348 /**
349 * Issue JWT for authenticated user. Payload includes `role` from role store (Phase 13).
350 * When no roles file exists (or it is empty), everyone gets role 'admin' — no manual setup
351 * or hardcoded IDs; every new install works and the Team tab is visible. Once the file has
352 * at least one entry, only listed users get that role; others get getRole() default 'member'.
353 */
354 function issueToken(user) {
355 const sub = `${user.provider}:${user.id}`;
356 const role = effectiveRoleForHub(roleMap, sub, offlineLockedActive);
357 return jwt.sign(
358 { sub, provider: user.provider, id: user.id, name: user.displayName, role, type: 'session' },
359 JWT_SECRET,
360 { expiresIn: JWT_EXPIRY }
361 );
362 }
363
364 /**
365 * Re-mint a short-lived access token from a `sub` alone (used by POST /auth/refresh, which
366 * only knows the user id). Role is re-derived from the current role store so a refreshed
367 * token always reflects the latest Team role, exactly like login. Display name is omitted
368 * (the UI reads it from /settings); identity for authorization is the `sub`.
369 * @param {string} sub
370 * @returns {string} signed JWT
371 */
372 function issueAccessTokenForSub(sub) {
373 const role = effectiveRoleForHub(roleMap, sub, offlineLockedActive);
374 const idx = sub.indexOf(':');
375 const provider = idx > 0 ? sub.slice(0, idx) : '';
376 const id = idx > 0 ? sub.slice(idx + 1) : sub;
377 return jwt.sign(
378 { sub, provider, id, role, type: 'session' },
379 JWT_SECRET,
380 { expiresIn: JWT_EXPIRY }
381 );
382 }
383
384 // Persistent sessions (refresh-token rotation). The refresh token is durable, hashed at
385 // rest, and delivered as an HttpOnly cookie; the security logic lives in
386 // hub/lib/refresh-token-core.mjs via the file store below.
387 const refreshStore = {
388 issue: (sub, opts) => issueRefreshToken(config.data_dir, sub, opts),
389 rotate: (token, opts) => rotateRefreshToken(config.data_dir, token, opts),
390 revoke: (token) => revokeRefreshToken(config.data_dir, token),
391 };
392
393 /**
394 * Cookie policy for the refresh token. Self-hosted Hub serves UI and API from one origin,
395 * so SameSite=Lax is correct; Secure follows whether the deployment is HTTPS. Scoped to the
396 * auth path so the cookie is only sent to /api/v1/auth endpoints.
397 */
398 function refreshCookiePolicy() {
399 return refreshCookieOptions({
400 secure: BASE_URL.startsWith('https://'),
401 sameSite: 'lax',
402 maxAgeMs: 90 * 24 * 60 * 60 * 1000,
403 });
404 }
405
406 function parseQueryBounds(req, res, next) {
407 const limitRaw = req.query?.limit != null ? parseInt(req.query.limit, 10) : undefined;
408 const offsetRaw = req.query?.offset != null ? parseInt(req.query.offset, 10) : undefined;
409 if (limitRaw != null && (isNaN(limitRaw) || limitRaw < 0 || limitRaw > 100)) {
410 return res.status(400).json({ error: 'limit must be 0–100', code: 'BAD_REQUEST' });
411 }
412 if (offsetRaw != null && (isNaN(offsetRaw) || offsetRaw < 0)) {
413 return res.status(400).json({ error: 'offset must be non-negative', code: 'BAD_REQUEST' });
414 }
415 next();
416 }
417
418 function jwtAuth(req, res, next) {
419 const auth = req.headers.authorization;
420 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
421 if (!token) {
422 return res.status(401).json({ error: 'Missing or invalid Authorization header', code: 'UNAUTHORIZED' });
423 }
424 try {
425 req.user = jwt.verify(token, JWT_SECRET);
426 next();
427 } catch (_) {
428 return res.status(401).json({ error: 'Invalid or expired token', code: 'UNAUTHORIZED' });
429 }
430 }
431
432 const IMAGE_PROXY_TOKEN_TTL_SECONDS = 300;
433
434 function signImageProxyToken(secret, uid) {
435 const exp = Math.floor(Date.now() / 1000) + IMAGE_PROXY_TOKEN_TTL_SECONDS;
436 const payload = `img\0${uid}\0${exp}`;
437 const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
438 return `${exp}.${Buffer.from(uid).toString('base64url')}.${sig}`;
439 }
440
441 function verifyImageProxyToken(secret, token) {
442 if (typeof token !== 'string') return null;
443 const parts = token.split('.');
444 if (parts.length !== 3) return null;
445 const [expStr, uidB64, sig] = parts;
446 const exp = parseInt(expStr, 10);
447 if (!exp || Math.floor(Date.now() / 1000) > exp) return null;
448 let uid;
449 try { uid = Buffer.from(uidB64, 'base64url').toString(); } catch (_) { return null; }
450 if (!uid) return null;
451 const payload = `img\0${uid}\0${exp}`;
452 const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
453 const sigBuf = Buffer.from(sig);
454 const expectedBuf = Buffer.from(expected);
455 if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
456 return uid;
457 }
458
459 function jwtAuthFlex(req, res, next) {
460 const auth = req.headers.authorization;
461 const headerToken = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
462 const queryToken = typeof req.query.token === 'string' ? req.query.token : null;
463 if (headerToken) {
464 try {
465 req.user = jwt.verify(headerToken, JWT_SECRET);
466 return next();
467 } catch (_) {
468 return res.status(401).json({ error: 'Invalid or expired token', code: 'UNAUTHORIZED' });
469 }
470 }
471 if (queryToken) {
472 const uid = verifyImageProxyToken(JWT_SECRET, queryToken);
473 if (uid) {
474 req.user = { sub: uid };
475 return next();
476 }
477 // Backward compat: old hub.js sends full JWT as ?token= (pre-signed-token change).
478 try {
479 const decoded = jwt.verify(queryToken, JWT_SECRET);
480 req.user = decoded;
481 return next();
482 } catch (_) { /* not a valid JWT either */ }
483 }
484 return res.status(401).json({ error: 'Missing or invalid Authorization header', code: 'UNAUTHORIZED' });
485 }
486
487 /**
488 * Phase 13: effective role for permission checks and Settings UI.
489 * Always derived from hub_roles.json (roleMap), not from the JWT payload, so Team role changes
490 * apply without forcing users to log out and back in. JWT `role` is only set at login time.
491 */
492 function effectiveRole(req) {
493 const sub = req.user?.sub ?? '';
494 if (roleMap.size === 0) {
495 return offlineLockedActive ? 'member' : 'admin';
496 }
497 const gr = getRole(roleMap, sub);
498 return gr === 'member' || !gr ? 'editor' : gr;
499 }
500
501 /** Phase 13: require one of the given roles (viewer, editor, admin, evaluator). Must run after jwtAuth. */
502 function requireRole(...allowedRoles) {
503 const set = new Set(allowedRoles);
504 return (req, res, next) => {
505 const role = effectiveRole(req);
506 if (set.has(role)) return next();
507 return res.status(403).json({ error: 'This action requires a different role.', code: 'FORBIDDEN' });
508 };
509 }
510
511 function hubEnvEvaluatorMayApprove() {
512 return process.env.HUB_EVALUATOR_MAY_APPROVE === '1';
513 }
514
515 /** Approve: admin always; evaluator per data/hub_evaluator_may_approve.json + env fallback;
516 * HOSTED-WRITE-EVAL: editor/member personal self-apply when Scooling fingerprint matches.
517 * SEC-SEAM-1 / S2.1 + S6.2: author/session inputs + named seam refusal codes. */
518 function requireApproveRole(req, res, next) {
519 const role = effectiveRole(req);
520 const sub = req.user?.sub ?? '';
521 const mayMap = readEvaluatorMayApprove(config.data_dir);
522 if (actorMayApproveProposals(sub, role, mayMap, hubEnvEvaluatorMayApprove())) return next();
523
524 const proposal = getProposal(config.data_dir, req.params.id);
525 const hasVaultWrite = role === 'editor' || role === 'admin' || role === 'member';
526 const authorActorId =
527 proposal && typeof proposal.proposed_by === 'string' ? proposal.proposed_by : '';
528 const reason = personalSelfApplyRefusalReason({
529 proposal,
530 hasVaultWrite,
531 partitionOwned: Boolean(proposal),
532 role,
533 authorActorId,
534 approverActorId: sub,
535 sessionBound: isSessionBoundActor(req.user),
536 });
537 if (reason === null) {
538 return next();
539 }
540
541 if (isHttpVisibleSelfApplySeamCode(reason)) {
542 return res.status(403).json({
543 error: SELF_APPLY_SEAM_ERROR_MESSAGES[reason] || reason,
544 code: reason,
545 });
546 }
547
548 return res.status(403).json({
549 error:
550 'Approve requires admin, or an evaluator with approve permission (Team tab / data/hub_evaluator_may_approve.json, or HUB_EVALUATOR_MAY_APPROVE=1 when no per-user entry).',
551 code: 'FORBIDDEN',
552 });
553 }
554
555 /** Phase 15: resolve vault_id to path, check access, set req.vaultPath and req.scope. Must run after jwtAuth. */
556 function requireVaultAccess(req, res, next) {
557 const allowed = getAllowedVaultIds(config.data_dir, req.user?.sub ?? '');
558 if (!allowed.includes(req.vault_id)) {
559 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
560 }
561 const vaultPath = config.resolveVaultPath(req.vault_id);
562 if (!vaultPath) {
563 return res.status(404).json({ error: 'Vault not found.', code: 'NOT_FOUND' });
564 }
565 req.vaultPath = vaultPath;
566 req.scope = getScopeForUserVault(config.data_dir, req.user?.sub ?? '', req.vault_id);
567 next();
568 }
569
570 function assertSelfHostedAgentScope(req, res) {
571 if (!isAgentAccessPayload(req.user)) return true;
572 const pathOnly = effectiveRequestPath(req);
573 if (!agentScopesPermitMethod(req.user.scopes, req.method, pathOnly)) {
574 res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
575 return false;
576 }
577 const vaultId = req.vault_id || String(req.headers['x-vault-id'] || 'default').trim() || 'default';
578 if (!assertAgentVaultAllowed(req.user, vaultId)) {
579 res.status(403).json({ error: 'vault forbidden for agent credential', code: 'AGENT_VAULT_FORBIDDEN' });
580 return false;
581 }
582 return true;
583 }
584
585 function ingestSessionWriteRole(role) {
586 return role === 'editor' || role === 'admin' || role === 'member';
587 }
588
589 function requireSessionIngestCrud(req, res, next) {
590 const actorClass = resolveActorTokenClass(req.user);
591 if (actorClass !== 'session' && actorClass !== 'legacy_session') {
592 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
593 }
594 if (isAgentAccessPayload(req.user) || !assertSelfHostedAgentScope(req, res)) {
595 if (!res.headersSent) res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
596 return;
597 }
598 const role = effectiveRole(req);
599 if (!ingestSessionWriteRole(role)) {
600 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
601 }
602 return next();
603 }
604
605 function makeSelfHostedIngestIo(req) {
606 const dataDir = config.data_dir;
607 const vaultPath = req.vaultPath;
608 return {
609 async getIdempotency(storeKey) {
610 return getIngestIdempotency(storeKey, dataDir);
611 },
612 async putIdempotency(storeKey, entry) {
613 return putIngestIdempotency(storeKey, entry, dataDir);
614 },
615 async appendAudit(action, detail, proposalId) {
616 appendAudit(dataDir, {
617 userId: req.user?.sub ?? 'unknown',
618 action,
619 proposalId: proposalId || '',
620 detail,
621 });
622 },
623 async runBilling() {
624 return true;
625 },
626 async readExistingNote(notePath) {
627 try {
628 return readNote(vaultPath, notePath);
629 } catch {
630 return null;
631 }
632 },
633 async writeNote(notePath, payload) {
634 writeNote(vaultPath, notePath, { body: payload.body, frontmatter: payload.frontmatter });
635 },
636 async createProposal(payload) {
637 const policyPending = getProposalEvaluationRequired(dataDir);
638 const augmented = augmentProposalCreateRequestBody(
639 {
640 path: payload.path,
641 body: payload.body,
642 frontmatter: payload.frontmatter,
643 intent: payload.intent,
644 labels: payload.labels,
645 source: payload.source,
646 proposed_by: payload.proposed_by,
647 },
648 dataDir,
649 {
650 evaluationRequired: policyPending,
651 evaluatedBy: req.user?.sub,
652 sessionBound: isSessionBoundActor(req.user),
653 authorActorId: req.user?.sub,
654 }
655 );
656 return createProposal(dataDir, {
657 ...augmented,
658 vault_id: req.vault_id,
659 proposed_by: req.user?.sub ?? undefined,
660 evaluationRequired: policyPending,
661 evaluationForcedPending: Boolean(augmented.auto_flag_reasons?.length),
662 review_queue: augmented.review_queue,
663 review_severity: augmented.review_severity,
664 auto_flag_reasons: augmented.auto_flag_reasons,
665 });
666 },
667 async markProposalApproved(proposalId) {
668 try {
669 updateProposalStatus(dataDir, proposalId, 'approved');
670 return { ok: true };
671 } catch {
672 return { ok: false };
673 }
674 },
675 };
676 }
677
678 async function handleSelfHostedAutomationIngest(req, res, { requireContract = false } = {}) {
679 if (!assertSelfHostedAgentScope(req, res)) return;
680 if (!req.vaultPath) {
681 const allowed = getAllowedVaultIds(config.data_dir, req.user?.sub ?? '');
682 const vaultId = req.vault_id || String(req.headers['x-vault-id'] || 'default').trim() || 'default';
683 if (!allowed.includes(vaultId)) {
684 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
685 }
686 const vaultPath = config.resolveVaultPath(vaultId);
687 if (!vaultPath) return res.status(404).json({ error: 'Vault not found.', code: 'NOT_FOUND' });
688 req.vault_id = vaultId;
689 req.vaultPath = vaultPath;
690 }
691 const actorClass = resolveActorTokenClass(req.user);
692 if (actorClass !== 'agent_access' && actorClass !== 'session' && actorClass !== 'legacy_session') {
693 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
694 }
695 if (actorClass !== 'agent_access' && !ingestSessionWriteRole(effectiveRole(req))) {
696 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
697 }
698 try {
699 const loaded = await loadIngestRulesForSub(req.user?.sub ?? '', config.data_dir);
700 const out = await processAutomationIngest({
701 rawBody: req.body,
702 idempotencyHeader: req.headers['x-ingest-idempotency-key'],
703 actor: {
704 sub: req.user?.sub ?? '',
705 vaultId: req.vault_id || 'default',
706 credentialId: req.user?.cid != null ? String(req.user.cid) : null,
707 credentialName: req.user?.agent != null ? String(req.user.agent) : null,
708 evaluationRequired: getProposalEvaluationRequired(config.data_dir),
709 sessionBound: isSessionBoundActor(req.user),
710 },
711 rules: loaded.rules,
712 triggers: loadReviewTriggers(config.data_dir),
713 io: makeSelfHostedIngestIo(req),
714 requireContract,
715 });
716 if (out && out.billed === false) return;
717 return res.status(out.status).json(out.body);
718 } catch (e) {
719 if (e && e.code === 'AGENT_CREDENTIAL_STORE_UNAVAILABLE') {
720 return res.status(503).json({ error: e.message || 'store unavailable', code: e.code });
721 }
722 return sendIngestError(res, e);
723 }
724 }
725
726 const app = express();
727 // Trust the first downstream proxy so express-rate-limit reads the real client IP from
728 // X-Forwarded-For instead of the CDN/load-balancer address.
729 app.set('trust proxy', 1);
730 const corsOrigin = process.env.HUB_CORS_ORIGIN;
731 const jsonBodyLimit = process.env.HUB_JSON_BODY_LIMIT || '5mb';
732 app.use(cors({ origin: corsOrigin ? corsOrigin.split(',') : true, credentials: true }));
733 app.use(express.json({ limit: jsonBodyLimit }));
734 app.use(cookieParser());
735 app.use(passport.initialize());
736
737 // Rate limits
738 const loginLimiter = rateLimit({ windowMs: 60 * 1000, max: 5, message: { error: 'Too many login attempts', code: 'RATE_LIMIT' } });
739 const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, message: { error: 'Too many requests', code: 'RATE_LIMIT' } });
740 const importUrlLimiter = rateLimit({
741 windowMs: 15 * 60 * 1000,
742 max: 40,
743 message: { error: 'Too many URL imports. Try again later.', code: 'RATE_LIMIT' },
744 });
745
746 function captureAuth(req, res, next) {
747 const secret = process.env.CAPTURE_WEBHOOK_SECRET;
748 if (!secret) {
749 return res.status(503).json({ error: 'Capture webhook not configured (CAPTURE_WEBHOOK_SECRET missing)', code: 'NOT_CONFIGURED' });
750 }
751 const provided = req.headers['x-webhook-secret'];
752 if (typeof provided !== 'string' || provided.length === 0) {
753 return res.status(401).json({ error: 'Invalid or missing X-Webhook-Secret', code: 'UNAUTHORIZED' });
754 }
755 const a = Buffer.from(secret);
756 const b = Buffer.from(provided);
757 if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
758 return res.status(401).json({ error: 'Invalid or missing X-Webhook-Secret', code: 'UNAUTHORIZED' });
759 }
760 return next();
761 }
762
763 function sanitizeForFilename(id) {
764 if (typeof id !== 'string') return '';
765 return id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'unknown';
766 }
767
768 // Health (no auth)
769 app.get('/health', (_req, res) => res.json({ ok: true }));
770 app.get('/api/v1/health', (_req, res) => res.json({ ok: true }));
771
772 // Which OAuth providers are configured (no auth; UI uses this to show buttons vs setup help)
773 app.get('/api/v1/auth/providers', (req, res) => {
774 if (offlineLockedActive) {
775 return res.json({ google: false, github: false, local: true });
776 }
777 res.json({
778 google: Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
779 github: Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET),
780 });
781 });
782
783 const oauthBlocked = oauthDisabledGuard(offlineLockedActive, config.data_dir);
784
785 // Auth: login redirect (rate limited). Optional ?invite=TOKEN passed through state for Phase 13 invite.
786 app.get('/api/v1/auth/login', loginLimiter, oauthBlocked, (req, res, next) => {
787 const provider = (req.query.provider || 'google').toLowerCase();
788 const inviteToken = typeof req.query.invite === 'string' ? req.query.invite.trim() : null;
789 const stateOpt = inviteToken ? { state: signState({ invite: inviteToken, ts: Date.now() }) } : {};
790 if (provider === 'google' && process.env.GOOGLE_CLIENT_ID) {
791 return passport.authenticate('google', { scope: ['profile'], ...stateOpt })(req, res, next);
792 }
793 if (provider === 'github' && process.env.GITHUB_CLIENT_ID) {
794 return passport.authenticate('github', { scope: ['user:email'], ...stateOpt })(req, res, next);
795 }
796 return res.status(400).json({ error: `Unknown or disabled provider: ${provider}`, code: 'BAD_REQUEST' });
797 });
798
799 // Auth: OAuth callbacks. If state contains invite token, consume it and re-issue JWT with new role.
800 async function handleAuthCallback(req, res) {
801 const redirect = (process.env.HUB_UI_ORIGIN || BASE_URL).replace(/\/$/, '');
802 let token = issueToken(req.user);
803 const sub = `${req.user.provider}:${req.user.id}`;
804 // Start a persistent session: durable, HttpOnly refresh cookie alongside the access token.
805 const issueSession = async () => {
806 try {
807 await issueRefreshCookie(res, {
808 store: refreshStore,
809 sub,
810 cookieOptions: refreshCookiePolicy,
811 meta: { ua: String(req.headers['user-agent'] || '').slice(0, 256) },
812 });
813 } catch (_) {
814 // A refresh-store write failure must not block login; the access token still works.
815 }
816 };
817 const statePayload = req.query.state ? verifyState(req.query.state, 7 * 24 * 60 * 60 * 1000) : null;
818 if (statePayload && statePayload.invite && req.user && req.user.id) {
819 const consumed = consumeInvite(config.data_dir, statePayload.invite, sub);
820 if (consumed) {
821 roleMap = loadRoleMap(config.data_dir);
822 token = issueToken(req.user);
823 await issueSession();
824 return res.redirect(`${redirect}/#token=${encodeURIComponent(token)}&invite_accepted=1`);
825 }
826 }
827 await issueSession();
828 res.redirect(`${redirect}/#token=${encodeURIComponent(token)}`);
829 }
830 app.get(
831 '/api/v1/auth/callback/google',
832 oauthBlocked,
833 passport.authenticate('google', { session: false }),
834 handleAuthCallback
835 );
836 app.get(
837 '/api/v1/auth/callback/github',
838 oauthBlocked,
839 passport.authenticate('github', { session: false }),
840 handleAuthCallback
841 );
842
843 registerLocalAuthRoutes(app, {
844 dataDir: config.data_dir,
845 sessionSecret: JWT_SECRET,
846 jwtExpiry: JWT_EXPIRY,
847 offlineLockedActive,
848 issueRefreshCookie: async (res, req, sub) => {
849 await issueRefreshCookie(res, {
850 store: refreshStore,
851 sub,
852 cookieOptions: refreshCookiePolicy,
853 meta: { ua: String(req.headers['user-agent'] || '').slice(0, 256) },
854 });
855 },
856 });
857
858 // Persistent sessions: exchange the HttpOnly refresh cookie for a fresh access token, and
859 // real server-side logout (revokes the refresh token, not just the client cookie).
860 // Refresh is called on access-token expiry, so its limit is looser than the login limiter.
861 const refreshLimiter = rateLimit({
862 windowMs: 15 * 60 * 1000,
863 max: 60,
864 message: { error: 'Too many refresh attempts', code: 'RATE_LIMIT' },
865 });
866 app.post(
867 '/api/v1/auth/refresh',
868 refreshLimiter,
869 createRefreshHandler({
870 store: refreshStore,
871 issueAccessToken: issueAccessTokenForSub,
872 cookieOptions: refreshCookiePolicy,
873 meta: (req) => ({ ua: String(req.headers['user-agent'] || '').slice(0, 256) }),
874 })
875 );
876 app.post(
877 '/api/v1/auth/logout',
878 createLogoutHandler({ store: refreshStore, cookieOptions: refreshCookiePolicy })
879 );
880 // Opportunistically prune dead refresh records at startup (best effort; never fatal).
881 try { pruneRefreshTokens(config.data_dir); } catch (_) { /* noop */ }
882
883 // Connect GitHub (repo scope): redirect to GitHub, then callback saves token for vault push
884 function signState(statePayload) {
885 const payload = JSON.stringify(statePayload);
886 const sig = crypto.createHmac('sha256', JWT_SECRET).update(payload).digest('hex');
887 return Buffer.from(payload).toString('base64url') + '.' + sig;
888 }
889 function verifyState(stateStr, maxAgeMs = 600000) {
890 const [payloadB64, sig] = String(stateStr).split('.');
891 if (!payloadB64 || !sig) return null;
892 try {
893 const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
894 const expected = crypto.createHmac('sha256', JWT_SECRET).update(JSON.stringify(payload)).digest('hex');
895 const sigBuf = Buffer.from(sig, 'utf8');
896 const expectedBuf = Buffer.from(expected, 'utf8');
897 if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
898 if (Date.now() - (payload.ts || 0) > maxAgeMs) return null;
899 return payload;
900 } catch (_) {
901 return null;
902 }
903 }
904 app.get('/api/v1/auth/github-connect', (req, res) => {
905 if (!process.env.GITHUB_CLIENT_ID) {
906 return res.redirect((process.env.HUB_UI_ORIGIN || BASE_URL).replace(/\/$/, '') + '/?github_connect_error=not_configured');
907 }
908 const state = signState({ r: crypto.randomBytes(16).toString('hex'), ts: Date.now() });
909 const redirectUri = BASE_URL + '/api/v1/auth/callback/github-connect';
910 const url = 'https://github.com/login/oauth/authorize?client_id=' + encodeURIComponent(process.env.GITHUB_CLIENT_ID) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=repo&state=' + encodeURIComponent(state);
911 res.redirect(url);
912 });
913 app.get('/api/v1/auth/callback/github-connect', async (req, res) => {
914 const { code, state } = req.query || {};
915 const baseRedirect = (process.env.HUB_UI_ORIGIN || BASE_URL).replace(/\/$/, '');
916 if (!verifyState(state)) {
917 return res.redirect(baseRedirect + '/?github_connect_error=invalid_state');
918 }
919 if (!code || !process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET) {
920 return res.redirect(baseRedirect + '/?github_connect_error=missing');
921 }
922 try {
923 const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
924 method: 'POST',
925 headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
926 body: JSON.stringify({
927 client_id: process.env.GITHUB_CLIENT_ID,
928 client_secret: process.env.GITHUB_CLIENT_SECRET,
929 code,
930 redirect_uri: BASE_URL + '/api/v1/auth/callback/github-connect',
931 }),
932 });
933 const tokenData = await tokenRes.json();
934 const accessToken = tokenData.access_token;
935 if (!accessToken) {
936 return res.redirect(baseRedirect + '/?github_connect_error=no_token');
937 }
938 writeGitHubConnection(config.data_dir, { access_token: accessToken });
939 return res.redirect(baseRedirect + '/?github_connected=1');
940 } catch (e) {
941 return res.redirect(baseRedirect + '/?github_connect_error=' + encodeURIComponent(e.message || 'exchange_failed'));
942 }
943 });
944
945 // Vault context for multi-vault / canister: optional X-Vault-Id header or vault_id query (Phase 0 / hosted)
946 app.use('/api/v1', (req, res, next) => {
947 const raw = req.get('X-Vault-Id') || req.query.vault_id;
948 req.vault_id = typeof raw === 'string' && raw.trim() ? raw.trim() : 'default';
949 next();
950 });
951
952 // POST /api/v1/capture — webhook for Slack, Discord, etc. (no JWT; optional X-Webhook-Secret)
953 app.post('/api/v1/capture', captureAuth, (req, res) => {
954 const payload = req.body || {};
955 const body = payload.body;
956 if (!body || typeof body !== 'string') {
957 return res.status(400).json({ error: 'body (string) is required', code: 'BAD_REQUEST' });
958 }
959 const source = payload.source || 'webhook';
960 const sourceId = payload.source_id || null;
961 const project = payload.project || null;
962 const tags = payload.tags || null;
963 const now = new Date().toISOString().slice(0, 10);
964 const sourceSlug = normalizeSlug(source) || 'webhook';
965 const filename = sourceId
966 ? `${sourceSlug}_${sanitizeForFilename(sourceId)}.md`
967 : `${sourceSlug}_${Date.now()}.md`;
968 const relativePath = project
969 ? `projects/${normalizeSlug(project)}/inbox/${filename}`
970 : `inbox/${filename}`;
971 const baseFm = {
972 source,
973 date: now,
974 ...(sourceId && { source_id: sourceId }),
975 ...(project && { project: normalizeSlug(project) }),
976 ...(tags && { tags }),
977 };
978 const frontmatter = mergeProvenanceFrontmatter(baseFm, { kind: 'webhook' });
979 try {
980 const result = writeNote(config.vault_path, relativePath, { body: body.trimEnd(), frontmatter });
981 invalidateFacetsCache();
982 maybeAutoSync(config);
983 res.status(200).json({ ok: true, path: result.path });
984 } catch (e) {
985 if (e.message && e.message.includes('Invalid path')) {
986 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
987 }
988 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
989 }
990 });
991
992 // API v1 (JWT + rate limit + vault access for notes/search/proposals)
993 app.use('/api/v1/notes', jwtAuth, apiLimiter, requireVaultAccess);
994 app.use('/api/v1/search', jwtAuth, apiLimiter, requireVaultAccess);
995 app.use('/api/v1/proposals', jwtAuth, apiLimiter, requireVaultAccess);
996 app.use('/api/v1/automation', jwtAuth, apiLimiter);
997
998 app.post('/api/v1/automation/ingest', (req, res) => {
999 return handleSelfHostedAutomationIngest(req, res, { requireContract: false });
1000 });
1001
1002 app.get('/api/v1/automation/ingest-rules', requireSessionIngestCrud, async (req, res) => {
1003 try {
1004 const loaded = await loadIngestRulesForSub(req.user?.sub ?? '', config.data_dir);
1005 return res.json({ rules: loaded.rules, templates: loaded.templates });
1006 } catch (e) {
1007 return res.status(503).json({
1008 error: e.message || 'store unavailable',
1009 code: e.code || 'AGENT_CREDENTIAL_STORE_UNAVAILABLE',
1010 });
1011 }
1012 });
1013
1014 app.put('/api/v1/automation/ingest-rules', requireSessionIngestCrud, async (req, res) => {
1015 try {
1016 const incoming = Array.isArray(req.body && req.body.rules) ? req.body.rules : req.body;
1017 const list = Array.isArray(incoming) ? incoming : [];
1018 if (list.length > MAX_USER_RULES) {
1019 return res.status(400).json({ error: 'max 32 rules', code: 'BAD_REQUEST' });
1020 }
1021 const rules = list.map((row) => normalizeRuleForSave(row, { mintMissingId: true }));
1022 await saveIngestRulesForSub(req.user?.sub ?? '', rules, config.data_dir);
1023 return res.json({ rules, templates: listPackTemplates() });
1024 } catch (e) {
1025 if (e && e.status) return sendIngestError(res, e);
1026 return res.status(503).json({
1027 error: e.message || 'store unavailable',
1028 code: e.code || 'AGENT_CREDENTIAL_STORE_UNAVAILABLE',
1029 });
1030 }
1031 });
1032
1033 app.post('/api/v1/automation/ingest-rules', requireSessionIngestCrud, async (req, res) => {
1034 try {
1035 const loaded = await loadIngestRulesForSub(req.user?.sub ?? '', config.data_dir);
1036 if (loaded.rules.length >= MAX_USER_RULES) {
1037 return res.status(400).json({ error: 'max 32 rules', code: 'BAD_REQUEST' });
1038 }
1039 const rule = normalizeRuleForSave({ ...req.body, rule_id: undefined }, { mintMissingId: true });
1040 const rules = [...loaded.rules, rule];
1041 await saveIngestRulesForSub(req.user?.sub ?? '', rules, config.data_dir);
1042 return res.status(201).json({ rule, rules, templates: listPackTemplates() });
1043 } catch (e) {
1044 if (e && e.status) return sendIngestError(res, e);
1045 return res.status(503).json({
1046 error: e.message || 'store unavailable',
1047 code: e.code || 'AGENT_CREDENTIAL_STORE_UNAVAILABLE',
1048 });
1049 }
1050 });
1051
1052 app.post('/api/v1/automation/ingest-rules/from-template', requireSessionIngestCrud, async (req, res) => {
1053 try {
1054 const templateId = String((req.body && req.body.template_id) || '').trim();
1055 const templates = listPackTemplates();
1056 const tmpl = templates.find((t) => t.rule_id === templateId);
1057 if (!tmpl) return res.status(400).json({ error: 'unknown template', code: 'BAD_REQUEST' });
1058 const loaded = await loadIngestRulesForSub(req.user?.sub ?? '', config.data_dir);
1059 if (loaded.rules.length >= MAX_USER_RULES) {
1060 return res.status(400).json({ error: 'max 32 rules', code: 'BAD_REQUEST' });
1061 }
1062 const enable = req.body && req.body.enable === true;
1063 const rule = normalizeRuleForSave(
1064 { ...tmpl, rule_id: mintRuleId(), enabled: enable === true },
1065 { mintMissingId: false }
1066 );
1067 const rules = [...loaded.rules, rule];
1068 await saveIngestRulesForSub(req.user?.sub ?? '', rules, config.data_dir);
1069 return res.status(201).json({ rule, rules, templates });
1070 } catch (e) {
1071 if (e && e.status) return sendIngestError(res, e);
1072 return res.status(503).json({
1073 error: e.message || 'store unavailable',
1074 code: e.code || 'AGENT_CREDENTIAL_STORE_UNAVAILABLE',
1075 });
1076 }
1077 });
1078
1079 app.delete('/api/v1/automation/ingest-rules/:rule_id', requireSessionIngestCrud, async (req, res) => {
1080 try {
1081 const loaded = await loadIngestRulesForSub(req.user?.sub ?? '', config.data_dir);
1082 const rules = loaded.rules.filter((r) => r.rule_id !== String(req.params.rule_id || ''));
1083 await saveIngestRulesForSub(req.user?.sub ?? '', rules, config.data_dir);
1084 return res.json({ rules, templates: loaded.templates });
1085 } catch (e) {
1086 return res.status(503).json({
1087 error: e.message || 'store unavailable',
1088 code: e.code || 'AGENT_CREDENTIAL_STORE_UNAVAILABLE',
1089 });
1090 }
1091 });
1092 app.use('/api/v1/note-outline', jwtAuth, apiLimiter, requireVaultAccess);
1093 app.use('/api/v1/document-tree', jwtAuth, apiLimiter, requireVaultAccess);
1094 app.use('/api/v1/metadata-facets', jwtAuth, apiLimiter, requireVaultAccess);
1095 app.use('/api/v1/section-source', jwtAuth, apiLimiter, requireVaultAccess);
1096
1097 // GET /api/v1/calendar/connectors/callback — Google OAuth redirect (state-authenticated; no JWT)
1098 app.get('/api/v1/calendar/connectors/callback', async (req, res) => {
1099 try {
1100 const mod = await import('../lib/calendar/google-oauth-connector.mjs');
1101 const googleClient = mod.createProductionGoogleClient
1102 ? mod.createProductionGoogleClient()
1103 : mod.createFakeGoogleClient();
1104 const result = await mod.handleGoogleConnectorCallback({
1105 dataDir: config.data_dir,
1106 query: req.query,
1107 googleClient,
1108 env: process.env,
1109 });
1110 if (result.redirect) {
1111 return res.redirect(result.status, result.redirect);
1112 }
1113 return res.status(result.status).json({ code: result.code });
1114 } catch (e) {
1115 return res.status(500).json({ error: 'Callback failed', code: 'RUNTIME_ERROR' });
1116 }
1117 });
1118
1119 // GET /api/v1/docs/connectors/callback — Drive OAuth redirect (state-authenticated; no JWT)
1120 app.get('/api/v1/docs/connectors/callback', async (req, res) => {
1121 try {
1122 const result = await handleDocsConnectorCallbackUnified({
1123 dataDir: config.data_dir,
1124 query: req.query,
1125 googleClient: createProductionGoogleDriveClient(),
1126 env: process.env,
1127 });
1128 if (result.redirect) {
1129 return res.redirect(result.status, result.redirect);
1130 }
1131 return res.status(result.status).json({ code: result.code });
1132 } catch (e) {
1133 return res.status(500).json({ error: 'Callback failed', code: 'RUNTIME_ERROR' });
1134 }
1135 });
1136
1137 app.use('/api/v1/calendar', jwtAuth, apiLimiter, requireVaultAccess);
1138 app.use('/api/v1/docs', jwtAuth, apiLimiter, requireVaultAccess);
1139 app.use('/api/v1/flows', jwtAuth, apiLimiter, requireVaultAccess);
1140 app.use('/api/v1/tasks', jwtAuth, apiLimiter, requireVaultAccess);
1141 app.use('/api/v1/attachments', jwtAuth, apiLimiter, requireVaultAccess);
1142 app.use('/api/v1/task-loops', jwtAuth, apiLimiter, requireVaultAccess);
1143 app.use('/api/v1/learning-paths', jwtAuth, apiLimiter, requireVaultAccess);
1144
1145 // Facets cache (60s) per vault; invalidate on write/approve
1146 const FACETS_TTL_MS = 60 * 1000;
1147 const facetsCacheByVault = {};
1148 function invalidateFacetsCache() {
1149 Object.keys(facetsCacheByVault).forEach((k) => delete facetsCacheByVault[k]);
1150 }
1151
1152 // GET /api/v1/vault/folders — disk folders for Hub “New note” picker (self-hosted; empty on hosted gateway stub)
1153 app.get('/api/v1/vault/folders', jwtAuth, apiLimiter, requireVaultAccess, (req, res) => {
1154 try {
1155 const folders = listVaultFolderOptions(req.vaultPath);
1156 res.json({ folders });
1157 } catch (e) {
1158 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1159 }
1160 });
1161
1162 // GET /api/v1/note-outline?path=... — body-free heading outline for one authorized note
1163 app.get('/api/v1/note-outline', (req, res) => {
1164 const requestedPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
1165 if (!requestedPath) {
1166 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1167 }
1168 try {
1169 resolveVaultRelativePath(req.vaultPath, requestedPath);
1170 } catch (_) {
1171 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1172 }
1173 if (req.scope?.projects?.length || req.scope?.folders?.length) {
1174 const allowed = applyScopeFilter([{ path: requestedPath }], req.scope);
1175 if (allowed.length === 0) {
1176 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
1177 }
1178 }
1179 try {
1180 res.json(buildNoteOutline(readNote(req.vaultPath, requestedPath)));
1181 } catch (e) {
1182 const message = e?.message ? String(e.message) : '';
1183 if (message.includes('not found')) {
1184 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
1185 }
1186 if (message.includes('Invalid path')) {
1187 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1188 }
1189 return res.status(502).json({ error: 'Upstream error', code: 'UPSTREAM_ERROR' });
1190 }
1191 });
1192
1193 // GET /api/v1/document-tree?path=... — body-free nested heading tree for one authorized note
1194 app.get('/api/v1/document-tree', (req, res) => {
1195 const requestedPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
1196 if (!requestedPath) {
1197 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1198 }
1199 try {
1200 resolveVaultRelativePath(req.vaultPath, requestedPath);
1201 } catch (_) {
1202 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1203 }
1204 if (req.scope?.projects?.length || req.scope?.folders?.length) {
1205 const allowed = applyScopeFilter([{ path: requestedPath }], req.scope);
1206 if (allowed.length === 0) {
1207 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
1208 }
1209 }
1210 try {
1211 res.json(buildDocumentTree(readNote(req.vaultPath, requestedPath)));
1212 } catch (e) {
1213 const message = e?.message ? String(e.message) : '';
1214 if (message.includes('not found')) {
1215 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
1216 }
1217 if (message.includes('Invalid path')) {
1218 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1219 }
1220 return res.status(502).json({ error: 'Upstream error', code: 'UPSTREAM_ERROR' });
1221 }
1222 });
1223
1224 // GET /api/v1/metadata-facets?path=... — body-free metadata hints for one authorized note
1225 app.get('/api/v1/metadata-facets', (req, res) => {
1226 const requestedPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
1227 if (!requestedPath) {
1228 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1229 }
1230 try {
1231 resolveVaultRelativePath(req.vaultPath, requestedPath);
1232 } catch (_) {
1233 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1234 }
1235 if (req.scope?.projects?.length || req.scope?.folders?.length) {
1236 const allowed = applyScopeFilter([{ path: requestedPath }], req.scope);
1237 if (allowed.length === 0) {
1238 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
1239 }
1240 }
1241 try {
1242 const note = readNote(req.vaultPath, requestedPath);
1243 res.json(normalizeMetadataFacets(requestedPath, note.frontmatter));
1244 } catch (e) {
1245 const message = e?.message ? String(e.message) : '';
1246 if (message.includes('not found')) {
1247 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
1248 }
1249 if (message.includes('Invalid path')) {
1250 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1251 }
1252 return res.status(502).json({ error: 'Upstream error', code: 'UPSTREAM_ERROR' });
1253 }
1254 });
1255
1256 // GET /api/v1/section-source?path=... — body-free section metadata for one authorized note
1257 app.get('/api/v1/section-source', (req, res) => {
1258 const requestedPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
1259 if (!requestedPath) {
1260 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1261 }
1262 try {
1263 resolveVaultRelativePath(req.vaultPath, requestedPath);
1264 } catch (_) {
1265 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1266 }
1267 if (req.scope?.projects?.length || req.scope?.folders?.length) {
1268 const allowed = applyScopeFilter([{ path: requestedPath }], req.scope);
1269 if (allowed.length === 0) {
1270 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
1271 }
1272 }
1273 try {
1274 res.json(readSectionSource(req.vaultPath, requestedPath));
1275 } catch (e) {
1276 const message = e?.message ? String(e.message) : '';
1277 if (message.includes('not found')) {
1278 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
1279 }
1280 if (message.includes('Invalid path')) {
1281 return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
1282 }
1283 return res.status(502).json({ error: 'Upstream error', code: 'UPSTREAM_ERROR' });
1284 }
1285 });
1286
1287 // GET /api/v1/calendar/timeline?from=&to=&layers=notes,events&source_calendar_ids=
1288 app.get('/api/v1/calendar/timeline', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1289 const from = typeof req.query.from === 'string' ? req.query.from.trim() : '';
1290 const to = typeof req.query.to === 'string' ? req.query.to.trim() : '';
1291 if (!from || !to) {
1292 return res.status(400).json({ error: '`from` and `to` are required', code: 'BAD_REQUEST' });
1293 }
1294 try {
1295 const payload = buildCalendarTimeline({
1296 dataDir: config.data_dir,
1297 vaultId: req.vault_id ?? 'default',
1298 vaultPath: req.vaultPath,
1299 vaultConfig: config,
1300 from,
1301 to,
1302 layers: req.query.layers,
1303 sourceCalendarIds: req.query.source_calendar_ids,
1304 scope: req.scope,
1305 });
1306 return res.json(payload);
1307 } catch (e) {
1308 const message = e?.message ? String(e.message) : 'Invalid timeline request';
1309 if (message.includes('Unsupported timeline layer') || message.includes('Invalid') || message.includes('required') || message.includes('before')) {
1310 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
1311 }
1312 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
1313 }
1314 });
1315
1316 // GET /api/v1/calendar/agent-context?from=&to=&agent_context_tier=0|1|2&source_calendar_ids=
1317 // Server-side tier-enforced calendar context for agents (Phase 1E). Enforces
1318 // enabled_for_agents + agent_context_tier_max + org policy cap; v0 ceiling tier 2.
1319 app.get('/api/v1/calendar/agent-context', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1320 const from = typeof req.query.from === 'string' ? req.query.from.trim() : '';
1321 const to = typeof req.query.to === 'string' ? req.query.to.trim() : '';
1322 if (!from || !to) {
1323 return res.status(400).json({ error: '`from` and `to` are required', code: 'BAD_REQUEST' });
1324 }
1325 try {
1326 const payload = retrieveAgentCalendarContext(config.data_dir, req.vault_id ?? 'default', {
1327 from,
1328 to,
1329 agentContextTier: req.query.agent_context_tier,
1330 sourceCalendarIds: req.query.source_calendar_ids,
1331 });
1332 return res.json(payload);
1333 } catch (e) {
1334 const message = e?.message ? String(e.message) : 'Invalid agent context request';
1335 if (
1336 message.includes('agent_context_tier')
1337 || message.includes('Invalid')
1338 || message.includes('required')
1339 || message.includes('before')
1340 ) {
1341 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
1342 }
1343 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
1344 }
1345 });
1346
1347 // GET /api/v1/calendar/source-calendars — display/agent toggles (no OAuth secrets)
1348 app.get('/api/v1/calendar/source-calendars', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1349 try {
1350 res.json({
1351 schema: 'knowtation.source_calendars/v0',
1352 vault_id: req.vault_id ?? 'default',
1353 source_calendars: listSourceCalendarsForClient(config.data_dir, req.vault_id ?? 'default'),
1354 });
1355 } catch (e) {
1356 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1357 }
1358 });
1359
1360 // PATCH /api/v1/calendar/source-calendars/:id — update display/agent toggles (self-hosted)
1361 app.patch('/api/v1/calendar/source-calendars/:id', requireRole('editor', 'admin'), (req, res) => {
1362 const sourceCalendarId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1363 if (!sourceCalendarId) {
1364 return res.status(400).json({ error: 'source calendar id is required', code: 'BAD_REQUEST' });
1365 }
1366 try {
1367 const patch = parseSourceCalendarPatchBody(req.body);
1368 const result = patchSourceCalendar(
1369 config.data_dir,
1370 req.vault_id ?? 'default',
1371 sourceCalendarId,
1372 patch,
1373 );
1374 return res.json({
1375 schema: 'knowtation.source_calendar_patch/v0',
1376 vault_id: req.vault_id ?? 'default',
1377 policy_agent_context_tier_max_cap: result.policy_agent_context_tier_max_cap,
1378 source_calendar: result.source_calendar,
1379 });
1380 } catch (e) {
1381 const message = e?.message ? String(e.message) : 'Patch failed';
1382 if (e?.code === 'POLICY_CAP_EXCEEDED') {
1383 return res.status(403).json({ error: message, code: 'POLICY_CAP_EXCEEDED' });
1384 }
1385 if (message.includes('not found')) {
1386 return res.status(404).json({ error: message, code: 'NOT_FOUND' });
1387 }
1388 if (
1389 message.includes('must be')
1390 || message.includes('required')
1391 || message.includes('exceeds policy')
1392 ) {
1393 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
1394 }
1395 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
1396 }
1397 });
1398
1399 // POST /api/v1/calendar/events/import — one-time ICS file import (read-only, self-hosted)
1400 app.post('/api/v1/calendar/events/import', requireRole('editor', 'admin'), (req, res) => {
1401 const body = req.body && typeof req.body === 'object' ? req.body : {};
1402 const icsText = typeof body.ics_text === 'string' ? body.ics_text : '';
1403 if (!icsText.trim()) {
1404 return res.status(400).json({ error: 'ics_text (string) is required', code: 'BAD_REQUEST' });
1405 }
1406 try {
1407 const result = importIcsIntoVault(config.data_dir, req.vault_id ?? 'default', {
1408 icsText,
1409 displayName: typeof body.display_name === 'string' ? body.display_name : undefined,
1410 sourceCalendarId: typeof body.source_calendar_id === 'string' ? body.source_calendar_id : undefined,
1411 connectorId: typeof body.connector_id === 'string' ? body.connector_id : undefined,
1412 defaultTimezone: typeof body.default_timezone === 'string' ? body.default_timezone : undefined,
1413 });
1414 return res.status(200).json({
1415 schema: 'knowtation.calendar_import/v0',
1416 vault_id: req.vault_id ?? 'default',
1417 ...result,
1418 });
1419 } catch (e) {
1420 const message = e?.message ? String(e.message) : 'Import failed';
1421 if (message.includes('not found') || message.includes('required') || message.includes('exceeds') || message.includes('ICS')) {
1422 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
1423 }
1424 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
1425 }
1426 });
1427
1428 // POST /api/v1/calendar/connectors — begin Google OAuth connect (Phase 1D, gated)
1429 app.post('/api/v1/calendar/connectors', requireRole('editor', 'admin'), (req, res) => {
1430 const result = handleBeginGoogleConnector({
1431 dataDir: config.data_dir,
1432 vaultId: req.vault_id ?? 'default',
1433 body: req.body,
1434 env: process.env,
1435 });
1436 if (!result.ok) {
1437 return res.status(result.status).json({ error: result.error ?? 'Not authorized', code: result.code });
1438 }
1439 return res.status(result.status).json(result.payload);
1440 });
1441
1442 // GET /api/v1/calendar/connectors — connector status (token-free, gated)
1443 app.get('/api/v1/calendar/connectors', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1444 const result = handleListGoogleConnectors({
1445 dataDir: config.data_dir,
1446 vaultId: req.vault_id ?? 'default',
1447 });
1448 if (!result.ok) {
1449 return res.status(result.status).json({ error: result.error ?? 'Not authorized', code: result.code });
1450 }
1451 return res.json(result.payload);
1452 });
1453
1454 // POST /api/v1/calendar/connectors/:id/sync — manual sync (gated, rate-limited)
1455 app.post('/api/v1/calendar/connectors/:id/sync', requireRole('editor', 'admin'), async (req, res) => {
1456 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1457 try {
1458 const mod = await import('../lib/calendar/google-oauth-connector.mjs');
1459 const googleClient = mod.createProductionGoogleClient
1460 ? mod.createProductionGoogleClient()
1461 : mod.createFakeGoogleClient();
1462 const result = await mod.handleSyncGoogleConnector({
1463 dataDir: config.data_dir,
1464 vaultId: req.vault_id ?? 'default',
1465 connectorId,
1466 googleClient,
1467 env: process.env,
1468 });
1469 if (!result.ok) {
1470 return res.status(result.status).json({ code: result.code });
1471 }
1472 return res.status(result.status).json(result.payload);
1473 } catch (e) {
1474 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1475 }
1476 });
1477
1478 // DELETE /api/v1/calendar/connectors/:id — revoke + purge (gated)
1479 app.delete('/api/v1/calendar/connectors/:id', requireRole('editor', 'admin'), async (req, res) => {
1480 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1481 try {
1482 const mod = await import('../lib/calendar/google-oauth-connector.mjs');
1483 const googleClient = mod.createProductionGoogleClient
1484 ? mod.createProductionGoogleClient()
1485 : mod.createFakeGoogleClient();
1486 const result = await mod.handleRevokeGoogleConnector({
1487 dataDir: config.data_dir,
1488 vaultId: req.vault_id ?? 'default',
1489 connectorId,
1490 googleClient,
1491 env: process.env,
1492 });
1493 if (!result.ok) {
1494 return res.status(result.status).json({ code: result.code });
1495 }
1496 return res.status(result.status).json(result.payload);
1497 } catch (e) {
1498 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1499 }
1500 });
1501
1502 // ── Docs connectors (KN-DOCS-SYNC-b) — gates hard-coded false ───────────────
1503 app.post('/api/v1/docs/connectors', requireRole('editor', 'admin'), (req, res) => {
1504 const result = handleBeginDocsProvider({
1505 dataDir: config.data_dir,
1506 vaultId: req.vault_id ?? 'default',
1507 body: req.body,
1508 env: process.env,
1509 });
1510 if (!result.ok) {
1511 return res.status(result.status).json({ error: result.error ?? 'Not authorized', code: result.code });
1512 }
1513 return res.status(result.status).json(result.payload);
1514 });
1515
1516 app.get('/api/v1/docs/connectors', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1517 const result = handleListAllDocsConnectors({
1518 dataDir: config.data_dir,
1519 vaultId: req.vault_id ?? 'default',
1520 });
1521 if (!result.ok) {
1522 return res.status(result.status).json({ error: result.error ?? 'Not authorized', code: result.code });
1523 }
1524 return res.json(result.payload);
1525 });
1526
1527 app.get('/api/v1/docs/connectors/:id/files', requireRole('viewer', 'editor', 'admin', 'evaluator'), async (req, res) => {
1528 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1529 try {
1530 const result = await handleDocsConnectorAction('list', {
1531 dataDir: config.data_dir,
1532 vaultId: req.vault_id ?? 'default',
1533 connectorId,
1534 query: req.query,
1535 env: process.env,
1536 googleClient: createProductionGoogleDriveClient(),
1537 notionClient: createProductionNotionClient(),
1538 });
1539 if (!result.ok) {
1540 return res.status(result.status).json({ code: result.code });
1541 }
1542 return res.status(result.status).json(result.payload);
1543 } catch (e) {
1544 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1545 }
1546 });
1547
1548 app.post('/api/v1/docs/connectors/:id/import', requireRole('editor', 'admin'), async (req, res) => {
1549 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1550 try {
1551 const result = await handleDocsConnectorAction('import', {
1552 dataDir: config.data_dir,
1553 vaultPath: req.vaultPath,
1554 vaultId: req.vault_id ?? 'default',
1555 connectorId,
1556 body: req.body,
1557 env: process.env,
1558 googleClient: createProductionGoogleDriveClient(),
1559 notionClient: createProductionNotionClient(),
1560 createProposalFn: createProposal,
1561 });
1562 if (!result.ok) {
1563 return res.status(result.status).json({ code: result.code });
1564 }
1565 return res.status(result.status).json(result.payload);
1566 } catch (e) {
1567 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1568 }
1569 });
1570
1571 app.post('/api/v1/docs/connectors/:id/sync', requireRole('editor', 'admin'), async (req, res) => {
1572 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1573 try {
1574 const result = await handleDocsConnectorAction('sync', {
1575 dataDir: config.data_dir,
1576 vaultPath: req.vaultPath,
1577 vaultId: req.vault_id ?? 'default',
1578 connectorId,
1579 env: process.env,
1580 googleClient: createProductionGoogleDriveClient(),
1581 notionClient: createProductionNotionClient(),
1582 createProposalFn: createProposal,
1583 });
1584 if (!result.ok) {
1585 return res.status(result.status).json({ code: result.code });
1586 }
1587 return res.status(result.status).json(result.payload);
1588 } catch (e) {
1589 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1590 }
1591 });
1592
1593 app.delete('/api/v1/docs/connectors/:id', requireRole('editor', 'admin'), async (req, res) => {
1594 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1595 try {
1596 const result = await handleDocsConnectorAction('revoke', {
1597 dataDir: config.data_dir,
1598 vaultId: req.vault_id ?? 'default',
1599 connectorId,
1600 env: process.env,
1601 googleClient: createProductionGoogleDriveClient(),
1602 notionClient: createProductionNotionClient(),
1603 });
1604 if (!result.ok) {
1605 return res.status(result.status).json({ code: result.code });
1606 }
1607 return res.status(result.status).json(result.payload);
1608 } catch (e) {
1609 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1610 }
1611 });
1612
1613 // GET /api/v1/flows — scope/tag filtered, content-minimized list (Phase 7A-10b)
1614 app.get('/api/v1/flows', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1615 const limitRaw = req.query.limit;
1616 let limit;
1617 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
1618 limit = parseInt(String(limitRaw), 10);
1619 }
1620 const result = handleFlowListRequest({
1621 dataDir: config.data_dir,
1622 vaultId: req.vault_id ?? 'default',
1623 userId: req.user?.sub ?? '',
1624 role: effectiveRole(req),
1625 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
1626 tag: typeof req.query.tag === 'string' ? req.query.tag : undefined,
1627 limit,
1628 });
1629 if (!result.ok) {
1630 return res.status(result.status).json({ error: result.error, code: result.code });
1631 }
1632 return res.json(result.payload);
1633 });
1634
1635 // GET /api/v1/flows/:id/projection — derived harness projection (Phase 7A-11b)
1636 app.get(
1637 '/api/v1/flows/:id/projection',
1638 requireRole('viewer', 'editor', 'admin', 'evaluator'),
1639 (req, res) => {
1640 const flowId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1641 const harness = typeof req.query.harness === 'string' ? req.query.harness.trim() : '';
1642 const result = handleFlowProjectRequest({
1643 dataDir: config.data_dir,
1644 vaultId: req.vault_id ?? 'default',
1645 flowId,
1646 harness,
1647 userId: req.user?.sub ?? '',
1648 role: effectiveRole(req),
1649 version: typeof req.query.version === 'string' ? req.query.version : undefined,
1650 });
1651 if (!result.ok) {
1652 return res.status(result.status).json({ error: result.error, code: result.code });
1653 }
1654 return res.json(result.payload);
1655 },
1656 );
1657
1658 // GET /api/v1/flows/:id — full definition + ordered steps (Phase 7A-10b)
1659 app.get('/api/v1/flows/:id', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1660 const flowId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1661 const result = handleFlowGetRequest({
1662 dataDir: config.data_dir,
1663 vaultId: req.vault_id ?? 'default',
1664 flowId,
1665 userId: req.user?.sub ?? '',
1666 role: effectiveRole(req),
1667 version: typeof req.query.version === 'string' ? req.query.version : undefined,
1668 });
1669 if (!result.ok) {
1670 return res.status(result.status).json({ error: result.error, code: result.code });
1671 }
1672 return res.json(result.payload);
1673 });
1674
1675 // GET /api/v1/tasks — scope-filtered, content-minimized list (Phase 2G-b)
1676 app.get('/api/v1/tasks', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1677 const limitRaw = req.query.limit;
1678 let limit;
1679 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
1680 limit = parseInt(String(limitRaw), 10);
1681 }
1682 const result = handleTaskListRequest({
1683 dataDir: config.data_dir,
1684 vaultId: req.vault_id ?? 'default',
1685 userId: req.user?.sub ?? '',
1686 role: effectiveRole(req),
1687 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
1688 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
1689 status: typeof req.query.status === 'string' ? req.query.status : undefined,
1690 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
1691 limit,
1692 });
1693 if (!result.ok) {
1694 return res.status(result.status).json({ error: result.error, code: result.code });
1695 }
1696 return res.json(result.payload);
1697 });
1698
1699 // GET /api/v1/tasks/:id — one authorized task (Phase 2G-b)
1700 app.get('/api/v1/tasks/:id', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1701 const taskId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1702 const result = handleTaskGetRequest({
1703 dataDir: config.data_dir,
1704 vaultId: req.vault_id ?? 'default',
1705 taskId,
1706 userId: req.user?.sub ?? '',
1707 role: effectiveRole(req),
1708 });
1709 if (!result.ok) {
1710 return res.status(result.status).json({ error: result.error, code: result.code });
1711 }
1712 return res.json(result.payload);
1713 });
1714
1715 // GET /api/v1/attachments — scope-filtered, content-minimized list (Phase 2F-b-b)
1716 app.get('/api/v1/attachments', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1717 const limitRaw = req.query.limit;
1718 let limit;
1719 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
1720 limit = parseInt(String(limitRaw), 10);
1721 }
1722 const agentVisibleRaw = req.query.agent_visible;
1723 const agentVisible =
1724 agentVisibleRaw === 'true' || agentVisibleRaw === true || agentVisibleRaw === '1';
1725 const result = handleAttachmentListRequest({
1726 dataDir: config.data_dir,
1727 vaultPath: req.vaultPath,
1728 vaultId: req.vault_id ?? 'default',
1729 userId: req.user?.sub ?? '',
1730 role: effectiveRole(req),
1731 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
1732 note_ref: typeof req.query.note_ref === 'string' ? req.query.note_ref : undefined,
1733 source: typeof req.query.source === 'string' ? req.query.source : undefined,
1734 mime_class: typeof req.query.mime_class === 'string' ? req.query.mime_class : undefined,
1735 storage_kind: typeof req.query.storage_kind === 'string' ? req.query.storage_kind : undefined,
1736 agent_visible: agentVisible,
1737 limit,
1738 hubScope: req.scope ?? null,
1739 vaultConfig: { ignore: config.ignore },
1740 });
1741 if (!result.ok) {
1742 return res.status(result.status).json({ error: result.error, code: result.code });
1743 }
1744 return res.json(result.payload);
1745 });
1746
1747 // Media write surfaces (Phase 2F-b-d-kn-b) — typed facade over /proposals (SD-4).
1748 // Gated independently: MEDIA_EXTERNAL_LINK_ENABLED / MEDIA_ATTACH_ENABLED (default OFF).
1749 const MEDIA_WRITE_ROLES = requireRole('editor', 'admin');
1750 const MEDIA_CONSENT_READ_ROLES = requireRole('viewer', 'editor', 'admin', 'evaluator');
1751
1752 app.post('/api/v1/attachments/link-proposals', MEDIA_WRITE_ROLES, async (req, res) => {
1753 try {
1754 const body = req.body && typeof req.body === 'object' ? req.body : {};
1755 const result = await handleMediaLinkProposeRequest({
1756 dataDir: config.data_dir,
1757 vaultPath: req.vaultPath,
1758 vaultId: req.vault_id ?? 'default',
1759 userId: req.user?.sub ?? '',
1760 role: effectiveRole(req),
1761 body,
1762 intent: body.intent,
1763 sessionBound: isSessionBoundActor(req.user),
1764 createProposal: createProposalWithSession(req),
1765 hubScope: req.scope ?? null,
1766 vaultConfig: { ignore: config.ignore },
1767 });
1768 if (!result.ok) {
1769 return res.status(result.status).json({ error: result.error, code: result.code });
1770 }
1771 appendAudit(config.data_dir, {
1772 userId: req.user?.sub ?? 'unknown',
1773 action: 'media_external_link_propose',
1774 proposalId: result.payload.proposal_id,
1775 detail: {
1776 proposal_kind: result.payload.proposal_kind,
1777 attachment_id: result.payload.attachment_id,
1778 },
1779 });
1780 return res.status(201).json(result.payload);
1781 } catch (e) {
1782 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1783 }
1784 });
1785
1786 app.post('/api/v1/attachments/attach-proposals', MEDIA_WRITE_ROLES, async (req, res) => {
1787 try {
1788 const body = req.body && typeof req.body === 'object' ? req.body : {};
1789 const result = await handleMediaAttachProposeRequest({
1790 dataDir: config.data_dir,
1791 vaultPath: req.vaultPath,
1792 vaultId: req.vault_id ?? 'default',
1793 userId: req.user?.sub ?? '',
1794 role: effectiveRole(req),
1795 body,
1796 intent: body.intent,
1797 sessionBound: isSessionBoundActor(req.user),
1798 createProposal: createProposalWithSession(req),
1799 hubScope: req.scope ?? null,
1800 vaultConfig: { ignore: config.ignore },
1801 });
1802 if (!result.ok) {
1803 return res.status(result.status).json({ error: result.error, code: result.code });
1804 }
1805 appendAudit(config.data_dir, {
1806 userId: req.user?.sub ?? 'unknown',
1807 action: 'media_attach_propose',
1808 proposalId: result.payload.proposal_id,
1809 detail: {
1810 proposal_kind: result.payload.proposal_kind,
1811 attachment_id: result.payload.attachment_id,
1812 note_ref: result.payload.note_ref,
1813 },
1814 });
1815 return res.status(201).json(result.payload);
1816 } catch (e) {
1817 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1818 }
1819 });
1820
1821 app.post('/api/v1/attachments/import-consents', MEDIA_WRITE_ROLES, (req, res) => {
1822 const body = req.body && typeof req.body === 'object' ? req.body : {};
1823 const result = handleMediaImportConsentGrantRequest({
1824 dataDir: config.data_dir,
1825 vaultId: req.vault_id ?? 'default',
1826 userId: req.user?.sub ?? '',
1827 role: effectiveRole(req),
1828 body,
1829 });
1830 if (!result.ok) {
1831 return res.status(result.status).json({ error: result.error, code: result.code });
1832 }
1833 return res.status(201).json(result.payload);
1834 });
1835
1836 app.get('/api/v1/attachments/import-consents', MEDIA_CONSENT_READ_ROLES, (req, res) => {
1837 const result = handleMediaImportConsentListRequest({
1838 dataDir: config.data_dir,
1839 vaultId: req.vault_id ?? 'default',
1840 userId: req.user?.sub ?? '',
1841 role: effectiveRole(req),
1842 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
1843 });
1844 if (!result.ok) {
1845 return res.status(result.status).json({ error: result.error, code: result.code });
1846 }
1847 return res.json(result.payload);
1848 });
1849
1850 app.delete('/api/v1/attachments/import-consents/:id', MEDIA_WRITE_ROLES, (req, res) => {
1851 const consentId =
1852 typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1853 const result = handleMediaImportConsentRevokeRequest({
1854 dataDir: config.data_dir,
1855 vaultId: req.vault_id ?? 'default',
1856 userId: req.user?.sub ?? '',
1857 role: effectiveRole(req),
1858 consentId,
1859 });
1860 if (!result.ok) {
1861 return res.status(result.status).json({ error: result.error, code: result.code });
1862 }
1863 return res.json(result.payload);
1864 });
1865
1866 // GET /api/v1/attachments/:id — one authorized attachment (Phase 2F-b-b)
1867 app.get('/api/v1/attachments/:id', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1868 const attachmentId =
1869 typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
1870 const result = handleAttachmentGetRequest({
1871 dataDir: config.data_dir,
1872 vaultPath: req.vaultPath,
1873 vaultId: req.vault_id ?? 'default',
1874 attachmentId,
1875 userId: req.user?.sub ?? '',
1876 role: effectiveRole(req),
1877 hubScope: req.scope ?? null,
1878 vaultConfig: { ignore: config.ignore },
1879 });
1880 if (!result.ok) {
1881 return res.status(result.status).json({ error: result.error, code: result.code });
1882 }
1883 return res.json(result.payload);
1884 });
1885
1886 // GET /api/v1/task-loops — scope-filtered loop list (Phase 2G-c hosted parity)
1887 app.get('/api/v1/task-loops', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1888 const limitRaw = req.query.limit;
1889 let limit;
1890 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
1891 limit = parseInt(String(limitRaw), 10);
1892 }
1893 const result = handleTaskLoopListRequest({
1894 dataDir: config.data_dir,
1895 vaultId: req.vault_id ?? 'default',
1896 userId: req.user?.sub ?? '',
1897 role: effectiveRole(req),
1898 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
1899 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
1900 status: typeof req.query.status === 'string' ? req.query.status : undefined,
1901 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
1902 limit,
1903 });
1904 if (!result.ok) {
1905 return res.status(result.status).json({ error: result.error, code: result.code });
1906 }
1907 return res.json(result.payload);
1908 });
1909
1910 // GET /api/v1/task-loops/:loop_id — one authorized loop (Phase 2G-c hosted parity)
1911 app.get('/api/v1/task-loops/:loop_id', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1912 const loopId =
1913 typeof req.params.loop_id === 'string' ? decodeURIComponent(req.params.loop_id).trim() : '';
1914 const result = handleTaskLoopGetRequest({
1915 dataDir: config.data_dir,
1916 vaultId: req.vault_id ?? 'default',
1917 loopId,
1918 userId: req.user?.sub ?? '',
1919 role: effectiveRole(req),
1920 });
1921 if (!result.ok) {
1922 return res.status(result.status).json({ error: result.error, code: result.code });
1923 }
1924 return res.json(result.payload);
1925 });
1926
1927 // Loop pass audit mirror — append-only, idempotent on pass_id (Phase 2G-e OD-7).
1928 // Gated by LOOP_PASS_AUDIT_MIRROR_ENABLED (default OFF → 403 LOOP_PASS_AUDIT_MIRROR_DISABLED).
1929 app.post('/api/v1/loop-pass-audit', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
1930 const body = req.body && typeof req.body === 'object' ? req.body : {};
1931 const result = handleLoopPassAuditAppendRequest({
1932 dataDir: config.data_dir,
1933 vaultId: req.vault_id ?? 'default',
1934 body,
1935 });
1936 if (!result.ok) {
1937 return res.status(result.status).json({ error: result.error, code: result.code });
1938 }
1939 return res.status(result.idempotent ? 200 : 201).json(result.payload);
1940 });
1941
1942 // Task + task-loop write proposals (Phase 2G-d) — typed facade over /proposals (SD-4).
1943 // Gated by TASK_WRITES_ENABLED (default OFF → 403 TASK_WRITES_DISABLED).
1944 const TASK_WRITE_ROLES = requireRole('viewer', 'editor', 'admin', 'evaluator');
1945
1946 function createProposalWithSession(req) {
1947 return (dataDir, input) =>
1948 createProposal(dataDir, {
1949 ...input,
1950 session_bound: isSessionBoundActor(req.user),
1951 });
1952 }
1953
1954
1955 app.post('/api/v1/tasks/proposals', TASK_WRITE_ROLES, async (req, res) => {
1956 try {
1957 const body = req.body && typeof req.body === 'object' ? req.body : {};
1958 const proposalKind =
1959 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
1960 ? body.proposal_kind.trim()
1961 : 'task_create';
1962 const result = await handleTaskProposeRequest({
1963 dataDir: config.data_dir,
1964 vaultId: req.vault_id ?? 'default',
1965 userId: req.user?.sub ?? '',
1966 role: effectiveRole(req),
1967 proposalKind,
1968 body,
1969 intent: body.intent,
1970 sessionBound: isSessionBoundActor(req.user),
1971 createProposal: createProposalWithSession(req),
1972 });
1973 if (!result.ok) {
1974 return res.status(result.status).json({ error: result.error, code: result.code });
1975 }
1976 appendAudit(config.data_dir, {
1977 userId: req.user?.sub ?? 'unknown',
1978 action: 'task_propose',
1979 proposalId: result.payload.proposal_id,
1980 detail: { proposal_kind: result.payload.proposal_kind, task_id: result.payload.task_id },
1981 });
1982 return res.status(201).json(result.payload);
1983 } catch (e) {
1984 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1985 }
1986 });
1987
1988 app.post('/api/v1/task-loops/proposals', TASK_WRITE_ROLES, async (req, res) => {
1989 try {
1990 const body = req.body && typeof req.body === 'object' ? req.body : {};
1991 const proposalKind =
1992 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
1993 ? body.proposal_kind.trim()
1994 : 'task_loop_create';
1995 const result = await handleTaskLoopProposeRequest({
1996 dataDir: config.data_dir,
1997 vaultId: req.vault_id ?? 'default',
1998 userId: req.user?.sub ?? '',
1999 role: effectiveRole(req),
2000 proposalKind,
2001 body,
2002 intent: body.intent,
2003 sessionBound: isSessionBoundActor(req.user),
2004 createProposal: createProposalWithSession(req),
2005 });
2006 if (!result.ok) {
2007 return res.status(result.status).json({ error: result.error, code: result.code });
2008 }
2009 appendAudit(config.data_dir, {
2010 userId: req.user?.sub ?? 'unknown',
2011 action: 'task_loop_propose',
2012 proposalId: result.payload.proposal_id,
2013 detail: { proposal_kind: result.payload.proposal_kind, loop_id: result.payload.loop_id },
2014 });
2015 return res.status(201).json(result.payload);
2016 } catch (e) {
2017 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2018 }
2019 });
2020
2021 // Learning paths (KN-WORK-PATH-LIST-b) — list/get always authorized; writes gated PATH_WRITES_ENABLED default off.
2022 app.get('/api/v1/learning-paths', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
2023 const result = handlePathListRequest({
2024 dataDir: config.data_dir,
2025 vaultId: req.vault_id ?? 'default',
2026 userId: req.user?.sub ?? '',
2027 role: effectiveRole(req),
2028 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
2029 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
2030 status: typeof req.query.status === 'string' ? req.query.status : undefined,
2031 limit: req.query.limit,
2032 });
2033 if (!result.ok) {
2034 return res.status(result.status).json({ error: result.error, code: result.code });
2035 }
2036 return res.json(result.payload);
2037 });
2038
2039 app.get('/api/v1/learning-paths/:path_id', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
2040 const pathId =
2041 typeof req.params.path_id === 'string' ? decodeURIComponent(req.params.path_id).trim() : '';
2042 const result = handlePathGetRequest({
2043 dataDir: config.data_dir,
2044 vaultId: req.vault_id ?? 'default',
2045 pathId,
2046 userId: req.user?.sub ?? '',
2047 role: effectiveRole(req),
2048 });
2049 if (!result.ok) {
2050 return res.status(result.status).json({ error: result.error, code: result.code });
2051 }
2052 return res.json(result.payload);
2053 });
2054
2055 const PATH_WRITE_ROLES = requireRole('viewer', 'editor', 'admin', 'evaluator');
2056
2057 app.post('/api/v1/learning-paths/proposals', PATH_WRITE_ROLES, async (req, res) => {
2058 try {
2059 const body = req.body && typeof req.body === 'object' ? req.body : {};
2060 const proposalKind =
2061 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
2062 ? body.proposal_kind.trim()
2063 : 'path_create';
2064 const result = await handlePathProposeRequest({
2065 dataDir: config.data_dir,
2066 vaultId: req.vault_id ?? 'default',
2067 userId: req.user?.sub ?? '',
2068 role: effectiveRole(req),
2069 proposalKind,
2070 body,
2071 intent: body.intent,
2072 sessionBound: isSessionBoundActor(req.user),
2073 createProposal: createProposalWithSession(req),
2074 });
2075 if (!result.ok) {
2076 return res.status(result.status).json({ error: result.error, code: result.code });
2077 }
2078 appendAudit(config.data_dir, {
2079 userId: req.user?.sub ?? 'unknown',
2080 action: 'path_propose',
2081 proposalId: result.payload.proposal_id,
2082 detail: { proposal_kind: result.payload.proposal_kind, path_id: result.payload.path_id },
2083 });
2084 return res.status(201).json(result.payload);
2085 } catch (e) {
2086 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2087 }
2088 });
2089
2090 app.post('/api/v1/task-loops/:loop_id/instances/proposals', TASK_WRITE_ROLES, async (req, res) => {
2091 try {
2092 const loopId =
2093 typeof req.params.loop_id === 'string' ? decodeURIComponent(req.params.loop_id).trim() : '';
2094 const body = req.body && typeof req.body === 'object' ? req.body : {};
2095 const result = await handleTaskInstanceMaterializeRequest({
2096 dataDir: config.data_dir,
2097 vaultId: req.vault_id ?? 'default',
2098 userId: req.user?.sub ?? '',
2099 role: effectiveRole(req),
2100 loopId,
2101 body: { ...body, loop_id: loopId },
2102 intent: body.intent,
2103 sessionBound: isSessionBoundActor(req.user),
2104 createProposal: createProposalWithSession(req),
2105 });
2106 if (!result.ok) {
2107 return res.status(result.status).json({ error: result.error, code: result.code });
2108 }
2109 appendAudit(config.data_dir, {
2110 userId: req.user?.sub ?? 'unknown',
2111 action: 'task_instance_materialize',
2112 proposalId: result.payload.proposal_id,
2113 detail: {
2114 loop_id: result.payload.loop_id,
2115 task_id: result.payload.task_id,
2116 occurrence_key: result.payload.occurrence_key,
2117 },
2118 });
2119 return res.status(201).json(result.payload);
2120 } catch (e) {
2121 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2122 }
2123 });
2124
2125 // Flow authoring write-back (Phase 7A-L1b) — typed facade over /proposals (SD-4).
2126 // Gated by FLOW_AUTHORING_WRITES (default OFF → 403 FLOW_AUTHORING_DISABLED).
2127 const FLOW_AUTHORING_WRITE_ROLES = requireRole('viewer', 'editor', 'admin', 'evaluator');
2128
2129 function runFlowPropose(req, res, kind, extra = {}) {
2130 const body = req.body && typeof req.body === 'object' ? req.body : {};
2131 return handleFlowProposeRequest({
2132 dataDir: config.data_dir,
2133 vaultId: req.vault_id ?? 'default',
2134 userId: req.user?.sub ?? '',
2135 role: effectiveRole(req),
2136 kind,
2137 flow: body.flow,
2138 steps: body.steps,
2139 bundle: kind === 'import' ? body.bundle ?? { flow: body.flow, steps: body.steps } : undefined,
2140 intent: body.intent,
2141 flowId: extra.flowId,
2142 baseVersion: body.base_version,
2143 baseStateId: body.base_state_id,
2144 externalRef: body.external_ref,
2145 sourceVaultHint: body.source_vault_hint,
2146 sessionBound: isSessionBoundActor(req.user),
2147 createProposal,
2148 }).then((result) => {
2149 if (!result.ok) {
2150 return res.status(result.status).json({ error: result.error, code: result.code });
2151 }
2152 appendAudit(config.data_dir, {
2153 userId: req.user?.sub ?? 'unknown',
2154 action: 'flow_propose',
2155 proposalId: result.payload.proposal_id,
2156 detail: { kind, flow_id: result.payload.flow_id },
2157 });
2158 return res.status(201).json(result.payload);
2159 });
2160 }
2161
2162 // POST /api/v1/flows — propose a new Flow (flow_propose, new).
2163 app.post('/api/v1/flows', FLOW_AUTHORING_WRITE_ROLES, (req, res) => {
2164 runFlowPropose(req, res, 'new').catch((e) => {
2165 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2166 });
2167 });
2168
2169 // POST /api/v1/flows/:id/proposals — propose an edit to an existing Flow.
2170 app.post('/api/v1/flows/:id/proposals', FLOW_AUTHORING_WRITE_ROLES, (req, res) => {
2171 const flowId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
2172 runFlowPropose(req, res, 'edit', { flowId }).catch((e) => {
2173 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2174 });
2175 });
2176
2177 // POST /api/v1/flows/import — import a portable bundle through the same path.
2178 app.post('/api/v1/flows/import', FLOW_AUTHORING_WRITE_ROLES, (req, res) => {
2179 runFlowPropose(req, res, 'import').catch((e) => {
2180 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2181 });
2182 });
2183
2184 // Flow capture flywheel (Phase 7A-L4b) — detection + capture writes independently gated.
2185 const FLOW_CAPTURE_WRITE_ROLES = requireRole('viewer', 'editor', 'admin', 'evaluator');
2186
2187 app.post('/api/v1/flows/capture/observe', FLOW_CAPTURE_WRITE_ROLES, (req, res) => {
2188 const body = req.body && typeof req.body === 'object' ? req.body : {};
2189 const result = handleFlowCaptureObserveRequest({
2190 dataDir: config.data_dir,
2191 vaultId: req.vault_id ?? 'default',
2192 userId: req.user?.sub ?? '',
2193 role: effectiveRole(req),
2194 sessionMeta: body,
2195 includeLowConfidence: body.include_low_confidence === true,
2196 harness: body.harness,
2197 config,
2198 });
2199 if (!result.ok) {
2200 return res.status(result.status).json({ error: result.error, code: result.code });
2201 }
2202 return res.json(result.payload);
2203 });
2204
2205 app.get('/api/v1/flows/candidates', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
2206 const limitRaw = req.query.limit != null ? parseInt(String(req.query.limit), 10) : undefined;
2207 const result = handleFlowCaptureListRequest({
2208 dataDir: config.data_dir,
2209 vaultId: req.vault_id ?? 'default',
2210 userId: req.user?.sub ?? '',
2211 role: effectiveRole(req),
2212 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
2213 includeLowConfidence: req.query.include_low_confidence === 'true',
2214 limit: Number.isFinite(limitRaw) ? limitRaw : undefined,
2215 config,
2216 });
2217 if (!result.ok) {
2218 return res.status(result.status).json({ error: result.error, code: result.code });
2219 }
2220 return res.json(result.payload);
2221 });
2222
2223 app.post('/api/v1/flows/candidates/:candidate_id/propose', FLOW_CAPTURE_WRITE_ROLES, (req, res) => {
2224 const candidateId =
2225 typeof req.params.candidate_id === 'string' ? decodeURIComponent(req.params.candidate_id).trim() : '';
2226 const body = req.body && typeof req.body === 'object' ? req.body : {};
2227 handleFlowCaptureProposeRequest({
2228 dataDir: config.data_dir,
2229 vaultId: req.vault_id ?? 'default',
2230 userId: req.user?.sub ?? '',
2231 role: effectiveRole(req),
2232 candidateId,
2233 confirmedScope: body.confirmed_scope,
2234 scopeWidenAcknowledged: body.scope_widen_acknowledged === true,
2235 allowLowConfidence: body.allow_low_confidence === true,
2236 forceNewFlow: body.force_new_flow === true,
2237 mergeIntoFlowId: body.merge_into_flow_id,
2238 intent: body.intent,
2239 createProposal,
2240 config,
2241 })
2242 .then((result) => {
2243 if (!result.ok) {
2244 const payload = { error: result.error, code: result.code };
2245 if (result.merge_into_flow_id) payload.merge_into_flow_id = result.merge_into_flow_id;
2246 if (result.overlap != null) payload.overlap = result.overlap;
2247 return res.status(result.status).json(payload);
2248 }
2249 appendAudit(config.data_dir, {
2250 userId: req.user?.sub ?? 'unknown',
2251 action: 'flow_capture_propose',
2252 proposalId: result.payload.proposal_id,
2253 detail: { candidate_id: candidateId },
2254 });
2255 return res.status(201).json(result.payload);
2256 })
2257 .catch((e) => {
2258 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2259 });
2260 });
2261
2262 app.post('/api/v1/flows/candidates/:candidate_id/dismiss', FLOW_CAPTURE_WRITE_ROLES, (req, res) => {
2263 const candidateId =
2264 typeof req.params.candidate_id === 'string' ? decodeURIComponent(req.params.candidate_id).trim() : '';
2265 const body = req.body && typeof req.body === 'object' ? req.body : {};
2266 handleFlowCaptureDismissRequest({
2267 dataDir: config.data_dir,
2268 vaultId: req.vault_id ?? 'default',
2269 userId: req.user?.sub ?? '',
2270 role: effectiveRole(req),
2271 candidateId,
2272 intent: body.intent,
2273 createProposal,
2274 })
2275 .then((result) => {
2276 if (!result.ok) {
2277 return res.status(result.status).json({ error: result.error, code: result.code });
2278 }
2279 appendAudit(config.data_dir, {
2280 userId: req.user?.sub ?? 'unknown',
2281 action: 'flow_capture_dismiss',
2282 proposalId: result.payload.proposal_id,
2283 detail: { candidate_id: candidateId },
2284 });
2285 return res.status(201).json(result.payload);
2286 })
2287 .catch((e) => {
2288 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2289 });
2290 });
2291
2292 // External-agent grants (Phase 7A-L2b) — gated by FLOW_EXTERNAL_AGENT_ENABLED (default off).
2293 app.post(
2294 '/api/v1/flows/:id/external-grants',
2295 requireRole('viewer', 'editor', 'admin', 'evaluator'),
2296 (req, res) => {
2297 const flowId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
2298 const body = req.body && typeof req.body === 'object' ? req.body : {};
2299 const result = handleFlowExternalGrantMintRequest({
2300 dataDir: config.data_dir,
2301 vaultId: req.vault_id ?? 'default',
2302 userId: req.user?.sub ?? '',
2303 role: effectiveRole(req),
2304 flowId,
2305 flowVersion: body.flow_version,
2306 requestedTools: body.requested_tools,
2307 ttlSeconds: body.ttl_seconds,
2308 actorLabel: body.actor_label,
2309 });
2310 if (!result.ok) {
2311 return res.status(result.status).json({ error: result.error, code: result.code });
2312 }
2313 return res.status(201).json(result.payload);
2314 },
2315 );
2316
2317 app.get('/api/v1/flows/external-grants', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
2318 const flowId = typeof req.query.flow_id === 'string' ? req.query.flow_id : undefined;
2319 const result = handleFlowExternalGrantListRequest({
2320 dataDir: config.data_dir,
2321 vaultId: req.vault_id ?? 'default',
2322 flowId,
2323 });
2324 if (!result.ok) {
2325 return res.status(result.status).json({ error: result.error, code: result.code });
2326 }
2327 return res.json(result.payload);
2328 });
2329
2330 app.delete(
2331 '/api/v1/flows/external-grants/:grant_id',
2332 requireRole('viewer', 'editor', 'admin', 'evaluator'),
2333 (req, res) => {
2334 const grantId =
2335 typeof req.params.grant_id === 'string' ? decodeURIComponent(req.params.grant_id).trim() : '';
2336 const result = handleFlowExternalGrantRevokeRequest({
2337 dataDir: config.data_dir,
2338 vaultId: req.vault_id ?? 'default',
2339 grantId,
2340 });
2341 if (!result.ok) {
2342 return res.status(result.status).json({ error: result.error, code: result.code });
2343 }
2344 return res.json(result.payload);
2345 },
2346 );
2347
2348 app.post(
2349 '/api/v1/flows/external-tools/:tool_id/invoke',
2350 requireRole('viewer', 'editor', 'admin', 'evaluator'),
2351 (req, res) => {
2352 const toolId =
2353 typeof req.params.tool_id === 'string' ? decodeURIComponent(req.params.tool_id).trim() : '';
2354 const body = req.body && typeof req.body === 'object' ? req.body : {};
2355 const bearer =
2356 typeof req.headers['x-flow-external-bearer'] === 'string'
2357 ? req.headers['x-flow-external-bearer']
2358 : body.bearer;
2359 const result = handleFlowExternalToolInvokeRequest({
2360 dataDir: config.data_dir,
2361 vaultId: req.vault_id ?? 'default',
2362 toolId,
2363 bearer,
2364 flowId: body.flow_id,
2365 flowVersion: body.flow_version,
2366 });
2367 if (!result.ok) {
2368 return res.status(result.status).json({ error: result.error, code: result.code });
2369 }
2370 return res.json(result.payload);
2371 },
2372 );
2373
2374 // Agent delegation (Phase 7C-6) — gated by DELEGATION_ENABLED (default off).
2375 app.post('/api/v1/agents/identities', requireRole('viewer', 'editor', 'admin', 'evaluator'), async (req, res) => {
2376 const body = req.body && typeof req.body === 'object' ? req.body : {};
2377 const result = await handleAgentIdentityRegisterProposeRequest({
2378 dataDir: config.data_dir,
2379 vaultId: req.vault_id ?? 'default',
2380 userId: req.user?.sub ?? '',
2381 kind: body.kind,
2382 agentId: body.agent_id,
2383 label: body.label,
2384 scopeCeiling: body.scope_ceiling,
2385 createProposal,
2386 });
2387 if (!result.ok) {
2388 return res.status(result.status).json({ error: result.error, code: result.code });
2389 }
2390 return res.status(201).json(result.payload);
2391 });
2392
2393 app.get('/api/v1/agents/identities', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
2394 const result = handleAgentIdentityListRequest({
2395 dataDir: config.data_dir,
2396 vaultId: req.vault_id ?? 'default',
2397 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
2398 status: typeof req.query.status === 'string' ? req.query.status : undefined,
2399 });
2400 if (!result.ok) {
2401 return res.status(result.status).json({ error: result.error, code: result.code });
2402 }
2403 return res.json(result.payload);
2404 });
2405
2406 app.post('/api/v1/delegation/consents', requireRole('viewer', 'editor', 'admin', 'evaluator'), async (req, res) => {
2407 const body = req.body && typeof req.body === 'object' ? req.body : {};
2408 const result = await handleDelegationConsentProposeRequest({
2409 dataDir: config.data_dir,
2410 vaultId: req.vault_id ?? 'default',
2411 userId: req.user?.sub ?? '',
2412 delegateAgentId: body.delegate_agent_id,
2413 scope: body.scope,
2414 workspaceId: body.workspace_id,
2415 allowedFlowIds: body.allowed_flow_ids,
2416 allowedTaskKinds: body.allowed_task_kinds,
2417 allowedTaskIds: body.allowed_task_ids,
2418 expiresAt: body.expires_at,
2419 createProposal,
2420 });
2421 if (!result.ok) {
2422 return res.status(result.status).json({ error: result.error, code: result.code });
2423 }
2424 return res.status(201).json(result.payload);
2425 });
2426
2427 app.delete(
2428 '/api/v1/delegation/consents/:consent_id',
2429 requireRole('viewer', 'editor', 'admin', 'evaluator'),
2430 (req, res) => {
2431 const consentId =
2432 typeof req.params.consent_id === 'string' ? decodeURIComponent(req.params.consent_id).trim() : '';
2433 const result = handleDelegationConsentRevokeRequest({
2434 dataDir: config.data_dir,
2435 vaultId: req.vault_id ?? 'default',
2436 consentId,
2437 userId: req.user?.sub ?? '',
2438 });
2439 if (!result.ok) {
2440 return res.status(result.status).json({ error: result.error, code: result.code });
2441 }
2442 return res.json(result.payload);
2443 },
2444 );
2445
2446 // SEC-KN-5 / P13: mint issues runtime bearer authority — admin only (not viewer/editor/evaluator).
2447 app.post('/api/v1/delegation/grants', requireRole('admin'), (req, res) => {
2448 const body = req.body && typeof req.body === 'object' ? req.body : {};
2449 const result = handleDelegationGrantMintRequest({
2450 dataDir: config.data_dir,
2451 vaultId: req.vault_id ?? 'default',
2452 consentId: body.consent_id,
2453 actorAgentId: body.actor_agent_id,
2454 taskRef: body.task_ref,
2455 runRef: body.run_ref,
2456 flowId: body.flow_id,
2457 flowVersion: body.flow_version,
2458 ttlSeconds: body.ttl_seconds,
2459 });
2460 if (!result.ok) {
2461 return res.status(result.status).json({ error: result.error, code: result.code });
2462 }
2463 return res.status(201).json(result.payload);
2464 });
2465
2466 app.get('/api/v1/delegation/grants', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
2467 const result = handleDelegationGrantListRequest({
2468 dataDir: config.data_dir,
2469 vaultId: req.vault_id ?? 'default',
2470 actorAgentId: typeof req.query.actor_agent_id === 'string' ? req.query.actor_agent_id : undefined,
2471 });
2472 if (!result.ok) {
2473 return res.status(result.status).json({ error: result.error, code: result.code });
2474 }
2475 return res.json(result.payload);
2476 });
2477
2478 app.delete(
2479 '/api/v1/delegation/grants/:grant_id',
2480 requireRole('viewer', 'editor', 'admin', 'evaluator'),
2481 (req, res) => {
2482 const grantId =
2483 typeof req.params.grant_id === 'string' ? decodeURIComponent(req.params.grant_id).trim() : '';
2484 const result = handleDelegationGrantRevokeRequest({
2485 dataDir: config.data_dir,
2486 vaultId: req.vault_id ?? 'default',
2487 grantId,
2488 });
2489 if (!result.ok) {
2490 return res.status(result.status).json({ error: result.error, code: result.code });
2491 }
2492 return res.json(result.payload);
2493 },
2494 );
2495
2496 app.post('/api/v1/delegation/audit', requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
2497 const body = req.body && typeof req.body === 'object' ? req.body : {};
2498 const principalRef =
2499 typeof body.principal_ref === 'string' && body.principal_ref.trim()
2500 ? body.principal_ref.trim()
2501 : hashPrincipalRef(req.user?.sub ?? '');
2502 const result = handleDelegationAuditAppendRequest({
2503 dataDir: config.data_dir,
2504 vaultId: req.vault_id ?? 'default',
2505 grantId: body.grant_id,
2506 actorAgentId: body.actor_agent_id,
2507 principalRef,
2508 action: body.action,
2509 evidenceRefs: body.evidence_refs,
2510 taskRef: body.task_ref,
2511 runRef: body.run_ref,
2512 flowId: body.flow_id,
2513 flowVersion: body.flow_version,
2514 stepId: body.step_id,
2515 executionLocation: body.execution_location,
2516 });
2517 if (!result.ok) {
2518 return res.status(result.status).json({ error: result.error, code: result.code });
2519 }
2520 return res.status(201).json(result.payload);
2521 });
2522
2523 // Flow execution gate (Phase 7A-L3b) — gated by FLOW_RUN_WRITES_ENABLED / FLOW_AUTOMATABLE_EXECUTION_ENABLED.
2524 const FLOW_RUN_WRITE_ROLES = requireRole('viewer', 'editor', 'admin', 'evaluator');
2525
2526 app.get('/api/v1/flow-runs/:run_id', FLOW_RUN_WRITE_ROLES, (req, res) => {
2527 const runId = typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
2528 const result = handleFlowRunGetRequest({
2529 dataDir: config.data_dir,
2530 vaultId: req.vault_id ?? 'default',
2531 userId: req.user?.sub ?? '',
2532 role: effectiveRole(req),
2533 runId,
2534 });
2535 if (!result.ok) {
2536 return res.status(result.status).json({ error: result.error, code: result.code });
2537 }
2538 return res.json(result.payload);
2539 });
2540
2541 app.get('/api/v1/flows/:id/runs', FLOW_RUN_WRITE_ROLES, (req, res) => {
2542 const flowId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
2543 const result = handleFlowRunListRequest({
2544 dataDir: config.data_dir,
2545 vaultId: req.vault_id ?? 'default',
2546 userId: req.user?.sub ?? '',
2547 role: effectiveRole(req),
2548 flowId,
2549 });
2550 if (!result.ok) {
2551 return res.status(result.status).json({ error: result.error, code: result.code });
2552 }
2553 return res.json(result.payload);
2554 });
2555
2556 app.get('/api/v1/flows/:id/runs/:run_id', FLOW_RUN_WRITE_ROLES, (req, res) => {
2557 const runId = typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
2558 const result = handleFlowRunGetRequest({
2559 dataDir: config.data_dir,
2560 vaultId: req.vault_id ?? 'default',
2561 userId: req.user?.sub ?? '',
2562 role: effectiveRole(req),
2563 runId,
2564 });
2565 if (!result.ok) {
2566 return res.status(result.status).json({ error: result.error, code: result.code });
2567 }
2568 return res.json(result.payload);
2569 });
2570
2571 app.post('/api/v1/flows/:id/runs', FLOW_RUN_WRITE_ROLES, (req, res) => {
2572 const flowId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
2573 const body = req.body && typeof req.body === 'object' ? req.body : {};
2574 const result = handleFlowRunStartRequest({
2575 dataDir: config.data_dir,
2576 vaultId: req.vault_id ?? 'default',
2577 userId: req.user?.sub ?? '',
2578 role: effectiveRole(req),
2579 flowId,
2580 flowVersion: body.flow_version,
2581 taskRef: body.task_ref,
2582 externalRef: body.external_ref,
2583 harness: 'hub',
2584 });
2585 if (!result.ok) {
2586 return res.status(result.status).json({ error: result.error, code: result.code });
2587 }
2588 return res.status(201).json(result.payload);
2589 });
2590
2591 app.post('/api/v1/flows/:id/runs/:run_id/advance', FLOW_RUN_WRITE_ROLES, (req, res) => {
2592 const runId = typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
2593 const body = req.body && typeof req.body === 'object' ? req.body : {};
2594 const result = handleFlowRunAdvanceRequest({
2595 dataDir: config.data_dir,
2596 vaultId: req.vault_id ?? 'default',
2597 userId: req.user?.sub ?? '',
2598 role: effectiveRole(req),
2599 runId,
2600 stepId: body.step_id,
2601 toStatus: body.to_status,
2602 skipReason: body.skip_reason,
2603 });
2604 if (!result.ok) {
2605 return res.status(result.status).json({ error: result.error, code: result.code });
2606 }
2607 return res.json(result.payload);
2608 });
2609
2610 app.post('/api/v1/flows/:id/runs/:run_id/evidence', FLOW_RUN_WRITE_ROLES, (req, res) => {
2611 const runId = typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
2612 const body = req.body && typeof req.body === 'object' ? req.body : {};
2613 const result = handleFlowRunEvidenceRequest({
2614 dataDir: config.data_dir,
2615 vaultId: req.vault_id ?? 'default',
2616 userId: req.user?.sub ?? '',
2617 role: effectiveRole(req),
2618 runId,
2619 stepId: body.step_id,
2620 evidenceRef: body.evidence_ref,
2621 pointerKind: body.pointer_kind,
2622 });
2623 if (!result.ok) {
2624 return res.status(result.status).json({ error: result.error, code: result.code });
2625 }
2626 return res.json(result.payload);
2627 });
2628
2629 app.post('/api/v1/flows/:id/runs/:run_id/execute-automatable', FLOW_RUN_WRITE_ROLES, (req, res) => {
2630 const runId = typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
2631 const body = req.body && typeof req.body === 'object' ? req.body : {};
2632 const result = handleFlowRunExecuteAutomatableRequest({
2633 dataDir: config.data_dir,
2634 vaultId: req.vault_id ?? 'default',
2635 userId: req.user?.sub ?? '',
2636 role: effectiveRole(req),
2637 runId,
2638 stepId: body.step_id,
2639 consentId: body.consent_id,
2640 modelLane: body.model_lane,
2641 dryRun: body.dry_run,
2642 });
2643 if (!result.ok) {
2644 return res.status(result.status).json({ error: result.error, code: result.code });
2645 }
2646 return res.json(result.payload);
2647 });
2648
2649 app.post('/api/v1/flows/:id/runs/:run_id/submit-review', FLOW_RUN_WRITE_ROLES, async (req, res) => {
2650 const runId = typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
2651 const body = req.body && typeof req.body === 'object' ? req.body : {};
2652 const result = await handleFlowRunSubmitReviewRequest({
2653 dataDir: config.data_dir,
2654 vaultId: req.vault_id ?? 'default',
2655 userId: req.user?.sub ?? '',
2656 role: effectiveRole(req),
2657 runId,
2658 intent: body.intent,
2659 createProposal,
2660 });
2661 if (!result.ok) {
2662 return res.status(result.status).json({ error: result.error, code: result.code });
2663 }
2664 return res.json(result.payload);
2665 });
2666
2667 app.post('/api/v1/flows/:id/runs/:run_id/consent', FLOW_RUN_WRITE_ROLES, (req, res) => {
2668 const runId = typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
2669 const body = req.body && typeof req.body === 'object' ? req.body : {};
2670 const result = handleFlowExecutionConsentMintRequest({
2671 dataDir: config.data_dir,
2672 vaultId: req.vault_id ?? 'default',
2673 userId: req.user?.sub ?? '',
2674 role: effectiveRole(req),
2675 runId,
2676 allowedLanes: body.allowed_lanes,
2677 costCapUnits: body.cost_cap_units,
2678 ttlSeconds: body.ttl_seconds,
2679 });
2680 if (!result.ok) {
2681 return res.status(result.status).json({ error: result.error, code: result.code });
2682 }
2683 return res.status(201).json(result.payload);
2684 });
2685
2686 /**
2687 * Fire-and-forget memory event capture after successful API responses.
2688 * Never throws, never delays the response — runs in a detached async chain.
2689 * @param {string} type - MEMORY_EVENT_TYPES value
2690 * @param {object} data - event payload
2691 * @param {object} cfg - server config (for resolveMemoryDir)
2692 * @param {string} vaultId
2693 */
2694 function fireCaptureEvent(type, data, cfg, vaultId) {
2695 (async () => {
2696 try {
2697 const { createMemoryManager } = await import('../lib/memory.mjs');
2698 const mm = createMemoryManager(cfg, vaultId || 'default');
2699 if (mm.shouldCapture(type)) mm.store(type, data);
2700 } catch (_) {}
2701 })();
2702 }
2703
2704 // GET /api/v1/notes/facets — filter dropdown values (before /:path to avoid collision)
2705 app.get('/api/v1/notes/facets', (req, res) => {
2706 try {
2707 const vid = req.vault_id ?? 'default';
2708 const cached = facetsCacheByVault[vid];
2709 if (cached?.data && Date.now() - cached.ts < FACETS_TTL_MS) {
2710 return res.json(cached.data);
2711 }
2712 const vaultConfig = { ...config, vault_path: req.vaultPath };
2713 let facets = runFacets(vaultConfig);
2714 if (req.scope?.projects?.length || req.scope?.folders?.length) {
2715 const notes = runListNotes(vaultConfig, { fields: 'path+metadata' });
2716 const filtered = applyScopeFilter(notes.notes || [], req.scope);
2717 const projects = new Set();
2718 const tags = new Set();
2719 const folders = new Set();
2720 for (const n of filtered) {
2721 if (n.project) projects.add(n.project);
2722 for (const t of n.tags || []) if (t) tags.add(t);
2723 const folder = n.path.includes('/') ? n.path.split('/').slice(0, -1).join('/') : '';
2724 if (folder) folders.add(folder);
2725 }
2726 facets = { projects: [...projects].sort(), tags: [...tags].sort(), folders: [...folders].sort() };
2727 }
2728 facetsCacheByVault[vid] = { data: facets, ts: Date.now() };
2729 res.json(facets);
2730 } catch (e) {
2731 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2732 }
2733 });
2734
2735 // GET /api/v1/notes — list notes
2736 app.get('/api/v1/notes', parseQueryBounds, (req, res) => {
2737 try {
2738 const limit = req.query.limit != null ? Math.min(100, Math.max(0, parseInt(req.query.limit, 10) || 20)) : 20;
2739 const offset = req.query.offset != null ? Math.max(0, parseInt(req.query.offset, 10) || 0) : 0;
2740 const opts = {
2741 folder: req.query.folder,
2742 project: req.query.project,
2743 tag: req.query.tag,
2744 since: req.query.since,
2745 until: req.query.until,
2746 chain: req.query.chain,
2747 entity: req.query.entity,
2748 episode: req.query.episode,
2749 limit,
2750 offset,
2751 order: req.query.order,
2752 fields: req.query.fields || 'path+metadata',
2753 countOnly: req.query.count_only === 'true',
2754 content_scope: req.query.content_scope,
2755 content_class: req.query.content_class,
2756 };
2757 const vaultConfig = { ...config, vault_path: req.vaultPath };
2758 const out = (req.scope?.projects?.length || req.scope?.folders?.length)
2759 ? (() => {
2760 const full = runListNotes(vaultConfig, { ...opts, limit: 10000, offset: 0 });
2761 const filtered = applyScopeFilter(full.notes || [], req.scope);
2762 return { notes: filtered.slice(offset, offset + limit), total: filtered.length };
2763 })()
2764 : runListNotes(vaultConfig, opts);
2765 res.json(out);
2766 } catch (e) {
2767 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2768 }
2769 });
2770
2771 // GET /api/v1/notes/:path — get one note (path may contain slashes)
2772 app.get(/^\/api\/v1\/notes\/(.+)$/, (req, res) => {
2773 const notePath = req.path.replace(/^\/api\/v1\/notes\//, '');
2774 if (!notePath) return res.status(400).json({ error: 'Path required', code: 'BAD_REQUEST' });
2775 try {
2776 const note = readNote(req.vaultPath, decodeURIComponent(notePath));
2777 res.json({ path: note.path, frontmatter: note.frontmatter, body: note.body });
2778 } catch (e) {
2779 if (e.message && e.message.includes('not found')) return res.status(404).json({ error: e.message, code: 'NOT_FOUND' });
2780 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2781 }
2782 });
2783
2784 // POST /api/v1/search — semantic (default) or keyword
2785 app.post('/api/v1/search', async (req, res) => {
2786 const query = req.body?.query;
2787 if (!query || typeof query !== 'string') {
2788 return res.status(400).json({ error: 'query required', code: 'BAD_REQUEST' });
2789 }
2790 const rawLimit = req.body?.limit;
2791 const limit = rawLimit != null ? Math.min(100, Math.max(0, parseInt(rawLimit, 10) || 20)) : 20;
2792 const mode = req.body?.mode === 'keyword' ? 'keyword' : 'semantic';
2793 try {
2794 const opts = {
2795 folder: req.body.folder,
2796 project: req.body.project,
2797 tag: req.body.tag,
2798 since: req.body.since,
2799 until: req.body.until,
2800 order: req.body.order,
2801 fields: req.body.fields,
2802 vault_id: req.vault_id,
2803 content_scope: req.body.content_scope,
2804 chain: req.body.chain,
2805 entity: req.body.entity,
2806 episode: req.body.episode,
2807 };
2808 const vaultConfig = { ...config, vault_path: req.vaultPath };
2809 let out;
2810 if (mode === 'keyword') {
2811 const kwLimit = Math.max(1, Math.min(100, limit || 20));
2812 const kwOpts = {
2813 ...opts,
2814 limit: kwLimit,
2815 snippetChars: req.body.snippetChars != null ? parseInt(req.body.snippetChars, 10) || 300 : undefined,
2816 countOnly: req.body.count_only === true || req.body.countOnly === true,
2817 match: req.body.match === 'all_terms' ? 'all_terms' : 'phrase',
2818 };
2819 out = await runKeywordSearch(query, kwOpts, vaultConfig);
2820 } else {
2821 out = { ...(await runSearch(query, { ...opts, limit }, vaultConfig)), mode: 'semantic' };
2822 }
2823 if (out.results && req.vaultPath) {
2824 out = {
2825 ...out,
2826 results: out.results.filter((r) => r && noteFileExistsInVault(req.vaultPath, r.path)),
2827 };
2828 }
2829 if ((req.scope?.projects?.length || req.scope?.folders?.length) && out.results) {
2830 out = { ...out, results: applyScopeFilter(out.results, req.scope) };
2831 }
2832 res.json(out);
2833 fireCaptureEvent('search', { query, mode, result_count: out.results?.length ?? 0 }, config, req.vault_id || 'default');
2834 } catch (e) {
2835 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2836 }
2837 });
2838
2839 // POST /api/v1/notes — write note (Phase 13: editor or admin)
2840 app.post('/api/v1/notes', requireRole('editor', 'admin'), (req, res) => {
2841 const { path: notePath, body, frontmatter, append } = req.body || {};
2842 if (!notePath || typeof notePath !== 'string') {
2843 return res.status(400).json({ error: 'path required', code: 'BAD_REQUEST' });
2844 }
2845 try {
2846 const fm = mergeProvenanceFrontmatter(frontmatter, {
2847 sub: req.user?.sub ?? null,
2848 kind: 'human',
2849 });
2850 const out = writeNote(req.vaultPath, notePath, { body, frontmatter: fm, append });
2851 invalidateFacetsCache();
2852 maybeAutoSync({ ...config, vault_path: req.vaultPath });
2853 res.json(out);
2854 fireCaptureEvent('write', { path: notePath, action: append ? 'append' : 'write' }, config, req.vault_id || 'default');
2855 } catch (e) {
2856 if (e.message && e.message.includes('Invalid path')) return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
2857 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2858 }
2859 });
2860
2861 // DELETE /api/v1/notes/:path — delete note (editor or admin)
2862 app.delete(/^\/api\/v1\/notes\/(.+)$/, requireRole('editor', 'admin'), (req, res) => {
2863 const notePath = req.path.replace(/^\/api\/v1\/notes\//, '');
2864 if (!notePath) return res.status(400).json({ error: 'Path required', code: 'BAD_REQUEST' });
2865 try {
2866 const out = deleteNote(req.vaultPath, decodeURIComponent(notePath));
2867 invalidateFacetsCache();
2868 maybeAutoSync({ ...config, vault_path: req.vaultPath });
2869 res.json(out);
2870 } catch (e) {
2871 if (e.message && e.message.includes('not found')) {
2872 return res.status(404).json({ error: e.message, code: 'NOT_FOUND' });
2873 }
2874 if (e.message && e.message.includes('Invalid path')) return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
2875 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2876 }
2877 });
2878
2879 // POST /api/v1/notes/delete-by-prefix — bulk delete notes under a vault-relative prefix (editor/admin; "delete project")
2880 app.post('/api/v1/notes/delete-by-prefix', requireRole('editor', 'admin'), (req, res) => {
2881 const raw = req.body && req.body.path_prefix != null ? String(req.body.path_prefix) : '';
2882 try {
2883 const { deleted, paths } = deleteNotesByPrefix(req.vaultPath, raw, { ignore: config.ignore || [] });
2884 const proposals_discarded = discardProposalsUnderPathPrefix(config.data_dir, {
2885 vault_id: req.vault_id ?? 'default',
2886 path_prefix: raw,
2887 });
2888 invalidateFacetsCache();
2889 maybeAutoSync({ ...config, vault_path: req.vaultPath });
2890 res.json({ deleted, paths, proposals_discarded });
2891 } catch (e) {
2892 if (
2893 e.message &&
2894 (e.message.includes('path_prefix') || e.message.includes('Invalid path_prefix') || e.message.includes('Invalid path'))
2895 ) {
2896 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
2897 }
2898 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2899 }
2900 });
2901
2902 // POST /api/v1/notes/delete-by-project — bulk delete by list-notes project filter (self-hosted Node; see docs/HUB-METADATA-BULK-OPS.md)
2903 app.post('/api/v1/notes/delete-by-project', requireRole('editor', 'admin'), (req, res) => {
2904 const raw = req.body && req.body.project != null ? String(req.body.project) : '';
2905 try {
2906 const { deleted, paths } = deleteNotesByProjectSlug(req.vaultPath, raw, { ignore: config.ignore || [] });
2907 const proposals_discarded = discardProposalsAtPaths(config.data_dir, {
2908 vault_id: req.vault_id ?? 'default',
2909 paths,
2910 });
2911 invalidateFacetsCache();
2912 maybeAutoSync({ ...config, vault_path: req.vaultPath });
2913 res.json({ deleted, paths, proposals_discarded });
2914 } catch (e) {
2915 if (e.message && (e.message.includes('project slug required') || e.message.includes('Invalid path'))) {
2916 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
2917 }
2918 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2919 }
2920 });
2921
2922 // POST /api/v1/notes/rename-project — rewrite frontmatter project slug (self-hosted Node; see docs/HUB-METADATA-BULK-OPS.md)
2923 app.post('/api/v1/notes/rename-project', requireRole('editor', 'admin'), (req, res) => {
2924 const from = req.body && req.body.from != null ? String(req.body.from) : '';
2925 const to = req.body && req.body.to != null ? String(req.body.to) : '';
2926 try {
2927 const { updated, paths } = renameProjectSlugInVault(req.vaultPath, from, to, { ignore: config.ignore || [] });
2928 invalidateFacetsCache();
2929 maybeAutoSync({ ...config, vault_path: req.vaultPath });
2930 res.json({ updated, paths });
2931 } catch (e) {
2932 if (
2933 e.message &&
2934 (e.message.includes('from and to project') || e.message.includes('Invalid path') || e.message.includes('escapes vault'))
2935 ) {
2936 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
2937 }
2938 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2939 }
2940 });
2941
2942 // POST /api/v1/index — re-run indexer (Phase 13: editor or admin; Phase 15: vault-scoped)
2943 app.post('/api/v1/index', jwtAuth, apiLimiter, requireVaultAccess, requireRole('editor', 'admin'), async (req, res) => {
2944 try {
2945 const { runIndex } = await import('../lib/indexer.mjs');
2946 const result = await runIndex({ log: () => {}, vaultId: req.vault_id, vaultPath: req.vaultPath });
2947 invalidateFacetsCache();
2948 res.json({ ok: true, notesProcessed: result.notesProcessed, chunksIndexed: result.chunksIndexed });
2949 fireCaptureEvent('index', { note_count: result.notesProcessed, chunk_count: result.chunksIndexed }, config, req.vault_id || 'default');
2950 } catch (e) {
2951 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
2952 }
2953 });
2954
2955 // POST /api/v1/export — export one note to content (any vault reader). Returns { content, filename } for client download.
2956 app.post(
2957 '/api/v1/export',
2958 jwtAuth,
2959 apiLimiter,
2960 requireVaultAccess,
2961 requireRole('viewer', 'editor', 'admin', 'evaluator'),
2962 (req, res) => {
2963 const { path: notePath, format } = req.body || {};
2964 if (!notePath || typeof notePath !== 'string') {
2965 return res.status(400).json({ error: 'path required', code: 'BAD_REQUEST' });
2966 }
2967 const fmt = format === 'html' ? 'html' : 'md';
2968 try {
2969 resolveVaultRelativePath(req.vaultPath, notePath);
2970 const { content, filename } = exportNoteToContent(req.vaultPath, notePath, { format: fmt });
2971 res.json({ content, filename });
2972 } catch (e) {
2973 if (e.message && e.message.includes('Invalid path')) return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
2974 res.status(404).json({ error: e.message || 'Note not found', code: 'NOT_FOUND' });
2975 }
2976 },
2977 );
2978
2979 // POST /api/v1/notes/copy — copy or move one note between vaults (editor/admin; multi-vault). Overwrites target path if it exists.
2980 app.post('/api/v1/notes/copy', requireRole('editor', 'admin'), (req, res) => {
2981 const body = req.body || {};
2982 const fromVault = typeof body.from_vault_id === 'string' ? body.from_vault_id.replace(/\\/g, '/').trim() : '';
2983 const toVault = typeof body.to_vault_id === 'string' ? body.to_vault_id.replace(/\\/g, '/').trim() : '';
2984 const rawPath = typeof body.path === 'string' ? body.path.replace(/\\/g, '/').trim() : '';
2985 const deleteSource = body.delete_source === true;
2986 if (!fromVault || !toVault || !rawPath || rawPath.includes('..') || rawPath.startsWith('/')) {
2987 return res.status(400).json({
2988 error: 'from_vault_id, to_vault_id, and path are required (vault-relative path)',
2989 code: 'BAD_REQUEST',
2990 });
2991 }
2992 if (fromVault === toVault) {
2993 return res.status(400).json({ error: 'from_vault_id and to_vault_id must differ', code: 'BAD_REQUEST' });
2994 }
2995 const allowed = getAllowedVaultIds(config.data_dir, req.user?.sub ?? '');
2996 if (!allowed.includes(fromVault) || !allowed.includes(toVault)) {
2997 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2998 }
2999 const fromPath = config.resolveVaultPath(fromVault);
3000 const toPath = config.resolveVaultPath(toVault);
3001 if (!fromPath || !toPath) {
3002 return res.status(404).json({ error: 'Vault not found.', code: 'NOT_FOUND' });
3003 }
3004 try {
3005 resolveVaultRelativePath(fromPath, rawPath);
3006 const note = readNote(fromPath, rawPath);
3007 const scopeFrom = getScopeForUserVault(config.data_dir, req.user?.sub ?? '', fromVault);
3008 if (scopeFrom && (scopeFrom.projects?.length || scopeFrom.folders?.length)) {
3009 const withProj = {
3010 path: note.path,
3011 project: materializeListFrontmatter(note.frontmatter).project ?? null,
3012 };
3013 const filtered = applyScopeFilter([withProj], scopeFrom);
3014 if (filtered.length === 0) {
3015 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
3016 }
3017 }
3018 const sub = req.user?.sub ?? '';
3019 const baseFm =
3020 typeof note.frontmatter === 'object' && note.frontmatter && !Array.isArray(note.frontmatter)
3021 ? { ...note.frontmatter }
3022 : {};
3023 const fm = mergeProvenanceFrontmatter(baseFm, { sub: sub || null, kind: 'human' });
3024 writeNote(toPath, note.path, { body: note.body, frontmatter: fm });
3025 invalidateFacetsCache();
3026 maybeAutoSync({ ...config, vault_path: toPath });
3027 fireCaptureEvent('write', { path: note.path, action: 'write' }, config, toVault);
3028 if (deleteSource) {
3029 try {
3030 deleteNote(fromPath, note.path);
3031 } catch (e) {
3032 return res.status(502).json({
3033 error: 'Note was copied to the target vault but deleting the source failed.',
3034 code: 'DELETE_FAILED',
3035 });
3036 }
3037 invalidateFacetsCache();
3038 maybeAutoSync({ ...config, vault_path: fromPath });
3039 fireCaptureEvent('write', { path: note.path, action: 'delete' }, config, fromVault);
3040 }
3041 res.json({
3042 ok: true,
3043 path: note.path,
3044 from_vault_id: fromVault,
3045 to_vault_id: toVault,
3046 moved: deleteSource,
3047 });
3048 } catch (e) {
3049 if (e.message && e.message.includes('not found')) {
3050 return res.status(404).json({ error: e.message, code: 'NOT_FOUND' });
3051 }
3052 if (e.message && e.message.includes('Invalid path')) {
3053 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
3054 }
3055 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3056 }
3057 });
3058
3059 // POST /api/v1/import — upload file (or zip) and run import (editor/admin). Multipart: source_type, file; optional project, output_dir, tags.
3060 const importTempDirMiddleware = (req, _res, next) => {
3061 req._importTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-import-'));
3062 next();
3063 };
3064 const importUpload = multer({
3065 storage: multer.diskStorage({
3066 destination: (req, _file, cb) => cb(null, req._importTempDir),
3067 filename: (req, file, cb) => cb(null, file.originalname || 'upload'),
3068 }),
3069 limits: { fileSize: 100 * 1024 * 1024 },
3070 }).single('file');
3071 app.post('/api/v1/import', jwtAuth, apiLimiter, requireVaultAccess, requireRole('editor', 'admin'), importTempDirMiddleware, importUpload, async (req, res) => {
3072 const tempDir = req._importTempDir;
3073 try {
3074 const sourceType = (req.body && req.body.source_type) ? String(req.body.source_type).trim() : '';
3075 if (!IMPORT_SOURCE_TYPES.includes(sourceType)) {
3076 return res.status(400).json({ error: `source_type must be one of: ${IMPORT_SOURCE_TYPES.join(', ')}`, code: 'BAD_REQUEST' });
3077 }
3078 const sheetId = req.body && req.body.spreadsheet_id ? String(req.body.spreadsheet_id).trim() : '';
3079 const sheetsRange = req.body && req.body.sheets_range ? String(req.body.sheets_range).trim() : undefined;
3080 if (sourceType === 'google-sheets') {
3081 if (!sheetId) {
3082 return res
3083 .status(400)
3084 .json({ error: 'google-sheets: spreadsheet_id is required in the multipart body', code: 'BAD_REQUEST' });
3085 }
3086 if (req.file) {
3087 return res
3088 .status(400)
3089 .json({ error: 'google-sheets: do not send a file; use spreadsheet_id only', code: 'BAD_REQUEST' });
3090 }
3091 } else if (!req.file) {
3092 return res.status(400).json({ error: 'file required', code: 'BAD_REQUEST' });
3093 }
3094 const project = req.body && req.body.project ? String(req.body.project).trim() : undefined;
3095 const outputDir = req.body && req.body.output_dir ? String(req.body.output_dir).trim() : undefined;
3096 const tagsRaw = req.body && req.body.tags ? String(req.body.tags) : '';
3097 const tags = tagsRaw ? tagsRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
3098 let inputPath = sourceType === 'google-sheets' ? sheetId : req.file.path;
3099 if (sourceType !== 'google-sheets' && req.file && req.file.originalname && req.file.originalname.toLowerCase().endsWith('.zip')) {
3100 const extractDir = path.join(tempDir, 'extracted');
3101 fs.mkdirSync(extractDir, { recursive: true });
3102 const zip = new AdmZip(req.file.path);
3103 // Zip-slip protection: every entry must resolve inside extractDir
3104 const extractDirResolved = path.resolve(extractDir) + path.sep;
3105 for (const entry of zip.getEntries()) {
3106 const entryResolved = path.resolve(extractDir, entry.entryName);
3107 if (entryResolved !== path.resolve(extractDir) && !entryResolved.startsWith(extractDirResolved)) {
3108 return res.status(400).json({ error: 'Invalid zip entry: path traversal detected', code: 'BAD_REQUEST' });
3109 }
3110 }
3111 zip.extractAllTo(extractDir, true);
3112 inputPath = extractDir;
3113 }
3114 const result = await runImport(sourceType, inputPath, {
3115 project,
3116 outputDir,
3117 tags,
3118 vaultPath: req.vaultPath,
3119 ...(sheetsRange ? { sheetsRange } : {}),
3120 });
3121 const importStamp = mergeProvenanceFrontmatter({}, {
3122 sub: req.user?.sub ?? null,
3123 kind: 'import',
3124 });
3125 for (const item of result.imported || []) {
3126 if (item.path && typeof item.path === 'string') {
3127 try {
3128 writeNote(req.vaultPath, item.path, { frontmatter: importStamp });
3129 } catch (e) {
3130 console.error('hub import provenance pass failed for', item.path, e.message || e);
3131 }
3132 }
3133 }
3134 invalidateFacetsCache();
3135 maybeAutoSync({ ...config, vault_path: req.vaultPath });
3136 res.json({ imported: result.imported, count: result.count });
3137 } catch (e) {
3138 const msg = e.message || String(e);
3139 const clientError =
3140 /OPENAI_API_KEY|required for transcription|Unsupported format|file not found|not found:|Transcription failed|413|Payload Too Large|25MB|Whisper accepts/i.test(
3141 msg
3142 );
3143 res.status(clientError ? 400 : 500).json({
3144 error: msg,
3145 code: clientError ? 'BAD_REQUEST' : 'RUNTIME_ERROR',
3146 });
3147 } finally {
3148 if (tempDir && fs.existsSync(tempDir)) {
3149 try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {}
3150 }
3151 }
3152 });
3153
3154 /**
3155 * Normalize `mode` for POST /api/v1/import-url body.
3156 * @param {unknown} raw
3157 * @returns {'auto' | 'bookmark' | 'extract'}
3158 */
3159 function normalizeImportUrlMode(raw) {
3160 const s = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
3161 if (s === 'bookmark' || s === 'extract' || s === 'auto') return s;
3162 return 'auto';
3163 }
3164
3165 /**
3166 * @param {unknown} body
3167 * @returns {string[]}
3168 */
3169 function tagsFromImportUrlBody(body) {
3170 const t = body && body.tags;
3171 if (Array.isArray(t)) return t.map((x) => String(x).trim()).filter(Boolean);
3172 if (typeof t === 'string') return t.split(',').map((s) => s.trim()).filter(Boolean);
3173 return [];
3174 }
3175
3176 // POST /api/v1/import-url — JSON { url, mode?, project?, output_dir?, tags? }; editor/admin.
3177 app.post(
3178 '/api/v1/import-url',
3179 jwtAuth,
3180 importUrlLimiter,
3181 requireVaultAccess,
3182 requireRole('editor', 'admin'),
3183 async (req, res) => {
3184 try {
3185 const body = req.body && typeof req.body === 'object' ? req.body : {};
3186 const urlStr = typeof body.url === 'string' ? body.url.trim() : '';
3187 if (!urlStr) return res.status(400).json({ error: 'url required', code: 'BAD_REQUEST' });
3188 const urlMode = normalizeImportUrlMode(body.mode);
3189 const project = body.project != null && String(body.project).trim() !== '' ? String(body.project).trim() : undefined;
3190 const outputDir =
3191 body.output_dir != null && String(body.output_dir).trim() !== '' ? String(body.output_dir).trim() : undefined;
3192 const tags = tagsFromImportUrlBody(body);
3193 const result = await runImport('url', urlStr, {
3194 project,
3195 outputDir,
3196 tags,
3197 urlMode,
3198 vaultPath: req.vaultPath,
3199 });
3200 const importStamp = mergeProvenanceFrontmatter({}, {
3201 sub: req.user?.sub ?? null,
3202 kind: 'import',
3203 });
3204 for (const item of result.imported || []) {
3205 if (item.path && typeof item.path === 'string') {
3206 try {
3207 writeNote(req.vaultPath, item.path, { frontmatter: importStamp });
3208 } catch (e) {
3209 console.error('hub import-url provenance pass failed for', item.path, e.message || e);
3210 }
3211 }
3212 }
3213 invalidateFacetsCache();
3214 maybeAutoSync({ ...config, vault_path: req.vaultPath });
3215 res.json({ imported: result.imported, count: result.count });
3216 } catch (e) {
3217 const msg = e.message || String(e);
3218 const clientError =
3219 /OPENAI_API_KEY|required for transcription|Only https|blocked|private IP|timed out|exceeds \d+ bytes|Invalid URL|URL is required|Extract mode requires|Could not extract|DNS resolution failed|Too many redirects|non-https/i.test(
3220 msg,
3221 );
3222 res.status(clientError ? 400 : 500).json({
3223 error: msg,
3224 code: clientError ? 'BAD_REQUEST' : 'RUNTIME_ERROR',
3225 });
3226 }
3227 },
3228 );
3229
3230 // Phase 18D: Upload image to GitHub backup repo, return raw URL for note embedding
3231 const imageUploadLimiter = rateLimit({
3232 windowMs: 15 * 60 * 1000,
3233 max: 10,
3234 message: { error: 'Too many image uploads. Try again later.', code: 'RATE_LIMIT' },
3235 });
3236 const imageUploadMiddleware = multer({
3237 storage: multer.memoryStorage(),
3238 limits: { fileSize: 25 * 1024 * 1024 },
3239 }).single('image');
3240
3241 app.post(
3242 /^\/api\/v1\/notes\/(.+)\/upload-image$/,
3243 jwtAuth,
3244 apiLimiter,
3245 imageUploadLimiter,
3246 requireVaultAccess,
3247 requireRole('editor', 'admin'),
3248 imageUploadMiddleware,
3249 async (req, res) => {
3250 try {
3251 if (!req.file) {
3252 return res.status(400).json({ error: 'image file is required (multipart field "image")', code: 'BAD_REQUEST' });
3253 }
3254
3255 const githubConn = readGitHubConnection(config.data_dir);
3256 if (!githubConn?.access_token) {
3257 return res.status(400).json({
3258 error: 'GitHub is not connected. Go to Settings → Backup → Connect GitHub first.',
3259 code: 'GITHUB_NOT_CONNECTED',
3260 });
3261 }
3262
3263 const remoteUrl = config.vault_git?.remote;
3264 if (!remoteUrl) {
3265 return res.status(400).json({
3266 error: 'No Git remote URL configured. Go to Settings → Backup and set a remote URL.',
3267 code: 'NO_GIT_REMOTE',
3268 });
3269 }
3270
3271 const originalName = req.file.originalname || 'image.png';
3272 let ext;
3273 try {
3274 ext = validateImageExtension(originalName);
3275 } catch (e) {
3276 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
3277 }
3278
3279 const contentType = req.file.mimetype || '';
3280 if (!contentType.startsWith('image/')) {
3281 return res.status(400).json({ error: `Invalid Content-Type: ${contentType}. Must be image/*`, code: 'BAD_REQUEST' });
3282 }
3283
3284 if (!validateMagicBytes(req.file.buffer, ext)) {
3285 return res.status(400).json({
3286 error: `File content does not match .${ext} format (magic bytes mismatch). The file may be corrupted or not a real image.`,
3287 code: 'BAD_REQUEST',
3288 });
3289 }
3290
3291 const now = new Date();
3292 const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
3293 const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
3294 const uniqueName = `${Date.now()}-${safeName}`;
3295 const repoFilePath = `media/images/${yearMonth}/${uniqueName}`;
3296
3297 const result = await commitImageToRepo({
3298 accessToken: githubConn.access_token,
3299 repoUrl: remoteUrl,
3300 filePath: repoFilePath,
3301 fileBuffer: req.file.buffer,
3302 commitMessage: `Add image: ${safeName}`,
3303 });
3304
3305 const insertedMarkdown = `![${safeName}](${result.url})`;
3306
3307 res.json({
3308 url: result.url,
3309 inserted_markdown: insertedMarkdown,
3310 sha: result.sha,
3311 repo_path: repoFilePath,
3312 repo_private: result.isPrivate === true,
3313 });
3314 } catch (e) {
3315 const msg = e.message || String(e);
3316 const clientErr = /not found|not connected|lacks permission|lacks repo|Reconnect|scope|remote/i.test(msg);
3317 res.status(clientErr ? 400 : 500).json({
3318 error: msg,
3319 code: clientErr ? 'BAD_REQUEST' : 'RUNTIME_ERROR',
3320 });
3321 }
3322 },
3323 );
3324
3325 app.get('/api/v1/vault/image-proxy-token', jwtAuth, (req, res) => {
3326 const uid = req.user?.sub ?? '';
3327 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3328 const token = signImageProxyToken(JWT_SECRET, uid);
3329 res.json({ token, expires_in: IMAGE_PROXY_TOKEN_TTL_SECONDS });
3330 });
3331
3332 const IMAGE_PROXY_SIZE_LIMIT = 10 * 1024 * 1024;
3333 app.get('/api/v1/vault/image-proxy', jwtAuthFlex, apiLimiter, async (req, res) => {
3334 const rawUrl = typeof req.query.url === 'string' ? req.query.url : '';
3335 // Accept only raw.githubusercontent.com URLs to prevent SSRF.
3336 if (!/^https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/.+$/i.test(rawUrl)) {
3337 return res.status(400).json({ error: 'url must be a raw.githubusercontent.com path', code: 'BAD_REQUEST' });
3338 }
3339 // Read the stored GitHub token for this user (falls back to any connected token).
3340 let accessToken = '';
3341 try {
3342 const userId = req.user?.sub ?? '';
3343 const conn = readGitHubConnection(config.data_dir, userId || undefined);
3344 if (conn?.access_token) accessToken = conn.access_token;
3345 } catch (_) {}
3346
3347 const fetchHeaders = { 'User-Agent': 'Knowtation-Hub/1.0' };
3348 if (accessToken) fetchHeaders.Authorization = `token ${accessToken}`;
3349
3350 let upstream;
3351 try {
3352 upstream = await fetch(rawUrl, { headers: fetchHeaders });
3353 } catch (e) {
3354 return res.status(502).json({ error: 'Failed to fetch image from GitHub', code: 'UPSTREAM_ERROR' });
3355 }
3356
3357 if (!upstream.ok) {
3358 return res.status(upstream.status).json({ error: 'Image not found on GitHub', code: 'UPSTREAM_ERROR' });
3359 }
3360
3361 const ct = upstream.headers.get('content-type') || '';
3362 if (!ct.startsWith('image/')) {
3363 return res.status(400).json({ error: 'URL does not point to an image', code: 'BAD_REQUEST' });
3364 }
3365
3366 // Buffer and enforce size limit before sending.
3367 const buf = Buffer.from(await upstream.arrayBuffer());
3368 if (buf.byteLength > IMAGE_PROXY_SIZE_LIMIT) {
3369 return res.status(400).json({ error: 'Image too large (max 10 MB)', code: 'BAD_REQUEST' });
3370 }
3371
3372 res.setHeader('Content-Type', ct);
3373 res.setHeader('Content-Length', buf.byteLength);
3374 res.setHeader('Cache-Control', 'private, max-age=3600');
3375 res.setHeader('X-Content-Type-Options', 'nosniff');
3376 res.send(buf);
3377 });
3378
3379 // Optional Muse read-only proxy (admin; Option C). 404 when MUSE_URL unset.
3380 app.get('/api/v1/operator/muse/proxy', jwtAuth, apiLimiter, requireRole('admin'), async (req, res) => {
3381 const cfg = parseMuseConfigFromEnv(museEnvForBridge());
3382 if (!cfg) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
3383 const rel = typeof req.query.path === 'string' ? req.query.path.trim() : '';
3384 if (!rel) return res.status(400).json({ error: 'path query required', code: 'BAD_REQUEST' });
3385 const result = await fetchMuseProxiedGet({ config: cfg, relativePath: rel });
3386 if (!result.ok && result.code === 'BAD_REQUEST') {
3387 return res.status(400).json({ error: 'Invalid path', code: 'BAD_REQUEST' });
3388 }
3389 if (!result.ok && !result.body) {
3390 return res.status(result.status).json({ error: 'Bad gateway', code: result.code });
3391 }
3392 if (!result.ok && result.body && result.contentType) {
3393 res.status(result.status).set('Content-Type', result.contentType);
3394 res.set('X-Content-Type-Options', 'nosniff');
3395 return res.send(result.body);
3396 }
3397 if (result.ok && result.body) {
3398 res.status(200).set('Content-Type', result.contentType);
3399 res.set('X-Content-Type-Options', 'nosniff');
3400 return res.send(result.body);
3401 }
3402 return res.status(502).json({ error: 'Bad gateway', code: 'BAD_GATEWAY' });
3403 });
3404
3405 // Proposals (vault-scoped)
3406 app.get('/api/v1/proposals', parseQueryBounds, (req, res) => {
3407 try {
3408 const limit = req.query.limit != null ? Math.min(100, Math.max(0, parseInt(req.query.limit, 10) || 50)) : 50;
3409 const offset = req.query.offset != null ? Math.max(0, parseInt(req.query.offset, 10) || 0) : 0;
3410 const opts = {
3411 status: req.query.status,
3412 vault_id: req.vault_id,
3413 limit,
3414 offset,
3415 label: typeof req.query.label === 'string' ? req.query.label : undefined,
3416 source: typeof req.query.source === 'string' ? req.query.source : undefined,
3417 path_prefix: typeof req.query.path_prefix === 'string' ? req.query.path_prefix : undefined,
3418 evaluation_status:
3419 typeof req.query.evaluation_status === 'string' ? req.query.evaluation_status : undefined,
3420 review_queue: typeof req.query.review_queue === 'string' ? req.query.review_queue : undefined,
3421 review_severity: typeof req.query.review_severity === 'string' ? req.query.review_severity : undefined,
3422 };
3423 const out = listProposals(config.data_dir, opts);
3424 res.json(out);
3425 } catch (e) {
3426 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3427 }
3428 });
3429
3430 app.get('/api/v1/proposals/:id', (req, res) => {
3431 const proposal = getProposal(config.data_dir, req.params.id);
3432 if (!proposal) return res.status(404).json({ error: 'Proposal not found', code: 'NOT_FOUND' });
3433 const allowed = getAllowedVaultIds(config.data_dir, req.user?.sub ?? '');
3434 const vid = proposal.vault_id ?? 'default';
3435 if (!allowed.includes(vid)) return res.status(403).json({ error: 'Access to this proposal is not allowed.', code: 'FORBIDDEN' });
3436 res.json(proposal);
3437 });
3438
3439 app.post('/api/v1/proposals/:id/evaluation', requireRole('admin', 'evaluator'), (req, res) => {
3440 const proposal = getProposal(config.data_dir, req.params.id);
3441 if (!proposal) return res.status(404).json({ error: 'Proposal not found', code: 'NOT_FOUND' });
3442 const allowed = getAllowedVaultIds(config.data_dir, req.user?.sub ?? '');
3443 const vid = proposal.vault_id ?? 'default';
3444 if (!allowed.includes(vid)) {
3445 return res.status(403).json({ error: 'Access to this proposal is not allowed.', code: 'FORBIDDEN' });
3446 }
3447 const body = req.body && typeof req.body === 'object' ? req.body : {};
3448 const rubric = loadProposalRubric(config.data_dir);
3449 const merged = mergeEvaluationChecklist(rubric.items, body.checklist);
3450 const result = submitProposalEvaluation(config.data_dir, req.params.id, {
3451 outcome: body.outcome,
3452 evaluation_checklist: merged,
3453 evaluation_grade: body.grade,
3454 evaluation_comment: body.comment,
3455 evaluated_by: req.user?.sub ?? 'unknown',
3456 });
3457 if (!result.ok) {
3458 const st = result.code === 'NOT_FOUND' ? 404 : 400;
3459 return res.status(st).json({ error: result.error, code: result.code });
3460 }
3461 appendAudit(config.data_dir, {
3462 userId: req.user?.sub ?? 'unknown',
3463 action: 'evaluation_submitted',
3464 proposalId: req.params.id,
3465 detail: { evaluation_status: result.proposal.evaluation_status },
3466 });
3467 res.json(result.proposal);
3468 });
3469
3470 app.post('/api/v1/proposals', requireRole('editor', 'admin', 'evaluator'), (req, res) => {
3471 if (!assertSelfHostedAgentScope(req, res)) return;
3472 if (isAgentAccessPayload(req.user) && isIngestContractBody(req.body)) {
3473 return handleSelfHostedAutomationIngest(req, res, { requireContract: true });
3474 }
3475 const {
3476 path: notePath,
3477 body,
3478 frontmatter,
3479 intent,
3480 base_state_id,
3481 external_ref,
3482 labels,
3483 source,
3484 } = req.body || {};
3485 try {
3486 const policyPending = getProposalEvaluationRequired(config.data_dir);
3487 const triggers = loadReviewTriggers(config.data_dir);
3488 const labelArr = Array.isArray(labels) ? labels : [];
3489 const applied = applyReviewTriggers(triggers, {
3490 path: String(notePath || ''),
3491 body: String(body || ''),
3492 intent: String(intent || ''),
3493 labels: labelArr,
3494 });
3495 const proposal = createProposal(config.data_dir, {
3496 path: notePath,
3497 body,
3498 frontmatter,
3499 intent,
3500 base_state_id,
3501 external_ref,
3502 labels,
3503 source,
3504 vault_id: req.vault_id,
3505 proposed_by: req.user?.sub ?? undefined,
3506 evaluationRequired: policyPending,
3507 evaluationForcedPending: applied.forcePending,
3508 review_queue: applied.review_queue,
3509 review_severity: applied.review_severity,
3510 auto_flag_reasons: applied.auto_flag_reasons,
3511 });
3512 if (applied.auto_flag_reasons.length) {
3513 appendAudit(config.data_dir, {
3514 userId: req.user?.sub ?? 'unknown',
3515 action: 'proposal_auto_flagged',
3516 proposalId: proposal.proposal_id,
3517 detail: { reasons: applied.auto_flag_reasons },
3518 });
3519 }
3520 if (getProposalReviewHintsEnabled(config.data_dir)) {
3521 setImmediate(() => {
3522 runProposalReviewHintsJob(config, proposal.proposal_id).catch(() => {});
3523 });
3524 }
3525 res.status(201).json(proposal);
3526 } catch (e) {
3527 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3528 }
3529 });
3530
3531 app.post('/api/v1/proposals/:id/approve', requireApproveRole, async (req, res) => {
3532 const proposal = getProposal(config.data_dir, req.params.id);
3533 if (!proposal) return res.status(404).json({ error: 'Proposal not found', code: 'NOT_FOUND' });
3534 const approveVaultPath = config.resolveVaultPath(proposal.vault_id ?? 'default');
3535 if (!approveVaultPath) return res.status(400).json({ error: 'Proposal vault not found.', code: 'BAD_REQUEST' });
3536 if (proposal.status !== 'proposed') {
3537 return res.status(400).json({ error: `Proposal status is ${proposal.status}`, code: 'BAD_REQUEST' });
3538 }
3539 const approveBody = req.body && typeof req.body === 'object' ? req.body : {};
3540 const waiverReason =
3541 approveBody.waiver_reason != null && String(approveBody.waiver_reason).trim()
3542 ? String(approveBody.waiver_reason).trim()
3543 : '';
3544 if (!evaluationAllowsApprove(proposal)) {
3545 if (waiverReason.length < 3) {
3546 return res.status(403).json({
3547 error: 'Evaluation must be passed before approve, or provide waiver_reason (admin override).',
3548 code: 'EVALUATION_REQUIRED',
3549 });
3550 }
3551 }
3552 const fromReq =
3553 approveBody.base_state_id != null && String(approveBody.base_state_id).trim() !== ''
3554 ? String(approveBody.base_state_id).trim()
3555 : '';
3556 const fromProposal =
3557 proposal.base_state_id != null && String(proposal.base_state_id).trim() !== ''
3558 ? String(proposal.base_state_id).trim()
3559 : '';
3560 const expectedBase = fromReq || fromProposal;
3561 // Flow proposals carry a flowst1_ token, not a note kn1_; the authoritative
3562 // flow concurrency re-check runs below instead of the note-level check.
3563 if (
3564 expectedBase &&
3565 proposal.source !== FLOW_PROPOSAL_SOURCE &&
3566 proposal.source !== FLOW_CAPTURE_PROPOSAL_SOURCE &&
3567 proposal.source !== DELEGATION_PROPOSAL_SOURCE &&
3568 proposal.source !== TASK_PROPOSAL_SOURCE &&
3569 proposal.source !== PATH_PROPOSAL_SOURCE &&
3570 proposal.source !== MEDIA_PROPOSAL_SOURCE
3571 ) {
3572 let currentId;
3573 if (noteFileExistsInVault(approveVaultPath, proposal.path)) {
3574 try {
3575 const n = readNote(approveVaultPath, proposal.path);
3576 currentId = noteStateIdFromParts(n.frontmatter, n.body);
3577 } catch (_) {
3578 return res.status(409).json({
3579 error: 'base_state_id mismatch; vault note changed or path state differs',
3580 code: 'CONFLICT',
3581 });
3582 }
3583 } else {
3584 currentId = absentNoteStateId();
3585 }
3586 if (currentId !== expectedBase) {
3587 return res.status(409).json({
3588 error: 'base_state_id mismatch; vault note changed or path state differs',
3589 code: 'CONFLICT',
3590 });
3591 }
3592 }
3593 // Authoritative Flow concurrency + bundle re-check BEFORE the mirror write, so
3594 // a conflict short-circuits with zero partial state (no index write, no mirror).
3595 let flowApply = null;
3596 if (proposal.source === FLOW_PROPOSAL_SOURCE) {
3597 const flowPrecheck = precheckApprovedFlowProposal(config.data_dir, proposal);
3598 if (!flowPrecheck.ok) {
3599 return res.status(flowPrecheck.status).json({ error: flowPrecheck.error, code: flowPrecheck.code });
3600 }
3601 flowApply = flowPrecheck;
3602 }
3603 let captureApply = null;
3604 if (proposal.source === FLOW_CAPTURE_PROPOSAL_SOURCE) {
3605 const capturePrecheck = precheckApprovedCaptureProposal(config.data_dir, proposal);
3606 if (!capturePrecheck.ok) {
3607 return res.status(capturePrecheck.status).json({ error: capturePrecheck.error, code: capturePrecheck.code });
3608 }
3609 captureApply = capturePrecheck;
3610 }
3611 let delegationApply = null;
3612 if (proposal.source === DELEGATION_PROPOSAL_SOURCE) {
3613 const delegationPrecheck = precheckApprovedDelegationProposal(config.data_dir, proposal, {
3614 author: typeof proposal.proposed_by === 'string' ? proposal.proposed_by : '',
3615 });
3616 if (!delegationPrecheck.ok) {
3617 return res.status(delegationPrecheck.status).json({
3618 error: delegationPrecheck.error,
3619 code: delegationPrecheck.code,
3620 });
3621 }
3622 delegationApply = delegationPrecheck;
3623 }
3624 let taskApply = null;
3625 if (proposal.source === TASK_PROPOSAL_SOURCE) {
3626 const taskPrecheck = precheckApprovedTaskProposal(config.data_dir, proposal);
3627 if (!taskPrecheck.ok) {
3628 return res.status(taskPrecheck.status).json({ error: taskPrecheck.error, code: taskPrecheck.code });
3629 }
3630 taskApply = taskPrecheck;
3631 }
3632 let pathApply = null;
3633 if (proposal.source === PATH_PROPOSAL_SOURCE) {
3634 const pathPrecheck = precheckApprovedPathProposal(config.data_dir, proposal);
3635 if (!pathPrecheck.ok) {
3636 return res.status(pathPrecheck.status).json({ error: pathPrecheck.error, code: pathPrecheck.code });
3637 }
3638 pathApply = pathPrecheck;
3639 }
3640 let mediaApply = null;
3641 if (proposal.source === MEDIA_PROPOSAL_SOURCE) {
3642 const mediaPrecheck = precheckApprovedMediaProposal(config.data_dir, proposal, {
3643 vaultPath: approveVaultPath,
3644 vaultConfig: { ignore: config.ignore },
3645 });
3646 if (!mediaPrecheck.ok) {
3647 return res.status(mediaPrecheck.status).json({ error: mediaPrecheck.error, code: mediaPrecheck.code });
3648 }
3649 mediaApply = mediaPrecheck;
3650 }
3651 try {
3652 const fm = mergeProvenanceFrontmatter(proposal.frontmatter ?? {}, {
3653 sub: req.user?.sub ?? null,
3654 kind: 'agent',
3655 proposedBy: proposal.proposed_by ?? null,
3656 approvedBy: req.user?.sub ?? null,
3657 });
3658 writeNote(approveVaultPath, proposal.path, {
3659 body: proposal.body,
3660 frontmatter: fm,
3661 });
3662 // Reconcile the approved mirror into the Flow index (new (flow_id, version)
3663 // row) — the only index write besides seed. Bundle pre-validated above.
3664 if (flowApply) {
3665 applyFlowProposalToIndex(config.data_dir, flowApply.vaultId, flowApply.flow, flowApply.steps);
3666 }
3667 if (captureApply) {
3668 applyCaptureProposal(config.data_dir, captureApply);
3669 }
3670 if (delegationApply) {
3671 applyDelegationProposalToIndex(config.data_dir, delegationApply);
3672 }
3673 if (taskApply) {
3674 const taskReconcile = reconcileApprovedTaskProposal(config.data_dir, taskApply);
3675 if (taskReconcile.cascade_task_ids && Array.isArray(taskReconcile.cascade_task_ids)) {
3676 patchProposalTaskMetaCascade(config.data_dir, req.params.id, taskReconcile.cascade_task_ids);
3677 }
3678 }
3679 if (pathApply) {
3680 reconcileApprovedPathProposal(config.data_dir, pathApply);
3681 }
3682 if (mediaApply) {
3683 reconcileApprovedMediaProposal(config.data_dir, mediaApply);
3684 }
3685 const approvedAtIso = new Date().toISOString();
3686 let approval_log_written = false;
3687 let approval_log_path;
3688 let approval_log_error;
3689 try {
3690 const excerpt =
3691 proposal.body != null && String(proposal.body).trim()
3692 ? String(proposal.body).replace(/\s+/g, ' ').trim()
3693 : '';
3694 const logSpec = buildApprovalLogWrite({
3695 proposalId: proposal.proposal_id,
3696 targetPath: proposal.path,
3697 approvedAt: approvedAtIso,
3698 approvedBy: req.user?.sub ?? undefined,
3699 proposedBy: proposal.proposed_by ?? undefined,
3700 intent: proposal.intent,
3701 source: proposal.source,
3702 proposedBodyExcerpt: excerpt || undefined,
3703 });
3704 writeNote(approveVaultPath, logSpec.relativePath, {
3705 body: logSpec.body,
3706 frontmatter: logSpec.frontmatter,
3707 });
3708 approval_log_written = true;
3709 approval_log_path = logSpec.relativePath;
3710 } catch (e) {
3711 approval_log_error = e.message || String(e);
3712 }
3713 let evaluation_waiver;
3714 if (!evaluationAllowsApprove(proposal) && waiverReason.length >= 3) {
3715 evaluation_waiver = {
3716 by: req.user?.sub ?? 'unknown',
3717 at: approvedAtIso,
3718 reason: waiverReason.slice(0, 2000),
3719 };
3720 }
3721 const museCfg = parseMuseConfigFromEnv(museEnvForBridge());
3722 const resolvedExternalRef = await resolveExternalRefForApprove({
3723 clientRef: approveBody.external_ref,
3724 proposalId: req.params.id,
3725 vaultId: proposal.vault_id ?? 'default',
3726 config: museCfg,
3727 });
3728 const updated = updateProposalStatus(config.data_dir, req.params.id, 'approved', {
3729 ...(evaluation_waiver ? { evaluation_waiver } : {}),
3730 ...(resolvedExternalRef ? { external_ref: resolvedExternalRef } : {}),
3731 });
3732 /** @type {Record<string, unknown>} */
3733 const approveDetail = {};
3734 if (evaluation_waiver) approveDetail.reason_len = waiverReason.length;
3735 if (resolvedExternalRef) {
3736 approveDetail.external_ref_set = true;
3737 approveDetail.external_ref_len = resolvedExternalRef.length;
3738 }
3739 appendAudit(config.data_dir, {
3740 userId: req.user?.sub ?? 'unknown',
3741 action: evaluation_waiver ? 'approve_waiver' : 'approve',
3742 proposalId: req.params.id,
3743 ...(Object.keys(approveDetail).length ? { detail: approveDetail } : {}),
3744 });
3745 invalidateFacetsCache();
3746 maybeAutoSync({ ...config, vault_path: approveVaultPath });
3747 res.json({
3748 ...updated,
3749 approval_log_written,
3750 ...(approval_log_path ? { approval_log_path } : {}),
3751 ...(approval_log_error ? { approval_log_error } : {}),
3752 });
3753 } catch (e) {
3754 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3755 }
3756 });
3757
3758 app.post('/api/v1/proposals/:id/discard', requireRole('admin'), (req, res) => {
3759 const proposal = getProposal(config.data_dir, req.params.id);
3760 if (!proposal) return res.status(404).json({ error: 'Proposal not found', code: 'NOT_FOUND' });
3761 const updated = updateProposalStatus(config.data_dir, req.params.id, 'discarded');
3762 appendAudit(config.data_dir, { userId: req.user?.sub ?? 'unknown', action: 'discard', proposalId: req.params.id });
3763 res.json(updated);
3764 });
3765
3766 // Optional Tier-2: LLM summary + suggested labels (KNOWTATION_HUB_PROPOSAL_ENRICH=1; see docs/PROPOSAL-LIFECYCLE.md)
3767 app.post('/api/v1/proposals/:id/enrich', requireRole('editor', 'admin', 'evaluator'), async (req, res) => {
3768 if (!getProposalEnrichEnabled(config.data_dir)) {
3769 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
3770 }
3771 const proposal = getProposal(config.data_dir, req.params.id);
3772 if (!proposal) return res.status(404).json({ error: 'Proposal not found', code: 'NOT_FOUND' });
3773 const allowed = getAllowedVaultIds(config.data_dir, req.user?.sub ?? '');
3774 const vid = proposal.vault_id ?? 'default';
3775 if (!allowed.includes(vid)) {
3776 return res.status(403).json({ error: 'Access to this proposal is not allowed.', code: 'FORBIDDEN' });
3777 }
3778 if (proposal.status !== 'proposed') {
3779 return res.status(400).json({ error: 'Can only enrich proposed proposals', code: 'BAD_REQUEST' });
3780 }
3781 try {
3782 const { buildEnrichMessages, validateAndNormalizeEnrichResult } = await import('../lib/proposal-enrich-llm.mjs');
3783 const { system, user } = buildEnrichMessages({
3784 path: proposal.path,
3785 intent: proposal.intent,
3786 body: proposal.body,
3787 });
3788 const raw = await completeChat(config, { system, user, maxTokens: 1200 });
3789 const norm = validateAndNormalizeEnrichResult(raw);
3790 const model = process.env.OPENAI_API_KEY
3791 ? config.llm?.openai_chat_model || process.env.OPENAI_CHAT_MODEL || 'gpt-4o-mini'
3792 : process.env.OLLAMA_CHAT_MODEL || config.llm?.ollama_chat_model || process.env.OLLAMA_MODEL || 'ollama';
3793 const updated = updateProposalEnrichment(config.data_dir, req.params.id, {
3794 assistant_notes: norm.summary,
3795 assistant_model: String(model).slice(0, 128),
3796 suggested_labels: norm.suggested_labels,
3797 assistant_suggested_frontmatter: norm.suggested_frontmatter,
3798 });
3799 appendAudit(config.data_dir, { userId: req.user?.sub ?? 'unknown', action: 'enrich', proposalId: req.params.id });
3800 res.json(updated);
3801 } catch (e) {
3802 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3803 }
3804 });
3805
3806 // GET /api/v1/settings — safe config status for Settings UI (Phase 13 + Phase 15 multi-vault)
3807 app.get('/api/v1/settings', jwtAuth, requireRole('viewer', 'editor', 'admin', 'evaluator'), (req, res) => {
3808 const vg = config.vault_git;
3809 const vaultPath = config.vault_path || '';
3810 const vault_path_display = vaultPath ? '…/' + path.basename(vaultPath) : '';
3811 const githubConn = readGitHubConnection(config.data_dir);
3812 const emb = config.embedding || {};
3813 const ollamaUrl = emb.ollama_url || (emb.provider === 'ollama' ? 'http://localhost:11434' : undefined);
3814 const vaultListRaw = readHubVaults(config.data_dir, projectRoot);
3815 const vaultList = (vaultListRaw.length ? vaultListRaw : config.vaultList || []).map((v) => ({ id: v.id, label: v.label || v.id }));
3816 const allowed_vault_ids = getAllowedVaultIds(config.data_dir, req.user?.sub ?? '');
3817 const dataDirDisplay = path.relative(projectRoot, config.data_dir);
3818 const storedPolicy = readProposalPolicyFile(config.data_dir);
3819 res.json({
3820 role: effectiveRole(req),
3821 user_id: req.user?.sub ?? '',
3822 vault_id: req.vault_id ?? 'default',
3823 vault_list: vaultList,
3824 allowed_vault_ids,
3825 data_dir_display: dataDirDisplay || 'data',
3826 vault_path_display,
3827 vault_git: {
3828 enabled: !!vg?.enabled,
3829 has_remote: !!vg?.remote,
3830 auto_commit: !!vg?.auto_commit,
3831 auto_push: !!vg?.auto_push,
3832 },
3833 github_connect_available: Boolean(process.env.GITHUB_CLIENT_ID),
3834 github_connected: Boolean(githubConn?.access_token),
3835 workspace_owner_id: null,
3836 hosted_delegating: false,
3837 embedding_display: {
3838 provider: emb.provider || 'ollama',
3839 model: emb.model || 'nomic-embed-text',
3840 ollama_url: ollamaUrl,
3841 },
3842 proposal_enrich_enabled: getProposalEnrichEnabled(config.data_dir),
3843 proposal_evaluation_required: getProposalEvaluationRequired(config.data_dir),
3844 proposal_review_hints_enabled: getProposalReviewHintsEnabled(config.data_dir),
3845 proposal_policy_stored: {
3846 proposal_evaluation_required: storedPolicy.proposal_evaluation_required === true,
3847 review_hints_enabled: storedPolicy.review_hints_enabled === true,
3848 enrich_enabled: storedPolicy.enrich_enabled === true,
3849 },
3850 proposal_policy_env_locked: proposalPolicyEnvLocked(),
3851 hub_evaluator_may_approve: actorMayApproveProposals(
3852 req.user?.sub ?? '',
3853 effectiveRole(req),
3854 readEvaluatorMayApprove(config.data_dir),
3855 hubEnvEvaluatorMayApprove(),
3856 ),
3857 proposal_rubric: loadProposalRubric(config.data_dir),
3858 muse_bridge: museBridgePublicSettings(),
3859 chat: {
3860 provider: config.llm?.provider || '',
3861 providers: CHAT_PROVIDERS,
3862 env_locked: Boolean(process.env.KNOWTATION_CHAT_PROVIDER),
3863 env_provider: String(process.env.KNOWTATION_CHAT_PROVIDER || '').trim().toLowerCase() || null,
3864 key_available: {
3865 openai: Boolean(process.env.OPENAI_API_KEY),
3866 anthropic: Boolean(process.env.ANTHROPIC_API_KEY),
3867 deepinfra: Boolean(process.env.DEEPINFRA_API_KEY),
3868 openrouter: Boolean(process.env.OPENROUTER_API_KEY),
3869 },
3870 },
3871 daemon: {
3872 enabled: Boolean(config.daemon?.enabled),
3873 interval_minutes: config.daemon?.interval_minutes ?? 120,
3874 idle_only: config.daemon?.idle_only !== false,
3875 idle_threshold_minutes: config.daemon?.idle_threshold_minutes ?? 15,
3876 run_on_start: Boolean(config.daemon?.run_on_start),
3877 max_cost_per_day_usd: config.daemon?.max_cost_per_day_usd ?? null,
3878 passes: {
3879 consolidate: config.daemon?.passes?.consolidate !== false,
3880 verify: config.daemon?.passes?.verify !== false,
3881 discover: Boolean(config.daemon?.passes?.discover),
3882 },
3883 llm: {
3884 provider: config.daemon?.llm?.provider || '',
3885 model: config.daemon?.llm?.model || '',
3886 base_url: config.daemon?.llm?.base_url || '',
3887 max_tokens: config.daemon?.llm?.max_tokens ?? 1024,
3888 },
3889 lookback_hours: config.daemon?.lookback_hours ?? 24,
3890 max_events_per_pass: config.daemon?.max_events_per_pass ?? 200,
3891 max_topics_per_pass: config.daemon?.max_topics_per_pass ?? 10,
3892 },
3893 });
3894 });
3895
3896 app.post(
3897 '/api/v1/settings/consolidation',
3898 jwtAuth,
3899 apiLimiter,
3900 requireRole('admin'),
3901 express.json(),
3902 async (req, res) => {
3903 try {
3904 const body = req.body && typeof req.body === 'object' ? req.body : {};
3905 const yaml = (await import('js-yaml')).default;
3906 const configPath = process.env.KNOWTATION_CONFIG || path.join(projectRoot, 'config', 'local.yaml');
3907 let doc = {};
3908 if (fs.existsSync(configPath)) {
3909 doc = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
3910 }
3911 if (!doc.daemon) doc.daemon = {};
3912 if (body.enabled !== undefined) doc.daemon.enabled = Boolean(body.enabled);
3913 if (body.interval_minutes !== undefined) {
3914 const iv = Math.floor(Number(body.interval_minutes) || 0);
3915 if (iv < 1 || iv > 43200) return res.status(400).json({ error: 'interval_minutes must be 1–43200', code: 'VALIDATION_ERROR' });
3916 doc.daemon.interval_minutes = iv;
3917 }
3918 if (body.idle_only !== undefined) doc.daemon.idle_only = Boolean(body.idle_only);
3919 if (body.idle_threshold_minutes !== undefined) doc.daemon.idle_threshold_minutes = Math.max(1, Math.floor(Number(body.idle_threshold_minutes) || 15));
3920 if (body.run_on_start !== undefined) doc.daemon.run_on_start = Boolean(body.run_on_start);
3921 if (body.max_cost_per_day_usd !== undefined) {
3922 doc.daemon.max_cost_per_day_usd = body.max_cost_per_day_usd === '' || body.max_cost_per_day_usd === null ? null : Math.max(0, Number(body.max_cost_per_day_usd) || 0);
3923 }
3924 if (body.passes !== undefined && typeof body.passes === 'object') {
3925 if (!doc.daemon.passes) doc.daemon.passes = {};
3926 if (body.passes.consolidate !== undefined) doc.daemon.passes.consolidate = Boolean(body.passes.consolidate);
3927 if (body.passes.verify !== undefined) doc.daemon.passes.verify = Boolean(body.passes.verify);
3928 if (body.passes.discover !== undefined) doc.daemon.passes.discover = Boolean(body.passes.discover);
3929 }
3930 if (body.lookback_hours !== undefined) {
3931 const lb = Math.floor(Number(body.lookback_hours));
3932 if (lb < 1 || lb > 8760) {
3933 return res.status(400).json({ error: 'lookback_hours must be 1–8760', code: 'VALIDATION_ERROR' });
3934 }
3935 doc.daemon.lookback_hours = lb;
3936 }
3937 if (body.max_events_per_pass !== undefined) {
3938 const me = Math.floor(Number(body.max_events_per_pass));
3939 if (me < 1 || me > 10000) {
3940 return res.status(400).json({ error: 'max_events_per_pass must be 1–10000', code: 'VALIDATION_ERROR' });
3941 }
3942 doc.daemon.max_events_per_pass = me;
3943 }
3944 if (body.max_topics_per_pass !== undefined) {
3945 const mt = Math.floor(Number(body.max_topics_per_pass));
3946 if (mt < 1 || mt > 500) {
3947 return res.status(400).json({ error: 'max_topics_per_pass must be 1–500', code: 'VALIDATION_ERROR' });
3948 }
3949 doc.daemon.max_topics_per_pass = mt;
3950 }
3951 if (body.llm !== undefined && typeof body.llm === 'object') {
3952 if (!doc.daemon.llm) doc.daemon.llm = {};
3953 if (body.llm.provider !== undefined) doc.daemon.llm.provider = String(body.llm.provider || '');
3954 if (body.llm.model !== undefined) {
3955 const m = String(body.llm.model || '');
3956 if (/[/\\;|&$`(){}<>!#]/.test(m)) return res.status(400).json({ error: 'Invalid model name', code: 'VALIDATION_ERROR' });
3957 doc.daemon.llm.model = m;
3958 }
3959 if (body.llm.base_url !== undefined) doc.daemon.llm.base_url = String(body.llm.base_url || '');
3960 if (body.llm.max_tokens !== undefined) {
3961 const mxt = Math.floor(Number(body.llm.max_tokens));
3962 if (mxt < 64 || mxt > 8192) {
3963 return res.status(400).json({ error: 'llm.max_tokens must be 64–8192', code: 'VALIDATION_ERROR' });
3964 }
3965 doc.daemon.llm.max_tokens = mxt;
3966 }
3967 }
3968 const dir = path.dirname(configPath);
3969 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3970 fs.writeFileSync(configPath, yaml.dump(doc), 'utf8');
3971 config = loadConfig(projectRoot);
3972 res.json({ ok: true, daemon: doc.daemon });
3973 } catch (e) {
3974 res.status(500).json({ error: e.message || 'Failed to save', code: 'RUNTIME_ERROR' });
3975 }
3976 },
3977 );
3978
3979 // POST /api/v1/settings/chat — set the completeChat provider (MCP summarize + proposal LLM jobs).
3980 // Admin only. Persists llm.provider to config/local.yaml. The provider drives where note text is
3981 // sent and which account is billed, so input is strictly whitelisted. When KNOWTATION_CHAT_PROVIDER
3982 // is set, the operator env lock wins and the UI cannot change it (409).
3983 app.post(
3984 '/api/v1/settings/chat',
3985 jwtAuth,
3986 apiLimiter,
3987 requireRole('admin'),
3988 express.json(),
3989 async (req, res) => {
3990 try {
3991 if (process.env.KNOWTATION_CHAT_PROVIDER) {
3992 return res.status(409).json({
3993 error:
3994 'Chat provider is locked by the KNOWTATION_CHAT_PROVIDER environment variable; unset it to manage the provider from the UI.',
3995 code: 'ENV_LOCKED',
3996 });
3997 }
3998 const body = req.body && typeof req.body === 'object' ? req.body : {};
3999 const result = normalizeChatProviderInput(body.provider);
4000 if (!result.ok) {
4001 return res.status(400).json({ error: result.error, code: 'VALIDATION_ERROR' });
4002 }
4003 const yaml = (await import('js-yaml')).default;
4004 const configPath = process.env.KNOWTATION_CONFIG || path.join(projectRoot, 'config', 'local.yaml');
4005 let doc = {};
4006 if (fs.existsSync(configPath)) {
4007 doc = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
4008 }
4009 if (!doc.llm || typeof doc.llm !== 'object') doc.llm = {};
4010 doc.llm.provider = result.provider;
4011 const dir = path.dirname(configPath);
4012 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
4013 fs.writeFileSync(configPath, yaml.dump(doc), 'utf8');
4014 config = loadConfig(projectRoot);
4015 res.json({ ok: true, chat: { provider: config.llm?.provider || '' } });
4016 } catch (e) {
4017 res.status(500).json({ error: e.message || 'Failed to save', code: 'RUNTIME_ERROR' });
4018 }
4019 },
4020 );
4021
4022 /**
4023 * Validate optional Muse base URL for config/local.yaml (self-hosted Settings).
4024 * @param {unknown} raw
4025 * @returns {{ ok: true, url: string } | { ok: false, error: string, code: string }}
4026 */
4027 function validateMuseUrlForYaml(raw) {
4028 if (raw == null) return { ok: true, url: '' };
4029 const s = String(raw).trim();
4030 if (!s) return { ok: true, url: '' };
4031 if (s.length > 2048) return { ok: false, error: 'URL too long (max 2048)', code: 'VALIDATION_ERROR' };
4032 const normalized = s.replace(/\/+$/, '');
4033 const parsed = parseMuseConfigFromEnv({ ...process.env, MUSE_URL: normalized });
4034 if (!parsed) {
4035 return {
4036 ok: false,
4037 error: 'Muse URL must start with https:// or http:// and be a valid URL.',
4038 code: 'VALIDATION_ERROR',
4039 };
4040 }
4041 return { ok: true, url: parsed.baseUrl };
4042 }
4043
4044 app.post(
4045 '/api/v1/settings/muse',
4046 jwtAuth,
4047 apiLimiter,
4048 requireRole('admin'),
4049 express.json(),
4050 async (req, res) => {
4051 try {
4052 if (process.env.MUSE_URL != null && String(process.env.MUSE_URL).trim() !== '') {
4053 return res.status(409).json({
4054 error:
4055 'MUSE_URL is set in the Hub process environment. Unset it to save the Muse URL in config/local.yaml from Settings.',
4056 code: 'ENV_CONFLICT',
4057 });
4058 }
4059 const body = req.body && typeof req.body === 'object' ? req.body : {};
4060 const v = validateMuseUrlForYaml(body.url);
4061 if (!v.ok) return res.status(400).json({ error: v.error, code: v.code });
4062 const yaml = (await import('js-yaml')).default;
4063 const configPath = process.env.KNOWTATION_CONFIG || path.join(projectRoot, 'config', 'local.yaml');
4064 let doc = {};
4065 if (fs.existsSync(configPath)) {
4066 doc = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
4067 }
4068 if (!v.url) {
4069 if (doc.muse && typeof doc.muse === 'object') {
4070 delete doc.muse.url;
4071 if (Object.keys(doc.muse).length === 0) delete doc.muse;
4072 }
4073 } else {
4074 doc.muse = { ...(doc.muse && typeof doc.muse === 'object' ? doc.muse : {}), url: v.url };
4075 }
4076 const dir = path.dirname(configPath);
4077 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
4078 fs.writeFileSync(configPath, yaml.dump(doc), 'utf8');
4079 config = loadConfig(projectRoot);
4080 roleMap = loadRoleMap(config.data_dir);
4081 res.json({ ok: true, muse_bridge: museBridgePublicSettings() });
4082 } catch (e) {
4083 res.status(500).json({ error: e.message || 'Failed to save', code: 'RUNTIME_ERROR' });
4084 }
4085 },
4086 );
4087
4088 app.post(
4089 '/api/v1/settings/proposal-policy',
4090 jwtAuth,
4091 apiLimiter,
4092 requireRole('admin'),
4093 (req, res) => {
4094 try {
4095 const body = req.body && typeof req.body === 'object' ? req.body : {};
4096 writeProposalPolicyMerge(config.data_dir, {
4097 proposal_evaluation_required: body.proposal_evaluation_required,
4098 review_hints_enabled: body.review_hints_enabled,
4099 enrich_enabled: body.enrich_enabled,
4100 });
4101 res.json({ ok: true });
4102 } catch (e) {
4103 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4104 }
4105 },
4106 );
4107
4108 /**
4109 * POST /api/v1/memory/consolidate
4110 * Self-hosted: runs consolidation inline using the user's config (LLM key from env or config.daemon).
4111 * Body: { dry_run?, passes?, lookback_hours? }
4112 */
4113 app.post('/api/v1/memory/consolidate', jwtAuth, apiLimiter, express.json(), async (req, res) => {
4114 const uid = req.user?.sub ?? 'local';
4115 const { dry_run, passes, lookback_hours } = req.body || {};
4116
4117 const llmApiKey =
4118 config.daemon?.llm?.api_key ||
4119 process.env.CONSOLIDATION_LLM_API_KEY ||
4120 process.env.OPENAI_API_KEY;
4121 if (!llmApiKey) {
4122 return res.status(503).json({
4123 error: 'No LLM API key configured. Set OPENAI_API_KEY in your environment or config/local.yaml daemon.llm.api_key.',
4124 code: 'LLM_NOT_CONFIGURED',
4125 });
4126 }
4127
4128 try {
4129 const { createMemoryManager } = await import('../lib/memory.mjs');
4130 const { consolidateMemory } = await import('../lib/memory-consolidate.mjs');
4131 const { computeCallCost } = await import('../lib/daemon-cost.mjs');
4132 const { completeChat } = await import('../lib/llm-complete.mjs');
4133
4134 const vaultId = req.vault_id || 'default';
4135 const mm = createMemoryManager(config, vaultId);
4136
4137 const consolidationConfig = {
4138 data_dir: config.data_dir,
4139 llm: {
4140 provider: config.daemon?.llm?.provider || 'openai',
4141 api_key: llmApiKey,
4142 model: config.daemon?.llm?.model || process.env.CONSOLIDATION_LLM_MODEL || 'gpt-4o-mini',
4143 base_url: config.daemon?.llm?.base_url || undefined,
4144 },
4145 daemon: config.daemon || {},
4146 memory: config.memory || { provider: 'file' },
4147 };
4148
4149 let totalCostUsd = 0;
4150 const trackingLlmFn = async (cfg, callOpts) => {
4151 const rawResponse = await completeChat(consolidationConfig, callOpts);
4152 totalCostUsd += computeCallCost(callOpts, rawResponse);
4153 return rawResponse;
4154 };
4155
4156 const result = await consolidateMemory(consolidationConfig, {
4157 mm,
4158 dryRun: Boolean(dry_run),
4159 passes: passes ?? undefined,
4160 lookbackHours: lookback_hours != null ? Number(lookback_hours) : undefined,
4161 llmFn: dry_run ? undefined : trackingLlmFn,
4162 });
4163
4164 const pass_id = 'cpass_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6);
4165
4166 // Store a pass-level summary event so History shows one row per run.
4167 if (!dry_run) {
4168 mm.store('consolidation_pass', {
4169 topics_count: Array.isArray(result.topics) ? result.topics.length : (result.topics ?? 0),
4170 total_events: result.total_events,
4171 cost_usd: totalCostUsd,
4172 pass_id,
4173 verify: result.verify ?? null,
4174 discover: result.discover ?? null,
4175 });
4176 }
4177
4178 return res.json({
4179 topics: result.topics,
4180 total_events: result.total_events,
4181 verify: result.verify ?? null,
4182 discover: result.discover ?? null,
4183 cost_usd: totalCostUsd,
4184 pass_id,
4185 dry_run: result.dry_run,
4186 });
4187 } catch (e) {
4188 console.error('[hub] POST /api/v1/memory/consolidate', e?.message);
4189 res.status(500).json({ error: e.message || 'Consolidation failed', code: 'RUNTIME_ERROR' });
4190 }
4191 });
4192
4193 /**
4194 * GET /api/v1/memory/consolidate/status
4195 * Self-hosted: returns daemon config + last consolidation pass from memory log.
4196 */
4197 app.get('/api/v1/memory/consolidate/status', jwtAuth, async (req, res) => {
4198 try {
4199 const { createMemoryManager } = await import('../lib/memory.mjs');
4200 const vaultId = req.vault_id || 'default';
4201 const mm = createMemoryManager(config, vaultId);
4202 const recentPasses = mm.list({ type: 'consolidation_pass', limit: 1 });
4203 const lastPass = recentPasses.length > 0 ? (recentPasses[0].ts || recentPasses[0].created_at || null) : null;
4204 const monthStart = new Date();
4205 monthStart.setDate(1);
4206 monthStart.setHours(0, 0, 0, 0);
4207 const allPasses = mm.list({ type: 'consolidation_pass', since: monthStart.toISOString(), limit: 500 });
4208 return res.json({
4209 enabled: Boolean(config.daemon?.enabled),
4210 interval_minutes: config.daemon?.interval_minutes ?? null,
4211 last_pass: lastPass,
4212 cost_today_usd: 0,
4213 cost_cap_usd: config.daemon?.max_cost_per_day_usd ?? null,
4214 pass_count_month: allPasses.length,
4215 });
4216 } catch (e) {
4217 res.status(500).json({ error: e.message || 'Status unavailable', code: 'RUNTIME_ERROR' });
4218 }
4219 });
4220
4221 /**
4222 * GET /api/v1/memory — list memory events (used by History button).
4223 * Query: type, since, until, limit (max 100)
4224 */
4225 app.get('/api/v1/memory', jwtAuth, async (req, res) => {
4226 try {
4227 const { createMemoryManager } = await import('../lib/memory.mjs');
4228 const vaultId = req.vault_id || 'default';
4229 const mm = createMemoryManager(config, vaultId);
4230 const events = mm.list({
4231 type: req.query.type || undefined,
4232 since: req.query.since || undefined,
4233 until: req.query.until || undefined,
4234 limit: Math.min(parseInt(req.query.limit) || 20, 100),
4235 });
4236 res.json({ events, count: events.length });
4237 } catch (e) {
4238 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4239 }
4240 });
4241
4242 // POST /api/v1/vault/sync — manual "Back up now" (Phase 13: editor or admin; Phase 15: vault-scoped)
4243 app.post('/api/v1/vault/sync', jwtAuth, requireVaultAccess, requireRole('editor', 'admin'), (req, res) => {
4244 try {
4245 const result = runVaultSync({ ...config, vault_path: req.vaultPath });
4246 res.json(result);
4247 } catch (e) {
4248 if (e.message && e.message.includes('must be set in config')) {
4249 return res.status(400).json({ error: e.message, code: 'NOT_CONFIGURED' });
4250 }
4251 if (e.message && /not a Git repository|Vault folder is not a Git repository/i.test(e.message)) {
4252 return res.status(400).json({ error: e.message, code: 'GIT_NOT_INITIALIZED' });
4253 }
4254 const stderr = e.stderr != null ? (Buffer.isBuffer(e.stderr) ? e.stderr.toString('utf8') : String(e.stderr)) : '';
4255 const stdout = e.stdout != null ? (Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf8') : String(e.stdout)) : '';
4256 const detail = [e.message, stderr, stdout].filter(Boolean).join('\n').trim();
4257 res.status(500).json({ error: detail || 'Sync failed', code: 'RUNTIME_ERROR' });
4258 }
4259 });
4260
4261 // POST /api/v1/vault/git-init — create .git in current vault (self-hosted); editor/admin
4262 app.post('/api/v1/vault/git-init', jwtAuth, requireVaultAccess, requireRole('editor', 'admin'), (req, res) => {
4263 try {
4264 const vaultPath = req.vaultPath;
4265 if (!vaultPath || !fs.existsSync(vaultPath)) {
4266 return res.status(400).json({ error: 'Vault path not found.', code: 'BAD_REQUEST' });
4267 }
4268 const gitDir = path.join(vaultPath, '.git');
4269 if (fs.existsSync(gitDir)) {
4270 return res.status(400).json({ error: 'This vault is already a Git repository.', code: 'ALREADY_GIT' });
4271 }
4272 const runGit = (args) =>
4273 execFileSync('git', args, { cwd: vaultPath, stdio: ['pipe', 'pipe', 'pipe'] });
4274 runGit(['init']);
4275 runGit(['config', 'user.email', '[email protected]']);
4276 runGit(['config', 'user.name', 'Knowtation Hub']);
4277 runGit(['add', '-A']);
4278 try {
4279 runGit(['commit', '-m', 'Initial commit']);
4280 } catch (_) {
4281 const stamp = path.join(vaultPath, '.knowtation-git-init.md');
4282 fs.writeFileSync(
4283 stamp,
4284 '# Vault\n\nGit initialized by Knowtation Hub. You can delete this file after your first real commit.\n',
4285 'utf8',
4286 );
4287 runGit(['add', '-A']);
4288 runGit(['commit', '-m', 'Initial commit']);
4289 }
4290 res.json({
4291 ok: true,
4292 message: 'Git initialized in this vault. Use Back up now to push (after Connect GitHub if needed).',
4293 });
4294 } catch (e) {
4295 res.status(500).json({ error: e.message || 'git init failed', code: 'RUNTIME_ERROR' });
4296 }
4297 });
4298
4299 // GET /api/v1/roles — list roles (Phase 13: admin only; for Team UI)
4300 app.get('/api/v1/roles', jwtAuth, requireRole('admin'), (_req, res) => {
4301 try {
4302 const roles = readRolesObject(config.data_dir);
4303 const evaluator_may_approve = readEvaluatorMayApprove(config.data_dir);
4304 res.json({ roles, evaluator_may_approve });
4305 } catch (e) {
4306 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4307 }
4308 });
4309
4310 // POST /api/v1/roles — add or update one role (Phase 13: admin only)
4311 app.post('/api/v1/roles', jwtAuth, requireRole('admin'), (req, res) => {
4312 const { user_id: userId, role } = req.body || {};
4313 if (!userId || typeof userId !== 'string' || !userId.trim()) {
4314 return res.status(400).json({ error: 'user_id required (e.g. github:12345)', code: 'BAD_REQUEST' });
4315 }
4316 const r = (role || '').toLowerCase();
4317 if (!['admin', 'editor', 'viewer', 'evaluator'].includes(r)) {
4318 return res.status(400).json({ error: 'role must be admin, editor, viewer, or evaluator', code: 'BAD_REQUEST' });
4319 }
4320 try {
4321 const beforeMap = loadRoleMap(config.data_dir);
4322 const current = readRolesObject(config.data_dir);
4323 const uidKey = userId.trim();
4324 current[uidKey] = r;
4325 const actorSub = req.user?.sub ?? '';
4326 const toWrite = ensureActorAdminOnFirstRolesPopulation(beforeMap.size, current, actorSub);
4327 writeRolesFile(config.data_dir, toWrite);
4328 roleMap = loadRoleMap(config.data_dir);
4329 let mayMap = readEvaluatorMayApprove(config.data_dir);
4330 if (r === 'evaluator' && req.body && Object.prototype.hasOwnProperty.call(req.body, 'evaluator_may_approve')) {
4331 mayMap = { ...mayMap, [uidKey]: Boolean(req.body.evaluator_may_approve) };
4332 writeEvaluatorMayApprove(config.data_dir, mayMap);
4333 } else if (r !== 'evaluator' && Object.prototype.hasOwnProperty.call(mayMap, uidKey)) {
4334 const next = { ...mayMap };
4335 delete next[uidKey];
4336 writeEvaluatorMayApprove(config.data_dir, next);
4337 }
4338 res.json({ ok: true, roles: toWrite });
4339 } catch (e) {
4340 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4341 }
4342 });
4343
4344 app.post('/api/v1/roles/evaluator-may-approve', jwtAuth, requireRole('admin'), (req, res) => {
4345 const { user_id: userId, evaluator_may_approve: flag } = req.body || {};
4346 if (!userId || typeof userId !== 'string' || !userId.trim()) {
4347 return res.status(400).json({ error: 'user_id required', code: 'BAD_REQUEST' });
4348 }
4349 if (typeof flag !== 'boolean') {
4350 return res.status(400).json({ error: 'evaluator_may_approve must be boolean', code: 'BAD_REQUEST' });
4351 }
4352 const uidKey = userId.trim();
4353 const rm = loadRoleMap(config.data_dir);
4354 const gr = getRole(rm, uidKey);
4355 const storedRole = gr === 'member' || !gr ? (rm.size === 0 ? 'admin' : 'editor') : gr;
4356 if (storedRole !== 'evaluator') {
4357 return res.status(400).json({ error: 'User must have evaluator role', code: 'BAD_REQUEST' });
4358 }
4359 try {
4360 const mayMap = { ...readEvaluatorMayApprove(config.data_dir), [uidKey]: flag };
4361 writeEvaluatorMayApprove(config.data_dir, mayMap);
4362 res.json({ ok: true });
4363 } catch (e) {
4364 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4365 }
4366 });
4367
4368 // Phase 13 invite flow (admin only)
4369 const baseOrigin = () => (process.env.HUB_UI_ORIGIN || BASE_URL).replace(/\/$/, '');
4370
4371 // POST /api/v1/invites — create invite link (admin only)
4372 app.post('/api/v1/invites', jwtAuth, requireRole('admin'), (req, res) => {
4373 const role = (req.body?.role || 'editor').toLowerCase();
4374 if (!['viewer', 'editor', 'admin', 'evaluator'].includes(role)) {
4375 return res.status(400).json({ error: 'role must be viewer, editor, admin, or evaluator', code: 'BAD_REQUEST' });
4376 }
4377 try {
4378 const { token, role: r, created_at, expires_at } = createInvite(config.data_dir, role);
4379 const invite_url = `${baseOrigin()}?invite=${encodeURIComponent(token)}`;
4380 res.status(201).json({ invite_url, token, role: r, created_at, expires_at });
4381 } catch (e) {
4382 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4383 }
4384 });
4385
4386 // GET /api/v1/invites — list pending invites (admin only)
4387 app.get('/api/v1/invites', jwtAuth, requireRole('admin'), (_req, res) => {
4388 try {
4389 const invites = listInvites(config.data_dir);
4390 res.json({ invites });
4391 } catch (e) {
4392 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4393 }
4394 });
4395
4396 // DELETE /api/v1/invites/:token — revoke invite (admin only)
4397 app.delete('/api/v1/invites/:token', jwtAuth, requireRole('admin'), (req, res) => {
4398 const token = req.params.token;
4399 if (!token) return res.status(400).json({ error: 'token required', code: 'BAD_REQUEST' });
4400 try {
4401 const removed = revokeInvite(config.data_dir, token);
4402 res.json({ ok: true, removed });
4403 } catch (e) {
4404 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4405 }
4406 });
4407
4408 // Phase 15: multi-vault admin (admin only)
4409 app.get('/api/v1/vaults', jwtAuth, requireRole('admin'), (_req, res) => {
4410 try {
4411 const list = readHubVaults(config.data_dir, projectRoot);
4412 const vaults = list.length > 0 ? list : (config.vaultList || []).map((v) => ({ id: v.id, path: v.path, label: v.label }));
4413 res.json({ vaults });
4414 } catch (e) {
4415 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4416 }
4417 });
4418
4419 app.post('/api/v1/vaults', jwtAuth, requireRole('admin'), (req, res) => {
4420 const vaults = req.body?.vaults;
4421 if (!Array.isArray(vaults)) return res.status(400).json({ error: 'vaults array required', code: 'BAD_REQUEST' });
4422 try {
4423 writeHubVaults(config.data_dir, vaults, projectRoot);
4424 config = loadConfig(projectRoot);
4425 res.json({ ok: true, vaults: config.vaultList });
4426 } catch (e) {
4427 if (e.message && (e.message.includes('default') || e.message.includes('required'))) {
4428 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
4429 }
4430 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4431 }
4432 });
4433
4434 app.delete('/api/v1/vaults/:vaultId', jwtAuth, apiLimiter, requireRole('admin'), async (req, res) => {
4435 const vaultId = decodeURIComponent(String(req.params.vaultId || '').trim());
4436 try {
4437 const out = await deleteSelfHostedVault({
4438 dataDir: config.data_dir,
4439 projectRoot,
4440 vaultId,
4441 config,
4442 });
4443 config = loadConfig(projectRoot);
4444 roleMap = loadRoleMap(config.data_dir);
4445 invalidateFacetsCache();
4446 res.json(out);
4447 } catch (e) {
4448 const code = e.code && typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
4449 const status =
4450 code === 'BAD_REQUEST' ? 400 : code === 'FORBIDDEN' ? 403 : code === 'NOT_FOUND' ? 404 : 500;
4451 res.status(status).json({ error: e.message || 'Delete vault failed', code });
4452 }
4453 });
4454
4455 app.get('/api/v1/vault-access', jwtAuth, requireRole('admin'), (_req, res) => {
4456 try {
4457 const access = readVaultAccess(config.data_dir);
4458 res.json({ access });
4459 } catch (e) {
4460 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4461 }
4462 });
4463
4464 app.post('/api/v1/vault-access', jwtAuth, requireRole('admin'), (req, res) => {
4465 const access = req.body?.access;
4466 if (!access || typeof access !== 'object') return res.status(400).json({ error: 'access object required', code: 'BAD_REQUEST' });
4467 try {
4468 writeVaultAccess(config.data_dir, access);
4469 res.json({ ok: true, access: readVaultAccess(config.data_dir) });
4470 } catch (e) {
4471 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4472 }
4473 });
4474
4475 app.get('/api/v1/scope', jwtAuth, requireRole('admin'), (_req, res) => {
4476 try {
4477 const scope = readScope(config.data_dir);
4478 res.json({ scope });
4479 } catch (e) {
4480 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4481 }
4482 });
4483
4484 app.post('/api/v1/scope', jwtAuth, requireRole('admin'), (req, res) => {
4485 const scope = req.body?.scope;
4486 if (!scope || typeof scope !== 'object') return res.status(400).json({ error: 'scope object required', code: 'BAD_REQUEST' });
4487 try {
4488 writeScope(config.data_dir, scope);
4489 res.json({ ok: true, scope: readScope(config.data_dir) });
4490 } catch (e) {
4491 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
4492 }
4493 });
4494
4495 // GET /api/v1/setup — editable setup (Phase 13: requires auth + viewer)
4496 app.get('/api/v1/setup', jwtAuth, requireRole('viewer', 'editor', 'admin', 'evaluator'), (_req, res) => {
4497 const vg = config.vault_git;
4498 res.json({
4499 vault_path: config.vault_path || '',
4500 vault_git: {
4501 enabled: !!vg?.enabled,
4502 remote: vg?.remote || '',
4503 },
4504 });
4505 });
4506
4507 // POST /api/v1/setup — write vault_path and/or vault.git (Phase 13: admin only)
4508 app.post('/api/v1/setup', jwtAuth, requireRole('admin'), (req, res) => {
4509 if (process.env.HUB_ALLOW_SETUP_WRITE === 'false') {
4510 return res.status(403).json({ error: 'Setup write is disabled (HUB_ALLOW_SETUP_WRITE=false)', code: 'FORBIDDEN' });
4511 }
4512 const body = req.body || {};
4513 try {
4514 const payload = {};
4515 if (body.vault_path !== undefined) payload.vault_path = body.vault_path;
4516 if (body.vault_git !== undefined) {
4517 payload.vault = { git: body.vault_git };
4518 }
4519 if (Object.keys(payload).length === 0) {
4520 return res.status(400).json({ error: 'Provide vault_path and/or vault_git', code: 'BAD_REQUEST' });
4521 }
4522 writeHubSetup(config.data_dir, payload);
4523 config = loadConfig(projectRoot);
4524 roleMap = loadRoleMap(config.data_dir);
4525 res.json({ ok: true, message: 'Setup saved. Config applied.' });
4526 } catch (e) {
4527 if (e.message && e.message.includes('cannot be empty')) {
4528 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
4529 }
4530 res.status(500).json({ error: e.message || 'Setup save failed', code: 'RUNTIME_ERROR' });
4531 }
4532 });
4533
4534 // Rich Hub UI — same origin as API so opening http://localhost:3333/ shows the app
4535 const hubUiDir = path.join(projectRoot, 'web', 'hub');
4536 app.use((err, req, res, next) => {
4537 if (!err) return next();
4538 if (err.type === 'entity.too.large') {
4539 const isApi = req.path === '/api' || req.path.startsWith('/api/');
4540 const message = `Request body exceeds Hub JSON limit (${jsonBodyLimit}).`;
4541 if (isApi) return res.status(413).json({ error: message, code: 'PAYLOAD_TOO_LARGE' });
4542 return res.status(413).type('text/plain').send(message);
4543 }
4544 return next(err);
4545 });
4546 // Disable caching for JS/CSS so the browser always fetches the latest source.
4547 app.use((req, res, next) => {
4548 if (/\.(mjs|js|css)$/.test(req.path)) {
4549 res.set('Cache-Control', 'no-store');
4550 }
4551 next();
4552 });
4553 app.use(express.static(hubUiDir, { index: 'index.html' }));
4554 app.get('/', (_req, res) => {
4555 res.sendFile(path.join(hubUiDir, 'index.html'));
4556 });
4557
4558 app.listen(PORT, () => {
4559 console.log(`Knowtation Hub listening on http://localhost:${PORT}`);
4560 console.log(' UI: GET / (Rich Hub)');
4561 console.log(' Health: GET /health');
4562 console.log(' Login: GET /api/v1/auth/login?provider=google|github');
4563 console.log(' API: /api/v1/notes, /api/v1/search, /api/v1/proposals (Bearer JWT)');
4564 if (isProduction && roleMap.size === 0) {
4565 console.warn(
4566 '\x1b[33m[SECURITY] No roles configured (data/hub_roles.json is empty or missing). ' +
4567 'All authenticated users currently have admin access. ' +
4568 'Add at least one role via POST /api/v1/roles before public launch.\x1b[0m'
4569 );
4570 }
4571 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago