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