server.mjs file-level

at sha256:6 · View file ↗ · Intel ↗

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