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