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