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