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