server.mjs
3,903 lines 152.2 KB
Raw
sha256:49f768e5fb72e8d17321410817422fc5cab8f0a1b66a1e1da8bdd4cd251c4f7e Merge 'feat/ourware-landing-rebrand' into 'main' — proposal… Human 18 days ago
1 /**
2 * Knowtation Hub Bridge — Connect GitHub + Back up now + indexer + search for hosted product.
3 * Stores GitHub token per user; sync fetches notes + full proposals from canister and pushes to repo (snapshot JSON + markdown).
4 * Index/search: pull vault from canister, chunk → embed → sqlite-vec per user; search via POST /api/v1/search.
5 * On Netlify, tokens and vector DBs persist via Netlify Blobs (set by netlify/functions/bridge.mjs).
6 * Env: SESSION_SECRET, CANISTER_URL, HUB_BASE_URL; optional HUB_UI_ORIGIN, HUB_UI_PATH (default /hub), GITHUB_*, EMBEDDING_*, BRIDGE_PORT, DATA_DIR.
7 * Consolidation: CONSOLIDATION_LLM_API_KEY / OPENAI_API_KEY, CONSOLIDATION_LLM_MODEL; CONSOLIDATION_MEMORY_ENCRYPT=true omits raw event payloads from consolidation LLM prompts.
8 */
9
10 import fs from 'fs';
11 import path from 'path';
12 import os from 'os';
13 import { fileURLToPath } from 'url';
14 import crypto from 'crypto';
15 import dotenv from 'dotenv';
16 import express from 'express';
17 import multer from 'multer';
18 import AdmZip from 'adm-zip';
19 import { parseCanisterProposalGetBody } from '../../lib/canister-proposal-response-parse.mjs';
20 import { runImport } from '../../lib/import.mjs';
21 import { IMPORT_SOURCE_TYPES } from '../../lib/import-source-types.mjs';
22 import { commitImageToRepo, parseGitHubRepoUrl, validateImageExtension, validateMagicBytes } from '../../lib/github-commit-image.mjs';
23 import { mergeProvenanceFrontmatter } from '../../lib/hub-provenance.mjs';
24 import { createIndexTimer } from './index-timing.mjs';
25 import { computeChunkContentHashTagged } from '../../lib/chunk-content-hash.mjs';
26 import {
27 defaultBridgeEmbeddingModelForProvider,
28 resolveIndexerChunkOptions,
29 } from '../../lib/indexer-chunk-options.mjs';
30 import {
31 runWithConcurrency,
32 parseEmbedConcurrency,
33 parseEmbedBatchSize,
34 } from '../../lib/parallel-embed-pool.mjs';
35 import { partitionChunksForReindex } from '../../lib/index-partition.mjs';
36 import {
37 estimateEmbedSeconds,
38 shouldUseBackgroundIndex,
39 parseSyncBudgetSeconds,
40 parseMaxSyncChunks,
41 } from '../../lib/bridge-index-preflight-estimate.mjs';
42 import {
43 acquireJobLock,
44 releaseJobLock,
45 peekJobLock,
46 } from '../../lib/bridge-index-job-lock.mjs';
47 import {
48 setLastIndexedAt,
49 getLastIndexedAt,
50 } from '../../lib/bridge-index-last-indexed.mjs';
51 import { signInternalRequest } from '../../lib/bridge-internal-hmac.mjs';
52 import { assertBackgroundKickoffOk } from '../../lib/bridge-index-kickoff-response.mjs';
53 import { writeNote } from '../../lib/write.mjs';
54 import { resolveVaultRelativePath, parseFrontmatterAndBody } from '../../lib/vault.mjs';
55 import {
56 resolveEffectiveCanisterUser,
57 getScopeForUserVaultFromScopeMap,
58 resolveAllowedVaultIdsForHostedContext,
59 } from '../lib/hosted-workspace-resolve.mjs';
60 import { applyScopeFilterToNotes, applyScopeFilterToProposals } from '../lib/scope-filter.mjs';
61 import { verifyJwtWithSecretRotation, resolveSessionSecretPrevious } from '../lib/session-secret-rotation.mjs';
62 import { actorMayApproveProposals } from '../lib/hub-evaluator-may-approve.mjs';
63 import {
64 buildCalendarTimeline,
65 listSourceCalendarsForClient,
66 } from '../../lib/calendar/timeline.mjs';
67 import { importIcsIntoVault } from '../../lib/calendar/event-store.mjs';
68 import { patchSourceCalendar, parseSourceCalendarPatchBody } from '../../lib/calendar/source-calendar-patch.mjs';
69 import { retrieveAgentCalendarContext } from '../../lib/calendar/agent-retrieval.mjs';
70 import {
71 handleBeginGoogleConnector,
72 handleListGoogleConnectors,
73 } from '../../lib/calendar/google-oauth-connector.mjs';
74 import { withCalendarBlobSync } from './calendar-blob-store.mjs';
75 import { materializeListFrontmatter } from '../gateway/note-facets.mjs';
76 import { registerBridgeDelegationRoutes } from './delegation-routes.mjs';
77 import { registerBridgeTaskRoutes } from './task-routes.mjs';
78 import { registerBridgePathRoutes } from './path-routes.mjs';
79 import { registerBridgeFlowRoutes } from './flow-routes.mjs';
80 import { registerBridgeFlowCaptureRoutes } from './flow-capture-routes.mjs';
81 import { registerBridgeFlowRunRoutes } from './flow-run-routes.mjs';
82 import { registerBridgeMediaRoutes } from './media-routes.mjs';
83 import { registerBridgeDocsRoutes } from './docs-routes.mjs';
84 import { registerBridgeExternalAgentRoutes } from './external-agent-routes.mjs';
85
86 // When Netlify bundles as CJS, import.meta.url is empty; avoid it in serverless so the app loads and routes register.
87 const inServerless = Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.NETLIFY);
88 let projectRoot;
89 if (inServerless) {
90 projectRoot = process.cwd();
91 } else {
92 projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
93 }
94 const __dirname = path.join(projectRoot, 'hub', 'bridge');
95 const envPath = path.join(projectRoot, '.env');
96 if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
97
98 const PORT = parseInt(process.env.BRIDGE_PORT || process.env.PORT || '3341', 10);
99 const BASE_URL = (process.env.HUB_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, '');
100 const CANISTER_URL = (process.env.CANISTER_URL || '').replace(/\/$/, '');
101 const HUB_UI_ORIGIN = (process.env.HUB_UI_ORIGIN || BASE_URL).replace(/\/$/, '');
102 // Path under HUB_UI_ORIGIN where the Hub app lives (e.g. /hub). Empty string = root.
103 const HUB_UI_PATH = (process.env.HUB_UI_PATH || '/hub').replace(/\/$/, '');
104 const SESSION_SECRET = process.env.SESSION_SECRET || process.env.HUB_JWT_SECRET;
105 // SEC-KN-P6-ROTATE: verify-only previous secret for zero-downtime rotation.
106 // signState/verifyState HMAC and GitHub-token encrypt stay on SESSION_SECRET only.
107 const SESSION_SECRET_PREVIOUS = resolveSessionSecretPrevious();
108 const CANISTER_AUTH_SECRET = process.env.CANISTER_AUTH_SECRET || '';
109 const HOSTED_CONTEXT_FETCH_TIMEOUT_MS = (() => {
110 const n = parseInt(String(process.env.HOSTED_CONTEXT_FETCH_TIMEOUT_MS || ''), 10);
111 if (!Number.isFinite(n)) return 3000;
112 return Math.min(10_000, Math.max(250, n));
113 })();
114 const HOSTED_CONTEXT_CACHE_TTL_MS = 60_000;
115 const canisterVaultIdsCache = new Map();
116
117 function hostedContextAbortSignal() {
118 return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
119 ? AbortSignal.timeout(HOSTED_CONTEXT_FETCH_TIMEOUT_MS)
120 : undefined;
121 }
122
123 /**
124 * Base headers for all bridge→canister requests.
125 * Includes x-gateway-auth when CANISTER_AUTH_SECRET is configured so the
126 * canister's gatewayAuthorized() check (Phase 0) passes.
127 * Uses the same env var name as the gateway (CANISTER_AUTH_SECRET).
128 */
129 function canisterHeaders(extra = {}) {
130 const h = { Accept: 'application/json', ...extra };
131 if (CANISTER_AUTH_SECRET) h['x-gateway-auth'] = CANISTER_AUTH_SECRET;
132 return h;
133 }
134 // On Netlify Lambda /var/task/ is read-only; only /tmp is writable.
135 // Use /tmp/knowtation-bridge-data when serverless and DATA_DIR is not explicitly set.
136 const DATA_DIR = process.env.DATA_DIR
137 ? (path.isAbsolute(process.env.DATA_DIR) ? process.env.DATA_DIR : path.join(projectRoot, process.env.DATA_DIR))
138 : (inServerless ? path.join(os.tmpdir(), 'knowtation-bridge-data') : path.join(projectRoot, 'data'));
139 const TOKENS_FILE = path.join(DATA_DIR, 'hub_github_tokens.json');
140 const ROLES_FILE = path.join(DATA_DIR, 'hub_roles.json');
141 const INVITES_FILE = path.join(DATA_DIR, 'hub_invites.json');
142 const WORKSPACE_FILE = path.join(DATA_DIR, 'hub_workspace.json');
143 const VAULT_ACCESS_FILE = path.join(DATA_DIR, 'hub_vault_access.json');
144 const SCOPE_FILE = path.join(DATA_DIR, 'hub_scope.json');
145 const EVALUATOR_MAY_APPROVE_FILE = path.join(DATA_DIR, 'hub_evaluator_may_approve.json');
146 const VALID_ROLES = new Set(['admin', 'editor', 'viewer', 'evaluator']);
147 const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
148
149 const adminUserIdsSet = new Set(
150 (process.env.HUB_ADMIN_USER_IDS || '')
151 .split(',')
152 .map((s) => s.trim())
153 .filter(Boolean)
154 );
155
156 function sanitizeUserId(uid) {
157 return String(uid).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128) || 'default';
158 }
159
160 function sanitizeVaultId(vaultId) {
161 return String(vaultId || 'default').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'default';
162 }
163
164 let warnedOllamaLocalhostOnNetlify = false;
165
166 /** Trim + default empty env so accidental whitespace does not break provider matching or Ollama URL. */
167 function getBridgeEmbeddingConfig() {
168 const pEnv = process.env.EMBEDDING_PROVIDER;
169 const provider = (
170 pEnv == null || String(pEnv).trim() === '' ? 'ollama' : String(pEnv).trim()
171 ).toLowerCase();
172 const mEnv = process.env.EMBEDDING_MODEL;
173 const model =
174 mEnv == null || String(mEnv).trim() === ''
175 ? defaultBridgeEmbeddingModelForProvider(provider)
176 : String(mEnv).trim();
177 const oEnv = process.env.OLLAMA_URL;
178 const ollama_url =
179 oEnv == null || String(oEnv).trim() === '' ? 'http://localhost:11434' : String(oEnv).trim();
180 if (inServerless && provider === 'ollama' && !warnedOllamaLocalhostOnNetlify) {
181 warnedOllamaLocalhostOnNetlify = true;
182 const t = String(ollama_url).trim() || 'http://localhost:11434';
183 try {
184 if (/^https?:\/\//i.test(t)) {
185 const u = new URL(t);
186 if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
187 console.warn(
188 '[bridge] EMBEDDING_PROVIDER=ollama with localhost OLLAMA_URL cannot reach your machine from Netlify. ' +
189 'Set EMBEDDING_PROVIDER=openai and OPENAI_API_KEY, or OLLAMA_URL to a public https:// Ollama API base.',
190 );
191 }
192 }
193 } catch (_) {
194 /* embed path will throw a clearer error via normalizeOllamaEmbedBaseUrl */
195 }
196 }
197 return {
198 provider,
199 model,
200 ollama_url,
201 };
202 }
203
204 /**
205 * Undici/fetch often throws TypeError with message "Invalid URL" only — map to actionable text for operators.
206 * @param {unknown} err
207 * @param {'index'|'search'|'embed'} kind
208 */
209 function bridgeEmbedFailureMessage(err, kind) {
210 const raw = err && typeof err.message === 'string' ? err.message : String(err);
211 if (raw !== 'Invalid URL' && !raw.includes('Invalid URL')) return raw;
212 const c = getBridgeEmbeddingConfig();
213 const hasOpenAiKey = Boolean(
214 process.env.OPENAI_API_KEY && String(process.env.OPENAI_API_KEY).trim(),
215 );
216 const hasVoyageKey = Boolean(process.env.VOYAGE_API_KEY && String(process.env.VOYAGE_API_KEY).trim());
217 return (
218 `${raw} (${kind}). On Netlify, Invalid URL often means sqlite-vec was esbuild-bundled ` +
219 '(stack: getLoadablePath / input ".") — set [functions].external_node_modules for sqlite-vec and better-sqlite3 in netlify.toml. ' +
220 `Resolved EMBEDDING_PROVIDER="${c.provider}"; OPENAI_API_KEY ${hasOpenAiKey ? 'is set' : 'is missing'}; ` +
221 `VOYAGE_API_KEY ${hasVoyageKey ? 'is set' : 'is missing'}. ` +
222 'If provider is ollama, OLLAMA_URL must be a full http(s) URL. Remove bad HTTP_PROXY/HTTPS_PROXY if set. ' +
223 'See hub/bridge/README.md (semantic index/search).'
224 );
225 }
226
227 const DB_FILENAME = 'knowtation_vectors.db';
228
229 function getBridgeStoreConfig(uid, vectorsDirOverride) {
230 const vectorsDir = vectorsDirOverride ?? (() => {
231 const d = path.join(DATA_DIR, 'vectors', sanitizeUserId(uid));
232 if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
233 return d;
234 })();
235 return {
236 vector_store: 'sqlite-vec',
237 data_dir: vectorsDir,
238 embedding: getBridgeEmbeddingConfig(),
239 // Bridge owns the data lifecycle (downloads from blob → re-indexes → uploads to blob).
240 // A dimension change (e.g. OpenAI 1536 → DeepInfra 1024) can only resolve via a full
241 // re-embed of every vault in this DB. CLI keeps the throw so an accidental swap surfaces
242 // loudly. See `lib/vector-store-sqlite.mjs ensureCollection` migration logic.
243 allow_dimension_migration: true,
244 };
245 }
246
247 const isServerless = Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.NETLIFY);
248
249 function ensureDataDir() {
250 if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
251 }
252
253 const ALGO = 'aes-256-gcm';
254 const IV_LEN = 16;
255 const TAG_LEN = 16;
256 const SALT_LEN = 16;
257 // Ciphertext format (v2): saltB64url.ivB64url.tagB64url.encB64url (4 parts)
258 // Legacy format (v1): ivB64url.tagB64url.encB64url (3 parts — decrypt will return null → graceful reconnect)
259 function encrypt(text, secret) {
260 const salt = crypto.randomBytes(SALT_LEN);
261 const key = crypto.scryptSync(secret, salt, 32);
262 const iv = crypto.randomBytes(IV_LEN);
263 const cipher = crypto.createCipheriv(ALGO, key, iv);
264 const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
265 const tag = cipher.getAuthTag();
266 return (
267 salt.toString('base64url') + '.' +
268 iv.toString('base64url') + '.' +
269 tag.toString('base64url') + '.' +
270 enc.toString('base64url')
271 );
272 }
273 function decrypt(encrypted, secret) {
274 const parts = encrypted.split('.');
275 // v1 ciphertexts had 3 parts (hardcoded salt); treat as not-found so the
276 // caller falls through to "prompt reconnect" without crashing.
277 if (parts.length !== 4) return null;
278 const [saltB, ivB, tagB, encB] = parts;
279 if (!saltB || !ivB || !tagB || !encB) return null;
280 try {
281 const key = crypto.scryptSync(secret, Buffer.from(saltB, 'base64url'), 32);
282 const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB, 'base64url'));
283 decipher.setAuthTag(Buffer.from(tagB, 'base64url'));
284 return decipher.update(Buffer.from(encB, 'base64url')) + decipher.final('utf8');
285 } catch {
286 return null;
287 }
288 }
289
290 function parseAndDecryptTokens(raw) {
291 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
292 const out = {};
293 let decryptFailures = 0;
294 for (const [uid, v] of Object.entries(raw)) {
295 if (v && typeof v.token === 'string') {
296 const t = decrypt(v.token, SESSION_SECRET);
297 if (t) out[uid] = { token: t, repo: v.repo || null };
298 else decryptFailures++;
299 }
300 }
301 if (decryptFailures > 0) {
302 console.warn(
303 '[bridge] loadTokens: decrypt failed for',
304 decryptFailures,
305 'stored GitHub token(s). If SESSION_SECRET was rotated on the bridge, run Connect GitHub again to re-store the token.'
306 );
307 }
308 return out;
309 }
310
311 async function loadTokens(blobStore) {
312 if (!blobStore) {
313 ensureDataDir();
314 if (!fs.existsSync(TOKENS_FILE)) return {};
315 try {
316 const raw = JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf8'));
317 return parseAndDecryptTokens(raw);
318 } catch (_) {
319 return {};
320 }
321 }
322 try {
323 const rawStr = await blobStore.get('hub_github_tokens');
324 if (!rawStr) return {};
325 const raw = JSON.parse(rawStr);
326 return parseAndDecryptTokens(raw);
327 } catch (_) {
328 return {};
329 }
330 }
331
332 async function saveTokens(blobStore, tokens) {
333 const toWrite = {};
334 for (const [uid, v] of Object.entries(tokens)) {
335 toWrite[uid] = { token: encrypt(v.token, SESSION_SECRET), repo: v.repo || null };
336 }
337 const str = JSON.stringify(toWrite, null, 2);
338 if (!blobStore) {
339 ensureDataDir();
340 fs.writeFileSync(TOKENS_FILE, str, 'utf8');
341 return;
342 }
343 await blobStore.set('hub_github_tokens', str);
344 }
345
346 // ——— Roles & invites (hosted parity: same contract as self-hosted hub/roles.mjs, hub/invites.mjs) ———
347 async function loadRoles(blobStore) {
348 if (!blobStore) {
349 ensureDataDir();
350 if (!fs.existsSync(ROLES_FILE)) return {};
351 try {
352 const data = JSON.parse(fs.readFileSync(ROLES_FILE, 'utf8'));
353 const roles = data.roles != null ? data.roles : data;
354 return typeof roles === 'object' && roles !== null ? roles : {};
355 } catch (_) {
356 return {};
357 }
358 }
359 try {
360 const rawStr = await blobStore.get('hub_roles');
361 if (!rawStr) return {};
362 const data = JSON.parse(rawStr);
363 const roles = data.roles != null ? data.roles : data;
364 return typeof roles === 'object' && roles !== null ? roles : {};
365 } catch (_) {
366 return {};
367 }
368 }
369
370 async function saveRoles(blobStore, roles) {
371 const obj = {};
372 for (const [sub, role] of Object.entries(roles)) {
373 if (typeof sub === 'string' && sub.trim() && VALID_ROLES.has(role)) obj[sub.trim()] = role;
374 }
375 const str = JSON.stringify({ roles: obj }, null, 2);
376 if (!blobStore) {
377 ensureDataDir();
378 fs.writeFileSync(ROLES_FILE, str, 'utf8');
379 return;
380 }
381 await blobStore.set('hub_roles', str);
382 }
383
384 function bridgeEnvEvaluatorMayApprove() {
385 return process.env.HUB_EVALUATOR_MAY_APPROVE === '1';
386 }
387
388 async function loadEvaluatorMayApproveMap(blobStore) {
389 if (!blobStore) {
390 ensureDataDir();
391 if (!fs.existsSync(EVALUATOR_MAY_APPROVE_FILE)) return {};
392 try {
393 const data = JSON.parse(fs.readFileSync(EVALUATOR_MAY_APPROVE_FILE, 'utf8'));
394 const m = data?.evaluator_may_approve != null ? data.evaluator_may_approve : data;
395 if (typeof m !== 'object' || m === null) return {};
396 const out = {};
397 for (const [k, v] of Object.entries(m)) {
398 if (typeof k === 'string' && k.trim()) out[k.trim()] = Boolean(v);
399 }
400 return out;
401 } catch (_) {
402 return {};
403 }
404 }
405 try {
406 const rawStr = await blobStore.get('hub_evaluator_may_approve');
407 if (!rawStr) return {};
408 const data = JSON.parse(rawStr);
409 const m = data?.evaluator_may_approve != null ? data.evaluator_may_approve : data;
410 if (typeof m !== 'object' || m === null) return {};
411 const out = {};
412 for (const [k, v] of Object.entries(m)) {
413 if (typeof k === 'string' && k.trim()) out[k.trim()] = Boolean(v);
414 }
415 return out;
416 } catch (_) {
417 return {};
418 }
419 }
420
421 async function saveEvaluatorMayApproveMap(blobStore, map) {
422 const obj = {};
423 for (const [k, v] of Object.entries(map)) {
424 if (typeof k === 'string' && k.trim()) obj[k.trim()] = Boolean(v);
425 }
426 const str = JSON.stringify({ evaluator_may_approve: obj }, null, 2);
427 if (!blobStore) {
428 ensureDataDir();
429 fs.writeFileSync(EVALUATOR_MAY_APPROVE_FILE, str, 'utf8');
430 return;
431 }
432 await blobStore.set('hub_evaluator_may_approve', str);
433 }
434
435 /** Effective “may approve proposals” for Hub UI and gateway (admin always; evaluator from map + env). */
436 function mayApproveProposalsForUser(uid, storedRoles, mayMap) {
437 const role = effectiveRole(uid, storedRoles);
438 return actorMayApproveProposals(uid, role, mayMap, bridgeEnvEvaluatorMayApprove());
439 }
440
441 async function loadInvites(blobStore) {
442 if (!blobStore) {
443 ensureDataDir();
444 if (!fs.existsSync(INVITES_FILE)) return {};
445 try {
446 const data = JSON.parse(fs.readFileSync(INVITES_FILE, 'utf8'));
447 const invites = data.invites && typeof data.invites === 'object' ? data.invites : {};
448 return invites;
449 } catch (_) {
450 return {};
451 }
452 }
453 try {
454 const rawStr = await blobStore.get('hub_invites');
455 if (!rawStr) return {};
456 const data = JSON.parse(rawStr);
457 const invites = data.invites && typeof data.invites === 'object' ? data.invites : {};
458 return invites;
459 } catch (_) {
460 return {};
461 }
462 }
463
464 async function saveInvites(blobStore, invites) {
465 const obj = {};
466 for (const [token, entry] of Object.entries(invites)) {
467 if (typeof token === 'string' && token && entry && typeof entry.role === 'string' && typeof entry.created_at === 'string') {
468 obj[token] = { role: entry.role, created_at: entry.created_at };
469 }
470 }
471 const str = JSON.stringify({ invites: obj }, null, 2);
472 if (!blobStore) {
473 ensureDataDir();
474 fs.writeFileSync(INVITES_FILE, str, 'utf8');
475 return;
476 }
477 await blobStore.set('hub_invites', str);
478 }
479
480 async function loadWorkspace(blobStore) {
481 if (!blobStore) {
482 ensureDataDir();
483 if (!fs.existsSync(WORKSPACE_FILE)) return { owner_user_id: null };
484 try {
485 const data = JSON.parse(fs.readFileSync(WORKSPACE_FILE, 'utf8'));
486 const id = data?.owner_user_id;
487 return { owner_user_id: typeof id === 'string' && id.trim() ? id.trim() : null };
488 } catch (_) {
489 return { owner_user_id: null };
490 }
491 }
492 try {
493 const rawStr = await blobStore.get('hub_workspace');
494 if (!rawStr) return { owner_user_id: null };
495 const data = JSON.parse(rawStr);
496 const id = data?.owner_user_id;
497 return { owner_user_id: typeof id === 'string' && id.trim() ? id.trim() : null };
498 } catch (_) {
499 return { owner_user_id: null };
500 }
501 }
502
503 async function saveWorkspace(blobStore, ownerUserId) {
504 const payload = JSON.stringify(
505 { owner_user_id: ownerUserId && String(ownerUserId).trim() ? String(ownerUserId).trim() : null },
506 null,
507 2,
508 );
509 if (!blobStore) {
510 ensureDataDir();
511 fs.writeFileSync(WORKSPACE_FILE, payload, 'utf8');
512 return;
513 }
514 await blobStore.set('hub_workspace', payload);
515 }
516
517 async function loadVaultAccess(blobStore) {
518 if (!blobStore) {
519 ensureDataDir();
520 if (!fs.existsSync(VAULT_ACCESS_FILE)) return {};
521 try {
522 const data = JSON.parse(fs.readFileSync(VAULT_ACCESS_FILE, 'utf8'));
523 const out = {};
524 if (data && typeof data === 'object') {
525 for (const [uid, arr] of Object.entries(data)) {
526 if (typeof uid === 'string' && uid.trim() && Array.isArray(arr)) {
527 out[uid.trim()] = arr.filter((v) => typeof v === 'string' && v.trim()).map((v) => v.trim());
528 }
529 }
530 }
531 return out;
532 } catch (_) {
533 return {};
534 }
535 }
536 try {
537 const rawStr = await blobStore.get('hub_vault_access');
538 if (!rawStr) return {};
539 const data = JSON.parse(rawStr);
540 const out = {};
541 if (data && typeof data === 'object') {
542 for (const [uid, arr] of Object.entries(data)) {
543 if (typeof uid === 'string' && uid.trim() && Array.isArray(arr)) {
544 out[uid.trim()] = arr.filter((v) => typeof v === 'string' && v.trim()).map((v) => v.trim());
545 }
546 }
547 }
548 return out;
549 } catch (_) {
550 return {};
551 }
552 }
553
554 async function saveVaultAccess(blobStore, access) {
555 const obj = {};
556 for (const [uid, arr] of Object.entries(access || {})) {
557 if (typeof uid === 'string' && uid.trim() && Array.isArray(arr)) {
558 obj[uid.trim()] = arr.filter((v) => typeof v === 'string' && v.trim()).map((v) => v.trim());
559 }
560 }
561 const str = JSON.stringify(obj, null, 2);
562 if (!blobStore) {
563 ensureDataDir();
564 fs.writeFileSync(VAULT_ACCESS_FILE, str, 'utf8');
565 return;
566 }
567 await blobStore.set('hub_vault_access', str);
568 }
569
570 async function loadScope(blobStore) {
571 if (!blobStore) {
572 ensureDataDir();
573 if (!fs.existsSync(SCOPE_FILE)) return {};
574 try {
575 const data = JSON.parse(fs.readFileSync(SCOPE_FILE, 'utf8'));
576 return data && typeof data === 'object' ? data : {};
577 } catch (_) {
578 return {};
579 }
580 }
581 try {
582 const rawStr = await blobStore.get('hub_scope');
583 if (!rawStr) return {};
584 const data = JSON.parse(rawStr);
585 return data && typeof data === 'object' ? data : {};
586 } catch (_) {
587 return {};
588 }
589 }
590
591 async function saveScope(blobStore, scope) {
592 const cleaned = {};
593 for (const [uid, vaultMap] of Object.entries(scope || {})) {
594 if (typeof uid !== 'string' || !uid.trim() || !vaultMap || typeof vaultMap !== 'object') continue;
595 cleaned[uid.trim()] = {};
596 for (const [vaultId, rules] of Object.entries(vaultMap)) {
597 if (typeof vaultId !== 'string' || !vaultId.trim() || !rules || typeof rules !== 'object') continue;
598 const projects = Array.isArray(rules.projects)
599 ? rules.projects.filter((p) => typeof p === 'string' && p.trim()).map((p) => p.trim())
600 : [];
601 const folders = Array.isArray(rules.folders)
602 ? rules.folders.filter((f) => typeof f === 'string' && f.trim()).map((f) => f.trim())
603 : [];
604 if (projects.length > 0 || folders.length > 0) {
605 cleaned[uid.trim()][vaultId.trim()] = { projects, folders };
606 }
607 }
608 }
609 const str = JSON.stringify(cleaned, null, 2);
610 if (!blobStore) {
611 ensureDataDir();
612 fs.writeFileSync(SCOPE_FILE, str, 'utf8');
613 return;
614 }
615 await blobStore.set('hub_scope', str);
616 }
617
618 /** Remove vault id from all hub_vault_access lists and hub_scope maps (hosted team). */
619 async function stripHostedVaultFromAccessAndScope(blobStore, vaultId) {
620 const id = String(vaultId || '').trim();
621 if (!id || id === 'default') return;
622 const access = await loadVaultAccess(blobStore);
623 const nextAccess = {};
624 for (const [uid, arr] of Object.entries(access)) {
625 if (!Array.isArray(arr)) continue;
626 const filtered = arr.filter((x) => String(x).trim() !== id);
627 if (filtered.length > 0) nextAccess[uid] = filtered;
628 }
629 await saveVaultAccess(blobStore, nextAccess);
630
631 const scope = await loadScope(blobStore);
632 const nextScope = {};
633 for (const [uid, vmap] of Object.entries(scope)) {
634 if (!vmap || typeof vmap !== 'object') continue;
635 const inner = {};
636 for (const [vid, rules] of Object.entries(vmap)) {
637 if (String(vid).trim() === id) continue;
638 inner[vid] = rules;
639 }
640 if (Object.keys(inner).length > 0) nextScope[uid] = inner;
641 }
642 await saveScope(blobStore, nextScope);
643 }
644
645 /** Drop bridge vector store for (effective user, vault). */
646 async function removeHostedVectorBlobForVault(blobStore, effectiveUid, vaultId) {
647 const safeUid = sanitizeUserId(effectiveUid);
648 const vid = sanitizeVaultId(vaultId);
649 const localDir = path.join(DATA_DIR, 'vectors', safeUid, vid);
650 if (!blobStore) {
651 if (fs.existsSync(localDir)) fs.rmSync(localDir, { recursive: true, force: true });
652 return;
653 }
654 const key = 'vectors/' + safeUid + '/' + vid;
655 try {
656 if (typeof blobStore.delete === 'function') await blobStore.delete(key);
657 } catch (_) {
658 /* Netlify Blobs may omit delete; ignore */
659 }
660 }
661
662 /** @returns {Promise<string[]>} */
663 async function fetchCanisterVaultIdsForUser(canisterUserId) {
664 if (!CANISTER_URL || !canisterUserId) return ['default'];
665 const cacheKey = String(canisterUserId);
666 const now = Date.now();
667 const hit = canisterVaultIdsCache.get(cacheKey);
668 if (hit && hit.expires > now) return [...hit.ids];
669 try {
670 const signal = hostedContextAbortSignal();
671 const vRes = await fetch(CANISTER_URL + '/api/v1/vaults', {
672 method: 'GET',
673 headers: canisterHeaders({ 'X-User-Id': canisterUserId }),
674 ...(signal ? { signal } : {}),
675 });
676 if (!vRes.ok) return ['default'];
677 const data = await vRes.json();
678 const vaults = Array.isArray(data.vaults) ? data.vaults : [];
679 if (vaults.length === 0) return ['default'];
680 const ids = vaults.map((v) => String(v.id || 'default')).filter(Boolean);
681 canisterVaultIdsCache.set(cacheKey, { expires: now + HOSTED_CONTEXT_CACHE_TTL_MS, ids });
682 return ids;
683 } catch (_) {
684 return ['default'];
685 }
686 }
687
688 function explicitVaultAccessForUser(accessMap, actorUid) {
689 const raw = accessMap && typeof accessMap === 'object' ? accessMap[actorUid] : null;
690 if (!Array.isArray(raw) || raw.length === 0) return null;
691 const out = raw.map((x) => String(x).trim()).filter(Boolean);
692 return out.length > 0 ? out : null;
693 }
694
695 /**
696 * @param {import('express').Request} req
697 * @param {string} actorUid
698 * @returns {Promise<{ ok: true, effectiveCanisterUid: string, actorUid: string, vaultId: string, scope: { projects: string[], folders: string[] } | null, allowedVaultIds: string[], delegating: boolean } | { ok: false, status: number, code: string, error: string }>}
699 */
700 async function resolveHostedBridgeContext(req, actorUid) {
701 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
702 const workspace = await loadWorkspace(req.blobStore);
703 const roles = await loadRoles(req.blobStore);
704 const access = await loadVaultAccess(req.blobStore);
705 const scopeMap = await loadScope(req.blobStore);
706 const ownerId = workspace.owner_user_id;
707 const { effective, delegate } = resolveEffectiveCanisterUser({
708 actorSub: actorUid,
709 workspaceOwnerId: ownerId,
710 storedRoles: roles,
711 adminUserIdsSet,
712 });
713 const explicitVaultIds = explicitVaultAccessForUser(access, actorUid);
714 const canisterIds =
715 delegate && explicitVaultIds ? explicitVaultIds : await fetchCanisterVaultIdsForUser(effective);
716 const allowedVaultIds = resolveAllowedVaultIdsForHostedContext({
717 delegate,
718 actorUid,
719 accessMap: access,
720 canisterIds,
721 });
722 if (!allowedVaultIds.includes(vaultId)) {
723 return {
724 ok: false,
725 status: 403,
726 code: 'FORBIDDEN',
727 error: 'Access to this vault is not allowed.',
728 };
729 }
730 let scope = getScopeForUserVaultFromScopeMap(scopeMap, actorUid, vaultId);
731 // Evaluators must see the full vault (per allowed_vault_ids) to review proposals in context;
732 // project/folder scope still applies to viewer/editor/admin delegating members.
733 const actorRole = effectiveRole(actorUid, roles);
734 if (actorRole === 'evaluator') {
735 scope = null;
736 }
737 return {
738 ok: true,
739 effectiveCanisterUid: effective,
740 actorUid,
741 vaultId,
742 scope,
743 allowedVaultIds,
744 delegating: delegate,
745 };
746 }
747
748 /**
749 * Hosted settings need the actor's vault allowlist without first proving access
750 * to a specific vault. This keeps Business-only delegated users from being
751 * denied while the UI is still deciding which vault to select.
752 *
753 * @param {import('express').Request} req
754 * @param {string} actorUid
755 */
756 async function resolveHostedBridgeSettingsContext(req, actorUid) {
757 const workspace = await loadWorkspace(req.blobStore);
758 const roles = await loadRoles(req.blobStore);
759 const access = await loadVaultAccess(req.blobStore);
760 const ownerId = workspace.owner_user_id;
761 const { effective, delegate } = resolveEffectiveCanisterUser({
762 actorSub: actorUid,
763 workspaceOwnerId: ownerId,
764 storedRoles: roles,
765 adminUserIdsSet,
766 });
767 const explicitVaultIds = explicitVaultAccessForUser(access, actorUid);
768 const canisterIds =
769 delegate && explicitVaultIds ? explicitVaultIds : await fetchCanisterVaultIdsForUser(effective);
770 const allowedVaultIds = resolveAllowedVaultIdsForHostedContext({
771 delegate,
772 actorUid,
773 accessMap: access,
774 canisterIds,
775 });
776 return {
777 effectiveCanisterUid: effective,
778 actorUid,
779 allowedVaultIds,
780 delegating: delegate,
781 workspaceOwnerId: ownerId,
782 role: effectiveRole(actorUid, roles),
783 };
784 }
785
786 function effectiveRole(uid, storedRoles) {
787 if (!uid) return 'member';
788 const stored = storedRoles && storedRoles[uid];
789 if (stored && VALID_ROLES.has(stored)) return stored;
790 return adminUserIdsSet.has(uid) ? 'admin' : 'member';
791 }
792
793 /** Return a directory path that contains (or will contain) knowtation_vectors.db for this user and vault. Rehydrates from Blob if needed. Phase 15: keyed by (uid, vault_id). */
794 async function getVectorsDirForUser(req, uid) {
795 const safeUid = sanitizeUserId(uid);
796 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
797 if (!req.blobStore) {
798 const d = path.join(DATA_DIR, 'vectors', safeUid, vaultId);
799 if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
800 return d;
801 }
802 const dir = path.join(os.tmpdir(), 'knowtation-bridge-vectors', safeUid, vaultId);
803 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
804 const key = 'vectors/' + safeUid + '/' + vaultId;
805 try {
806 const data = await req.blobStore.get(key, { type: 'arrayBuffer' });
807 if (data && data.byteLength > 0) {
808 fs.writeFileSync(path.join(dir, DB_FILENAME), Buffer.from(data));
809 }
810 } catch (_) {
811 // No existing blob or read error; start fresh
812 }
813 return dir;
814 }
815
816 /** Persist user's vector DB from disk to Blob (call after index). Phase 15: key includes vault_id. */
817 async function persistVectorsToBlob(req, uid, vectorsDir) {
818 if (!req.blobStore) return;
819 const dbPath = path.join(vectorsDir, DB_FILENAME);
820 if (!fs.existsSync(dbPath)) return;
821 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
822 const key = 'vectors/' + sanitizeUserId(uid) + '/' + vaultId;
823 const buf = fs.readFileSync(dbPath);
824 const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
825 await req.blobStore.set(key, arrayBuffer);
826 }
827
828 function signState(payload) {
829 const payloadStr = JSON.stringify(payload);
830 const sig = crypto.createHmac('sha256', SESSION_SECRET).update(payloadStr).digest('hex');
831 return Buffer.from(payloadStr).toString('base64url') + '.' + sig;
832 }
833
834 function verifyState(stateStr, maxAgeMs = 600000) {
835 if (!stateStr || typeof stateStr !== 'string') return null;
836 const [b64, sig] = stateStr.split('.');
837 if (!b64 || !sig) return null;
838 try {
839 const payload = JSON.parse(Buffer.from(b64, 'base64url').toString());
840 const expected = crypto.createHmac('sha256', SESSION_SECRET).update(JSON.stringify(payload)).digest('hex');
841 if (expected !== sig) return null;
842 if (Date.now() - (payload.ts || 0) > maxAgeMs) return null;
843 return payload;
844 } catch (_) {
845 return null;
846 }
847 }
848
849 function userIdFromJwt(token) {
850 const payload = verifyJwtWithSecretRotation(token, SESSION_SECRET, SESSION_SECRET_PREVIOUS);
851 return payload ? payload.sub ?? null : null;
852 }
853
854 const app = express();
855 app.use(express.json({ limit: '1mb' }));
856
857 app.use((req, _res, next) => {
858 req.blobStore = globalThis.__netlify_blob_store || null;
859 next();
860 });
861
862 // Background-function marker. `netlify/functions/bridge-index-background.mjs`
863 // validates an HMAC-signed inbound request, then sets
864 // `globalThis.__bridge_internal_request = { canisterUid, vaultId, jobId }` BEFORE
865 // invoking the same Express app via serverless-http. The index handler reads
866 // `req.bridgeInternalRequest` to decide whether to route (sync path) or skip
867 // routing and execute inline (background path). Globals are used because
868 // serverless-http does not let us inject per-request locals from the wrapper.
869 app.use((req, _res, next) => {
870 req.bridgeInternalRequest = globalThis.__bridge_internal_request || null;
871 next();
872 });
873
874 app.use((_req, res, next) => {
875 res.set('Access-Control-Allow-Origin', process.env.HUB_CORS_ORIGIN || '*');
876 res.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
877 res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Vault-Id');
878 res.set('Access-Control-Allow-Credentials', 'true');
879 next();
880 });
881
882 // When Netlify rewrites /* to /.netlify/functions/bridge/:splat, Express sees the full path; strip prefix so routes match.
883 if (inServerless) {
884 const bridgePrefix = '/.netlify/functions/bridge';
885 app.use((req, _res, next) => {
886 if (req.url.startsWith(bridgePrefix)) {
887 req.url = req.url.slice(bridgePrefix.length) || '/';
888 }
889 next();
890 });
891 }
892
893 // Public deploy probe (no auth): compare to Netlify **knowtation-bridge** Production commit vs gateway.
894 app.get('/api/v1/bridge-version', (_req, res) => {
895 res.json({
896 service: 'knowtation-bridge',
897 commit: process.env.COMMIT_REF || process.env.VERCEL_GIT_COMMIT_SHA || null,
898 deploy_id: process.env.DEPLOY_ID || null,
899 context: process.env.CONTEXT || null,
900 netlify: Boolean(process.env.NETLIFY || process.env.AWS_LAMBDA_FUNCTION_NAME),
901 });
902 });
903
904 // ——— Roles & invites (hosted parity) ———
905 async function requireBridgeAuth(req, res, next) {
906 const auth = req.headers.authorization;
907 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
908 const uid = token ? userIdFromJwt(token) : null;
909 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
910 req.uid = uid;
911 next();
912 }
913
914 async function requireBridgeAdmin(req, res, next) {
915 const roles = await loadRoles(req.blobStore);
916 const role = effectiveRole(req.uid, roles);
917 if (role !== 'admin') return res.status(403).json({ error: 'Admin only', code: 'FORBIDDEN' });
918 next();
919 }
920
921 /** Import / index parity: viewers cannot write; default role is member (treated like editor for hosted). */
922 async function requireBridgeEditorOrAdmin(req, res, next) {
923 const roles = await loadRoles(req.blobStore);
924 const role = effectiveRole(req.uid, roles);
925 if (role === 'viewer') {
926 return res.status(403).json({ error: 'This action requires editor or admin.', code: 'FORBIDDEN' });
927 }
928 next();
929 }
930
931 app.get('/api/v1/roles', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
932 try {
933 const roles = await loadRoles(req.blobStore);
934 const evaluator_may_approve = await loadEvaluatorMayApproveMap(req.blobStore);
935 res.json({ roles, evaluator_may_approve });
936 } catch (e) {
937 console.error('[bridge] GET /api/v1/roles', e?.message);
938 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
939 }
940 });
941
942 app.post('/api/v1/roles', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
943 const { user_id: userId, role } = req.body || {};
944 if (!userId || typeof userId !== 'string' || !userId.trim()) {
945 return res.status(400).json({ error: 'user_id required (e.g. github:12345)', code: 'BAD_REQUEST' });
946 }
947 const r = (role || 'editor').toLowerCase();
948 if (!VALID_ROLES.has(r)) {
949 return res.status(400).json({ error: 'role must be admin, editor, viewer, or evaluator', code: 'BAD_REQUEST' });
950 }
951 try {
952 const roles = await loadRoles(req.blobStore);
953 const uidKey = userId.trim();
954 roles[uidKey] = r;
955 await saveRoles(req.blobStore, roles);
956 const mayMap = await loadEvaluatorMayApproveMap(req.blobStore);
957 if (r === 'evaluator' && req.body && Object.prototype.hasOwnProperty.call(req.body, 'evaluator_may_approve')) {
958 mayMap[uidKey] = Boolean(req.body.evaluator_may_approve);
959 await saveEvaluatorMayApproveMap(req.blobStore, mayMap);
960 } else if (r !== 'evaluator' && Object.prototype.hasOwnProperty.call(mayMap, uidKey)) {
961 delete mayMap[uidKey];
962 await saveEvaluatorMayApproveMap(req.blobStore, mayMap);
963 }
964 res.json({ ok: true });
965 } catch (e) {
966 console.error('[bridge] POST /api/v1/roles', e?.message);
967 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
968 }
969 });
970
971 app.post('/api/v1/roles/evaluator-may-approve', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
972 const { user_id: userId, evaluator_may_approve: flag } = req.body || {};
973 if (!userId || typeof userId !== 'string' || !userId.trim()) {
974 return res.status(400).json({ error: 'user_id required', code: 'BAD_REQUEST' });
975 }
976 if (typeof flag !== 'boolean') {
977 return res.status(400).json({ error: 'evaluator_may_approve must be boolean', code: 'BAD_REQUEST' });
978 }
979 const uidKey = userId.trim();
980 try {
981 const roles = await loadRoles(req.blobStore);
982 if (effectiveRole(uidKey, roles) !== 'evaluator') {
983 return res.status(400).json({ error: 'User must have evaluator role', code: 'BAD_REQUEST' });
984 }
985 const mayMap = await loadEvaluatorMayApproveMap(req.blobStore);
986 mayMap[uidKey] = flag;
987 await saveEvaluatorMayApproveMap(req.blobStore, mayMap);
988 res.json({ ok: true });
989 } catch (e) {
990 console.error('[bridge] POST /api/v1/roles/evaluator-may-approve', e?.message);
991 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
992 }
993 });
994
995 app.get('/api/v1/invites', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
996 try {
997 const invitesMap = await loadInvites(req.blobStore);
998 const now = Date.now();
999 const list = [];
1000 for (const [token, entry] of Object.entries(invitesMap)) {
1001 const created = new Date(entry.created_at).getTime();
1002 const expires_at = new Date(created + INVITE_EXPIRY_MS).toISOString();
1003 if (now - created <= INVITE_EXPIRY_MS) {
1004 list.push({ token, role: entry.role, created_at: entry.created_at, expires_at });
1005 }
1006 }
1007 list.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1008 res.json({ invites: list });
1009 } catch (e) {
1010 console.error('[bridge] GET /api/v1/invites', e?.message);
1011 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1012 }
1013 });
1014
1015 app.post('/api/v1/invites', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1016 const role = (req.body?.role || 'editor').toLowerCase();
1017 if (!['viewer', 'editor', 'admin', 'evaluator'].includes(role)) {
1018 return res.status(400).json({ error: 'role must be viewer, editor, admin, or evaluator', code: 'BAD_REQUEST' });
1019 }
1020 try {
1021 const token = crypto.randomBytes(24).toString('base64url');
1022 const created_at = new Date().toISOString();
1023 const expires_at = new Date(Date.now() + INVITE_EXPIRY_MS).toISOString();
1024 const invites = await loadInvites(req.blobStore);
1025 invites[token] = { role, created_at };
1026 await saveInvites(req.blobStore, invites);
1027 const base = (HUB_UI_ORIGIN + (HUB_UI_PATH || '/hub') + '/').replace(/(\/)+$/, '/');
1028 const invite_url = base + '?invite=' + encodeURIComponent(token);
1029 res.status(201).json({ invite_url, token, role, created_at, expires_at });
1030 } catch (e) {
1031 console.error('[bridge] POST /api/v1/invites', e?.message);
1032 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1033 }
1034 });
1035
1036 app.delete('/api/v1/invites/:token', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1037 const token = req.params.token;
1038 if (!token) return res.status(400).json({ error: 'token required', code: 'BAD_REQUEST' });
1039 try {
1040 const invites = await loadInvites(req.blobStore);
1041 const had = token in invites;
1042 delete invites[token];
1043 await saveInvites(req.blobStore, invites);
1044 res.json({ ok: true, removed: had });
1045 } catch (e) {
1046 console.error('[bridge] DELETE /api/v1/invites/:token', e?.message);
1047 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1048 }
1049 });
1050
1051 app.post('/api/v1/invites/consume', requireBridgeAuth, async (req, res) => {
1052 const token = req.body?.token;
1053 if (!token || typeof token !== 'string' || !token.trim()) {
1054 return res.status(400).json({ error: 'token required', code: 'BAD_REQUEST' });
1055 }
1056 const uid = req.uid;
1057 try {
1058 const invites = await loadInvites(req.blobStore);
1059 const entry = invites[token];
1060 if (!entry) {
1061 return res.status(404).json({ error: 'Invite not found or already used', code: 'NOT_FOUND' });
1062 }
1063 const created = new Date(entry.created_at).getTime();
1064 if (Date.now() - created > INVITE_EXPIRY_MS) {
1065 delete invites[token];
1066 await saveInvites(req.blobStore, invites);
1067 return res.status(410).json({ error: 'Invite expired', code: 'EXPIRED' });
1068 }
1069 const roles = await loadRoles(req.blobStore);
1070 roles[uid] = entry.role;
1071 await saveRoles(req.blobStore, roles);
1072 delete invites[token];
1073 await saveInvites(req.blobStore, invites);
1074 res.json({ ok: true, role: entry.role });
1075 } catch (e) {
1076 console.error('[bridge] POST /api/v1/invites/consume', e?.message);
1077 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1078 }
1079 });
1080
1081 // For gateway GET /api/v1/settings: return role from bridge store so invited users get correct role
1082 app.get('/api/v1/role', requireBridgeAuth, async (req, res) => {
1083 try {
1084 const roles = await loadRoles(req.blobStore);
1085 const mayMap = await loadEvaluatorMayApproveMap(req.blobStore);
1086 const role = effectiveRole(req.uid, roles);
1087 const may_approve_proposals = mayApproveProposalsForUser(req.uid, roles, mayMap);
1088 res.json({ role, may_approve_proposals });
1089 } catch (e) {
1090 console.error('[bridge] GET /api/v1/role', e?.message);
1091 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1092 }
1093 });
1094
1095 app.get('/api/v1/workspace', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1096 try {
1097 const w = await loadWorkspace(req.blobStore);
1098 res.json({ owner_user_id: w.owner_user_id });
1099 } catch (e) {
1100 console.error('[bridge] GET /api/v1/workspace', e?.message);
1101 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1102 }
1103 });
1104
1105 app.post('/api/v1/workspace', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1106 const raw = req.body?.owner_user_id;
1107 const owner_user_id =
1108 raw === null || raw === undefined || raw === ''
1109 ? null
1110 : typeof raw === 'string' && raw.trim()
1111 ? raw.trim()
1112 : null;
1113 try {
1114 await saveWorkspace(req.blobStore, owner_user_id);
1115 const w = await loadWorkspace(req.blobStore);
1116 res.json({ ok: true, owner_user_id: w.owner_user_id });
1117 } catch (e) {
1118 console.error('[bridge] POST /api/v1/workspace', e?.message);
1119 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1120 }
1121 });
1122
1123 app.get('/api/v1/vault-access', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1124 try {
1125 const access = await loadVaultAccess(req.blobStore);
1126 res.json({ access });
1127 } catch (e) {
1128 console.error('[bridge] GET /api/v1/vault-access', e?.message);
1129 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1130 }
1131 });
1132
1133 app.post('/api/v1/vault-access', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1134 const access = req.body?.access;
1135 if (!access || typeof access !== 'object') {
1136 return res.status(400).json({ error: 'access object required', code: 'BAD_REQUEST' });
1137 }
1138 try {
1139 await saveVaultAccess(req.blobStore, access);
1140 const out = await loadVaultAccess(req.blobStore);
1141 res.json({ ok: true, access: out });
1142 } catch (e) {
1143 console.error('[bridge] POST /api/v1/vault-access', e?.message);
1144 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1145 }
1146 });
1147
1148 app.get('/api/v1/scope', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1149 try {
1150 const scope = await loadScope(req.blobStore);
1151 res.json({ scope });
1152 } catch (e) {
1153 console.error('[bridge] GET /api/v1/scope', e?.message);
1154 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1155 }
1156 });
1157
1158 app.post('/api/v1/scope', requireBridgeAuth, requireBridgeAdmin, async (req, res) => {
1159 const scope = req.body?.scope;
1160 if (!scope || typeof scope !== 'object') {
1161 return res.status(400).json({ error: 'scope object required', code: 'BAD_REQUEST' });
1162 }
1163 try {
1164 await saveScope(req.blobStore, scope);
1165 const out = await loadScope(req.blobStore);
1166 res.json({ ok: true, scope: out });
1167 } catch (e) {
1168 console.error('[bridge] POST /api/v1/scope', e?.message);
1169 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1170 }
1171 });
1172
1173 app.get('/api/v1/hosted-context', requireBridgeAuth, async (req, res) => {
1174 try {
1175 const actor = req.uid;
1176 const workspace = await loadWorkspace(req.blobStore);
1177 const roles = await loadRoles(req.blobStore);
1178 const ctx = await resolveHostedBridgeContext(req, actor);
1179 if (!ctx.ok) {
1180 return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
1181 }
1182 const role = effectiveRole(actor, roles);
1183 const mayMap = await loadEvaluatorMayApproveMap(req.blobStore);
1184 const may_approve_proposals = mayApproveProposalsForUser(actor, roles, mayMap);
1185 res.json({
1186 actor_sub: actor,
1187 workspace_owner_id: workspace.owner_user_id,
1188 effective_canister_user_id: ctx.effectiveCanisterUid,
1189 delegating: ctx.delegating,
1190 allowed_vault_ids: ctx.allowedVaultIds,
1191 scope: ctx.scope,
1192 role,
1193 may_approve_proposals,
1194 });
1195 } catch (e) {
1196 console.error('[bridge] GET /api/v1/hosted-context', e?.message);
1197 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1198 }
1199 });
1200
1201 app.get('/api/v1/hosted-context/settings', requireBridgeAuth, async (req, res) => {
1202 try {
1203 const actor = req.uid;
1204 const ctx = await resolveHostedBridgeSettingsContext(req, actor);
1205 const mayMap = await loadEvaluatorMayApproveMap(req.blobStore);
1206 const may_approve_proposals = mayApproveProposalsForUser(actor, { [actor]: ctx.role }, mayMap);
1207 res.json({
1208 actor_sub: actor,
1209 workspace_owner_id: ctx.workspaceOwnerId,
1210 effective_canister_user_id: ctx.effectiveCanisterUid,
1211 delegating: ctx.delegating,
1212 allowed_vault_ids: ctx.allowedVaultIds,
1213 scope: null,
1214 role: ctx.role,
1215 may_approve_proposals,
1216 });
1217 } catch (e) {
1218 console.error('[bridge] GET /api/v1/hosted-context/settings', e?.message);
1219 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1220 }
1221 });
1222
1223 // ——— Connect GitHub ———
1224 if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
1225 app.get('/auth/github-connect', (req, res) => {
1226 const token = req.query.token || (req.headers.authorization && req.headers.authorization.startsWith('Bearer ') && req.headers.authorization.slice(7));
1227 const uid = token ? userIdFromJwt(token) : null;
1228 if (!uid) {
1229 return res.redirect(HUB_UI_ORIGIN + HUB_UI_PATH + '/?github_connect_error=not_authenticated');
1230 }
1231 const state = signState({ uid, ts: Date.now() });
1232 const redirectUri = BASE_URL + '/auth/callback/github-connect';
1233 const url = 'https://github.com/login/oauth/authorize?client_id=' + encodeURIComponent(process.env.GITHUB_CLIENT_ID)
1234 + '&redirect_uri=' + encodeURIComponent(redirectUri)
1235 + '&scope=repo'
1236 + '&state=' + encodeURIComponent(state);
1237 res.redirect(url);
1238 });
1239
1240 app.get('/auth/callback/github-connect', async (req, res) => {
1241 const { code, state } = req.query || {};
1242 const hubBase = HUB_UI_ORIGIN + HUB_UI_PATH + '/';
1243 console.log('[bridge] callback: hubBase=%s (ORIGIN=%s PATH=%s)', hubBase, HUB_UI_ORIGIN, HUB_UI_PATH);
1244 const payload = verifyState(state);
1245 if (!payload) {
1246 const url = hubBase + '?github_connect_error=error_state';
1247 console.log('[bridge] redirect (error_state): %s', url);
1248 return res.redirect(302, url);
1249 }
1250 if (!code) {
1251 const url = hubBase + '?github_connect_error=error_code';
1252 console.log('[bridge] redirect (error_code): %s', url);
1253 return res.redirect(302, url);
1254 }
1255 const uid = payload.uid;
1256 const redirectUri = BASE_URL + '/auth/callback/github-connect';
1257 const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
1258 method: 'POST',
1259 headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
1260 body: JSON.stringify({
1261 client_id: process.env.GITHUB_CLIENT_ID,
1262 client_secret: process.env.GITHUB_CLIENT_SECRET,
1263 code,
1264 redirect_uri: redirectUri,
1265 }),
1266 });
1267 const data = await tokenRes.json();
1268 if (!data.access_token) {
1269 const url = hubBase + '?github_connect_error=error_token';
1270 console.log('[bridge] redirect (error_token): %s', url);
1271 return res.redirect(302, url);
1272 }
1273 const tokensByUser = await loadTokens(req.blobStore);
1274 tokensByUser[uid] = { token: data.access_token, repo: tokensByUser[uid]?.repo || null };
1275 try {
1276 await saveTokens(req.blobStore, tokensByUser);
1277 } catch (e) {
1278 console.error('[bridge] saveTokens after GitHub OAuth failed:', e?.message || e);
1279 const url = hubBase + '?github_connect_error=blob_storage';
1280 return res.redirect(302, url);
1281 }
1282 const redirectTo = hubBase + '?github_connected=1';
1283 console.log('[bridge] redirect after connect: HUB_UI_ORIGIN=%s HUB_UI_PATH=%s redirectTo=%s', HUB_UI_ORIGIN, HUB_UI_PATH, redirectTo);
1284 res.redirect(302, redirectTo);
1285 });
1286 }
1287
1288 // ——— Delete vault (canister + team access/scope + vector blob) ———
1289 app.delete('/api/v1/vaults/:vaultId', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
1290 if (!CANISTER_URL) {
1291 return res.status(503).json({ error: 'CANISTER_URL not configured', code: 'NOT_AVAILABLE' });
1292 }
1293 const vaultId = sanitizeVaultId(req.params.vaultId);
1294 if (!req.params.vaultId || String(req.params.vaultId).trim() === '' || vaultId === 'default') {
1295 return res.status(400).json({ error: 'Cannot delete the default vault', code: 'BAD_REQUEST' });
1296 }
1297
1298 const prevVaultHeader = req.headers['x-vault-id'];
1299 req.headers['x-vault-id'] = vaultId;
1300 const hctx = await resolveHostedBridgeContext(req, req.uid);
1301 req.headers['x-vault-id'] = prevVaultHeader;
1302
1303 if (!hctx.ok) {
1304 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
1305 }
1306
1307 const workspace = await loadWorkspace(req.blobStore);
1308 const owner = workspace.owner_user_id && String(workspace.owner_user_id).trim();
1309 if (owner && req.uid !== owner) {
1310 return res.status(403).json({
1311 error: 'Only the workspace owner can delete vaults.',
1312 code: 'FORBIDDEN',
1313 });
1314 }
1315
1316 let canRes;
1317 try {
1318 canRes = await fetch(`${CANISTER_URL}/api/v1/vaults/${encodeURIComponent(vaultId)}`, {
1319 method: 'DELETE',
1320 headers: canisterHeaders({ 'X-User-Id': hctx.effectiveCanisterUid }),
1321 });
1322 } catch (e) {
1323 console.error('[bridge] DELETE vault canister fetch', e?.message);
1324 return res.status(502).json({ error: 'Could not reach canister', code: 'BAD_GATEWAY' });
1325 }
1326
1327 const text = await canRes.text();
1328 if (!canRes.ok) {
1329 let errMsg = text;
1330 try {
1331 const j = JSON.parse(text);
1332 if (j && j.error) errMsg = j.error;
1333 } catch (_) {}
1334 return res.status(canRes.status >= 400 ? canRes.status : 502).json({
1335 error: errMsg || 'Canister error',
1336 code: 'UPSTREAM_ERROR',
1337 });
1338 }
1339
1340 await stripHostedVaultFromAccessAndScope(req.blobStore, vaultId);
1341 await removeHostedVectorBlobForVault(req.blobStore, hctx.effectiveCanisterUid, vaultId);
1342
1343 try {
1344 const data = text ? JSON.parse(text) : {};
1345 res.json({ ok: true, ...data });
1346 } catch (_) {
1347 res.json({ ok: true, deleted_vault_id: vaultId });
1348 }
1349 });
1350
1351 /**
1352 * Full proposal documents for GitHub backup (list + GET each id). Same scope as notes.
1353 * @param {string} canisterUrl
1354 * @param {string} canisterUid
1355 * @param {string} vaultId
1356 * @param {{ projects?: string[], folders?: string[] } | null | undefined} scope
1357 */
1358 async function fetchFullProposalsForGithubBackup(canisterUrl, canisterUid, vaultId, scope) {
1359 const base = String(canisterUrl || '').replace(/\/$/, '');
1360 const headers = canisterHeaders({ 'X-User-Id': canisterUid, 'X-Vault-Id': vaultId });
1361 const listRes = await fetch(`${base}/api/v1/proposals`, { method: 'GET', headers });
1362 if (!listRes.ok) {
1363 const err = new Error(`Canister proposals list ${listRes.status}`);
1364 err.status = 502;
1365 throw err;
1366 }
1367 let listJson;
1368 try {
1369 listJson = await listRes.json();
1370 } catch {
1371 const err = new Error('Invalid canister proposals list JSON');
1372 err.status = 502;
1373 throw err;
1374 }
1375 const stubs = Array.isArray(listJson.proposals) ? listJson.proposals : [];
1376 const full = [];
1377 for (const stub of stubs) {
1378 const id = stub && stub.proposal_id ? String(stub.proposal_id) : '';
1379 if (!id) continue;
1380 const oneRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(id)}`, {
1381 method: 'GET',
1382 headers,
1383 });
1384 if (!oneRes.ok) {
1385 const err = new Error(`Canister proposal ${id} ${oneRes.status}`);
1386 err.status = 502;
1387 throw err;
1388 }
1389 const text = await oneRes.text();
1390 const body = parseCanisterProposalGetBody(id, text, stub);
1391 if (body._knowtation_backup_json_unparseable) {
1392 console.error('[bridge] vault/sync proposal JSON parse failed', { id, preview: text.slice(0, 300) });
1393 }
1394 full.push(body);
1395 }
1396 return applyScopeFilterToProposals(full, scope);
1397 }
1398
1399 // ——— Back up now: fetch vault from canister, push to GitHub ———
1400 app.post('/api/v1/vault/sync', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
1401 const uid = req.uid;
1402
1403 const hctx = await resolveHostedBridgeContext(req, uid);
1404 if (!hctx.ok) {
1405 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
1406 }
1407 const canisterUid = hctx.effectiveCanisterUid;
1408
1409 const tokensByUser = await loadTokens(req.blobStore);
1410 const conn = tokensByUser[uid];
1411 const repo = req.body?.repo || conn?.repo;
1412 if (!conn?.token) {
1413 return res.status(400).json({ error: 'GitHub not connected', code: 'GITHUB_NOT_CONNECTED' });
1414 }
1415 if (!repo || typeof repo !== 'string') {
1416 return res.status(400).json({ error: 'Repo required', code: 'REPO_REQUIRED', hint: 'Send { "repo": "owner/name" } or set repo after connecting GitHub.' });
1417 }
1418
1419 const [owner, name] = repo.split('/').filter(Boolean);
1420 if (!owner || !name) {
1421 return res.status(400).json({ error: 'Invalid repo format', code: 'BAD_REQUEST' });
1422 }
1423
1424 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
1425
1426 // Fetch vault from canister (export)
1427 let exportRes;
1428 try {
1429 exportRes = await fetch(CANISTER_URL + '/api/v1/export', {
1430 method: 'GET',
1431 headers: canisterHeaders({ 'X-User-Id': canisterUid, 'X-Vault-Id': vaultId }),
1432 });
1433 } catch (e) {
1434 return res.status(502).json({ error: 'Could not reach canister', code: 'BAD_GATEWAY' });
1435 }
1436 if (!exportRes.ok) {
1437 return res.status(502).json({ error: 'Canister error', code: 'BAD_GATEWAY', status: exportRes.status });
1438 }
1439 let vault;
1440 try {
1441 vault = await exportRes.json();
1442 } catch (_) {
1443 return res.status(502).json({ error: 'Invalid canister response', code: 'BAD_GATEWAY' });
1444 }
1445 let notes = vault.notes || [];
1446 if (hctx.scope) {
1447 notes = applyScopeFilterToNotes(notes, hctx.scope);
1448 }
1449
1450 let proposals = [];
1451 try {
1452 proposals = await fetchFullProposalsForGithubBackup(CANISTER_URL, canisterUid, vaultId, hctx.scope);
1453 } catch (e) {
1454 console.error('[bridge] vault/sync proposals fetch', e?.message);
1455 return res.status(e.status || 502).json({
1456 error: e.message || 'Could not fetch proposals for backup',
1457 code: 'BAD_GATEWAY',
1458 });
1459 }
1460
1461 // Store repo for next time
1462 if (req.body?.repo && (!conn.repo || conn.repo !== repo)) {
1463 tokensByUser[uid] = { ...conn, repo };
1464 await saveTokens(req.blobStore, tokensByUser);
1465 }
1466
1467 // Push to GitHub: get default branch, create blobs, create tree, commit, push
1468 const ghToken = conn.token;
1469 const ghApi = 'https://api.github.com';
1470 // GitHub requires a non-empty User-Agent; some serverless runtimes send none → 403 "Administrative rules".
1471 const ghHeaders = {
1472 Authorization: 'token ' + ghToken,
1473 Accept: 'application/vnd.github.v3+json',
1474 'Content-Type': 'application/json',
1475 'User-Agent': 'KnowtationHub-Bridge/1.0 (+https://knowtation.store)',
1476 };
1477 const headsRefEnc = (branch) => encodeURIComponent(`heads/${String(branch || 'main').trim()}`);
1478
1479 let defaultBranch;
1480 try {
1481 const repoRes = await fetch(`${ghApi}/repos/${owner}/${name}`, { headers: ghHeaders });
1482 if (!repoRes.ok) {
1483 if (repoRes.status === 404) {
1484 return res.status(400).json({ error: 'Repo not found or no access', code: 'REPO_NOT_FOUND' });
1485 }
1486 throw new Error('GitHub API ' + repoRes.status);
1487 }
1488 const repoData = await repoRes.json();
1489 defaultBranch = String(repoData.default_branch || 'main').trim() || 'main';
1490 } catch (e) {
1491 return res.status(502).json({ error: 'GitHub API error', code: 'BAD_GATEWAY' });
1492 }
1493
1494 // GET single ref: documented as /git/ref/{ref} with ref = heads/<branch> (URL-encoded). Avoids edge cases with /git/refs/... on some hosts.
1495 const refRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/ref/${headsRefEnc(defaultBranch)}`, { headers: ghHeaders });
1496 let baseSha = null;
1497 let baseTreeSha = null;
1498 if (refRes.ok) {
1499 const refData = await refRes.json();
1500 baseSha = refData.object?.sha;
1501 if (!baseSha) {
1502 return res.status(502).json({ error: 'Invalid ref response', code: 'BAD_GATEWAY' });
1503 }
1504 const baseTreeRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/commits/${baseSha}`, { headers: ghHeaders });
1505 if (!baseTreeRes.ok) {
1506 return res.status(502).json({ error: 'Could not get base commit', code: 'BAD_GATEWAY' });
1507 }
1508 const baseCommit = await baseTreeRes.json();
1509 baseTreeSha = baseCommit.tree?.sha;
1510 } else if (refRes.status === 404) {
1511 // Repo exists on GitHub but has no commits yet (Quick setup / empty repo) — no refs/heads/* yet.
1512 baseSha = null;
1513 baseTreeSha = null;
1514 } else {
1515 const refErrBody = await refRes.text();
1516 console.warn('[bridge] GitHub GET ref failed', { owner, name, branch: defaultBranch, status: refRes.status, body: refErrBody.slice(0, 500) });
1517 if (refRes.status === 403 || refRes.status === 401) {
1518 return res.status(502).json({
1519 error:
1520 'GitHub denied access when reading the branch (often missing User-Agent or expired token). Use Settings → Connect GitHub again.',
1521 code: 'BAD_GATEWAY',
1522 });
1523 }
1524 return res.status(502).json({
1525 error: 'Could not read branch on GitHub. If the repo is new with no commits, try Back up again after redeploying the bridge; otherwise check bridge logs.',
1526 code: 'BAD_GATEWAY',
1527 });
1528 }
1529
1530 const tree = [];
1531 for (const note of notes) {
1532 const path = note.path || 'note.md';
1533 const content = (note.frontmatter && note.frontmatter !== '{}' ? '---\n' + note.frontmatter + '\n---\n\n' : '') + (note.body || '');
1534 const blobRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/blobs`, {
1535 method: 'POST',
1536 headers: ghHeaders,
1537 body: JSON.stringify({ content: Buffer.from(content, 'utf8').toString('base64'), encoding: 'base64' }),
1538 });
1539 if (!blobRes.ok) {
1540 return res.status(502).json({ error: 'GitHub blob failed', code: 'BAD_GATEWAY' });
1541 }
1542 const blob = await blobRes.json();
1543 tree.push({ path, mode: '100644', type: 'blob', sha: blob.sha });
1544 }
1545
1546 const snapshotObj = {
1547 format_version: 1,
1548 kind: 'knowtation-hosted-backup',
1549 exported_at: new Date().toISOString(),
1550 vault_id: vaultId,
1551 proposals,
1552 };
1553 const snapshotJson = JSON.stringify(snapshotObj);
1554 const snapBlobRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/blobs`, {
1555 method: 'POST',
1556 headers: ghHeaders,
1557 body: JSON.stringify({
1558 content: Buffer.from(snapshotJson, 'utf8').toString('base64'),
1559 encoding: 'base64',
1560 }),
1561 });
1562 if (!snapBlobRes.ok) {
1563 return res.status(502).json({ error: 'GitHub blob failed (snapshot)', code: 'BAD_GATEWAY' });
1564 }
1565 const snapBlob = await snapBlobRes.json();
1566 tree.push({
1567 path: '.knowtation/backup/v1/snapshot.json',
1568 mode: '100644',
1569 type: 'blob',
1570 sha: snapBlob.sha,
1571 });
1572
1573 const isInitialCommit = !baseSha;
1574 if (isInitialCommit && notes.length === 0 && proposals.length === 0) {
1575 const placeholder =
1576 '# Knowtation vault backup\n\n'
1577 + 'This folder is written by **Back up now** on hosted Knowtation.\n\n'
1578 + '- **Markdown files** elsewhere in this repo are your vault **notes**.\n'
1579 + '- **`.knowtation/backup/v1/snapshot.json`** holds full **proposal** records (status, review, enrich metadata, bodies).\n\n'
1580 + 'Your vault had no notes or proposals yet. Add content in the Hub and run **Back up now** again.\n';
1581 const blobRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/blobs`, {
1582 method: 'POST',
1583 headers: ghHeaders,
1584 body: JSON.stringify({
1585 content: Buffer.from(placeholder, 'utf8').toString('base64'),
1586 encoding: 'base64',
1587 }),
1588 });
1589 if (!blobRes.ok) {
1590 return res.status(502).json({ error: 'GitHub blob failed', code: 'BAD_GATEWAY' });
1591 }
1592 const blob = await blobRes.json();
1593 tree.push({ path: '.knowtation/README.md', mode: '100644', type: 'blob', sha: blob.sha });
1594 }
1595
1596 const treePayload = baseTreeSha ? { base_tree: baseTreeSha, tree } : { tree };
1597 const treeRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/trees`, {
1598 method: 'POST',
1599 headers: ghHeaders,
1600 body: JSON.stringify(treePayload),
1601 });
1602 if (!treeRes.ok) {
1603 return res.status(502).json({ error: 'GitHub tree failed', code: 'BAD_GATEWAY' });
1604 }
1605 const newTree = await treeRes.json();
1606
1607 const commitRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/commits`, {
1608 method: 'POST',
1609 headers: ghHeaders,
1610 body: JSON.stringify({
1611 message: 'Knowtation Hub backup ' + new Date().toISOString(),
1612 tree: newTree.sha,
1613 parents: baseSha ? [baseSha] : [],
1614 }),
1615 });
1616 if (!commitRes.ok) {
1617 return res.status(502).json({ error: 'GitHub commit failed', code: 'BAD_GATEWAY' });
1618 }
1619 const newCommit = await commitRes.json();
1620
1621 let refUpdateRes;
1622 if (baseSha) {
1623 refUpdateRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/refs/${headsRefEnc(defaultBranch)}`, {
1624 method: 'PATCH',
1625 headers: ghHeaders,
1626 body: JSON.stringify({ sha: newCommit.sha, force: false }),
1627 });
1628 } else {
1629 refUpdateRes = await fetch(`${ghApi}/repos/${owner}/${name}/git/refs`, {
1630 method: 'POST',
1631 headers: ghHeaders,
1632 body: JSON.stringify({ ref: `refs/heads/${defaultBranch}`, sha: newCommit.sha }),
1633 });
1634 }
1635 if (!refUpdateRes.ok) {
1636 return res.status(502).json({ error: 'GitHub push failed', code: 'BAD_GATEWAY' });
1637 }
1638
1639 res.json({
1640 ok: true,
1641 message: 'Synced',
1642 notesCount: notes.length,
1643 proposalsCount: proposals.length,
1644 });
1645 });
1646
1647 // Optional: GET status for Settings (connected + repo)
1648 app.get('/api/v1/vault/github-status', async (req, res) => {
1649 const auth = req.headers.authorization;
1650 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
1651 const uid = token ? userIdFromJwt(token) : null;
1652 if (!uid) {
1653 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1654 }
1655 const tokensByUser = await loadTokens(req.blobStore);
1656 const conn = tokensByUser[uid];
1657 res.json({
1658 github_connected: Boolean(conn?.token),
1659 repo: conn?.repo || null,
1660 });
1661 });
1662
1663 // Internal: GET GitHub connection (token + repo) for the gateway to use for image upload.
1664 // Server-to-server only — never exposed to the browser. Auth required.
1665 app.get('/api/v1/vault/github-token', async (req, res) => {
1666 const auth = req.headers.authorization;
1667 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
1668 const uid = token ? userIdFromJwt(token) : null;
1669 if (!uid) {
1670 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1671 }
1672 try {
1673 const tokensByUser = await loadTokens(req.blobStore);
1674 const conn = tokensByUser[uid];
1675 if (!conn?.token) {
1676 return res.status(400).json({ error: 'GitHub not connected', code: 'GITHUB_NOT_CONNECTED' });
1677 }
1678 res.json({ token: conn.token, repo: conn.repo || null });
1679 } catch (e) {
1680 res.status(500).json({ error: e.message || 'Internal error', code: 'INTERNAL_ERROR' });
1681 }
1682 });
1683
1684 /** Max notes per canister POST /api/v1/notes/batch (must match hub/icp NOTES_BATCH cap). */
1685 const CANISTER_NOTES_BATCH_MAX = 100;
1686
1687 /**
1688 * @param {string} canisterUid
1689 * @param {string} actorUid
1690 * @param {string} vaultId
1691 * @param {{ path: string, body: string, frontmatter?: Record<string, unknown> }[]} notes
1692 */
1693 async function postNotesBatchToCanister(canisterUid, actorUid, vaultId, notes) {
1694 if (!notes.length) return;
1695 for (let offset = 0; offset < notes.length; offset += CANISTER_NOTES_BATCH_MAX) {
1696 const chunk = notes.slice(offset, offset + CANISTER_NOTES_BATCH_MAX);
1697 const r = await fetch(CANISTER_URL + '/api/v1/notes/batch', {
1698 method: 'POST',
1699 headers: canisterHeaders({
1700 'Content-Type': 'application/json',
1701 'X-User-Id': canisterUid,
1702 'X-Actor-Id': actorUid,
1703 'X-Vault-Id': vaultId,
1704 }),
1705 body: JSON.stringify({ notes: chunk }),
1706 });
1707 const text = await r.text();
1708 if (!r.ok) {
1709 throw new Error(`Canister batch note write failed (${r.status}): ${text.slice(0, 800)}`);
1710 }
1711 }
1712 }
1713
1714 /**
1715 * Sanitize a user-supplied filename before writing it to disk.
1716 * - Strips all directory components (path traversal prevention).
1717 * - Removes every character that is not alphanumeric, a dot, hyphen, or underscore.
1718 * - Truncates to 200 chars so filesystem limits are never approached.
1719 * - Falls back to 'upload' when the result would be empty.
1720 */
1721 function sanitizeUploadFilename(rawName) {
1722 const base = path.basename(rawName || '');
1723 const safe = base.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
1724 return safe || 'upload';
1725 }
1726
1727 const importTempDirMiddleware = (req, _res, next) => {
1728 req._importTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-bridge-import-'));
1729 next();
1730 };
1731 const bridgeImportUpload = multer({
1732 storage: multer.diskStorage({
1733 destination: (req, _file, cb) => cb(null, req._importTempDir),
1734 filename: (_req, file, cb) => cb(null, sanitizeUploadFilename(file.originalname)),
1735 }),
1736 limits: { fileSize: 100 * 1024 * 1024 },
1737 }).single('file');
1738
1739 // ——— Phase 18: GitHub image upload + image proxy ———
1740
1741 const bridgeImageUpload = multer({
1742 storage: multer.memoryStorage(),
1743 limits: { fileSize: 25 * 1024 * 1024 },
1744 }).single('image');
1745
1746 app.post(
1747 /^\/api\/v1\/notes\/(.+)\/upload-image$/,
1748 requireBridgeAuth,
1749 requireBridgeEditorOrAdmin,
1750 bridgeImageUpload,
1751 async (req, res) => {
1752 try {
1753 if (!req.file) return res.status(400).json({ error: 'image file required', code: 'BAD_REQUEST' });
1754
1755 const originalName = req.file.originalname || 'image.jpg';
1756 try { validateImageExtension(originalName); } catch (e) {
1757 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
1758 }
1759 if (!(req.file.mimetype || '').toLowerCase().startsWith('image/')) {
1760 return res.status(400).json({ error: 'File content-type must be image/*', code: 'BAD_REQUEST' });
1761 }
1762 const ext = originalName.split('.').pop().toLowerCase();
1763 try { validateMagicBytes(req.file.buffer, ext); } catch (e) {
1764 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
1765 }
1766
1767 const tokensByUser = await loadTokens(req.blobStore);
1768 const conn = tokensByUser[req.uid];
1769 if (!conn?.token) {
1770 return res.status(400).json({ error: 'GitHub not connected. Go to Settings → Backup → Connect GitHub.', code: 'GITHUB_NOT_CONNECTED' });
1771 }
1772 if (!conn.repo) {
1773 return res.status(400).json({ error: 'GitHub repo not set. Back up once first to set the remote.', code: 'GITHUB_NOT_CONFIGURED' });
1774 }
1775
1776 const now = new Date();
1777 const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
1778 const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
1779 const uniqueName = `${Date.now()}-${safeName}`;
1780 const repoFilePath = `media/images/${yearMonth}/${uniqueName}`;
1781
1782 const result = await commitImageToRepo({
1783 accessToken: conn.token,
1784 repoUrl: conn.repo,
1785 filePath: repoFilePath,
1786 fileBuffer: req.file.buffer,
1787 commitMessage: `Add image: ${safeName}`,
1788 });
1789
1790 res.json({
1791 url: result.url,
1792 inserted_markdown: `![${safeName}](${result.url})`,
1793 sha: result.sha,
1794 repo_path: repoFilePath,
1795 repo_private: result.isPrivate === true,
1796 });
1797 } catch (e) {
1798 console.error('[bridge] upload-image error:', e?.message);
1799 const msg = e.message || String(e);
1800 const clientErr = /not found|not connected|lacks permission|lacks repo|Reconnect|scope|remote/i.test(msg);
1801 res.status(clientErr ? 400 : 500).json({ error: msg, code: clientErr ? 'BAD_REQUEST' : 'RUNTIME_ERROR' });
1802 }
1803 }
1804 );
1805
1806 // Image proxy: serve raw.githubusercontent.com images via the stored GitHub token.
1807 // Accepts JWT via ?token= query param (browsers cannot send headers for <img> tags).
1808 const BRIDGE_IMAGE_PROXY_SIZE_LIMIT = 10 * 1024 * 1024;
1809 app.get('/api/v1/vault/image-proxy', async (req, res) => {
1810 const auth = req.headers.authorization;
1811 const headerToken = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
1812 const queryToken = typeof req.query.token === 'string' ? req.query.token : null;
1813 const uid = (headerToken || queryToken) ? userIdFromJwt(headerToken || queryToken) : null;
1814 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1815
1816 const rawUrl = typeof req.query.url === 'string' ? req.query.url : '';
1817 if (!/^https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/.+$/i.test(rawUrl)) {
1818 return res.status(400).json({ error: 'url must be a raw.githubusercontent.com path', code: 'BAD_REQUEST' });
1819 }
1820
1821 let accessToken = '';
1822 try {
1823 const tokensByUser = await loadTokens(req.blobStore);
1824 const conn = tokensByUser[uid];
1825 if (conn?.token) accessToken = conn.token;
1826 } catch (_) {}
1827
1828 const fetchHeaders = { 'User-Agent': 'Knowtation-Hub/1.0' };
1829 if (accessToken) fetchHeaders.Authorization = `token ${accessToken}`;
1830
1831 let upstream;
1832 try {
1833 upstream = await fetch(rawUrl, { headers: fetchHeaders });
1834 } catch (e) {
1835 return res.status(502).json({ error: 'Failed to fetch image from GitHub', code: 'UPSTREAM_ERROR' });
1836 }
1837 if (!upstream.ok) {
1838 return res.status(upstream.status).json({ error: 'Image not found on GitHub', code: 'UPSTREAM_ERROR' });
1839 }
1840 const ct = upstream.headers.get('content-type') || '';
1841 if (!ct.startsWith('image/')) {
1842 return res.status(400).json({ error: 'URL does not point to an image', code: 'BAD_REQUEST' });
1843 }
1844 const buf = Buffer.from(await upstream.arrayBuffer());
1845 if (buf.byteLength > BRIDGE_IMAGE_PROXY_SIZE_LIMIT) {
1846 return res.status(400).json({ error: 'Image too large (max 10 MB)', code: 'BAD_REQUEST' });
1847 }
1848 res.setHeader('Content-Type', ct);
1849 res.setHeader('Content-Length', buf.byteLength);
1850 res.setHeader('Cache-Control', 'private, max-age=3600');
1851 res.setHeader('X-Content-Type-Options', 'nosniff');
1852 res.send(buf);
1853 });
1854
1855 app.post(
1856 '/api/v1/import',
1857 requireBridgeAuth,
1858 requireBridgeEditorOrAdmin,
1859 importTempDirMiddleware,
1860 bridgeImportUpload,
1861 async (req, res) => {
1862 const tempDir = req._importTempDir;
1863 try {
1864 if (!CANISTER_URL) {
1865 return res.status(503).json({ error: 'Canister not configured', code: 'SERVICE_UNAVAILABLE' });
1866 }
1867 const sourceType = req.body && req.body.source_type ? String(req.body.source_type).trim() : '';
1868 if (!IMPORT_SOURCE_TYPES.includes(sourceType)) {
1869 return res.status(400).json({
1870 error: `source_type must be one of: ${IMPORT_SOURCE_TYPES.join(', ')}`,
1871 code: 'BAD_REQUEST',
1872 });
1873 }
1874 const sheetId = req.body && req.body.spreadsheet_id ? String(req.body.spreadsheet_id).trim() : '';
1875 const sheetsRange = req.body && req.body.sheets_range ? String(req.body.sheets_range).trim() : undefined;
1876 if (sourceType === 'google-sheets') {
1877 if (!sheetId) {
1878 return res
1879 .status(400)
1880 .json({ error: 'google-sheets: spreadsheet_id is required in the multipart body', code: 'BAD_REQUEST' });
1881 }
1882 if (req.file) {
1883 return res
1884 .status(400)
1885 .json({ error: 'google-sheets: do not send a file; use spreadsheet_id only', code: 'BAD_REQUEST' });
1886 }
1887 } else if (!req.file) {
1888 return res.status(400).json({ error: 'file required', code: 'BAD_REQUEST' });
1889 }
1890 const project = req.body && req.body.project ? String(req.body.project).trim() : undefined;
1891 const outputDir = req.body && req.body.output_dir ? String(req.body.output_dir).trim() : undefined;
1892 const tagsRaw = req.body && req.body.tags ? String(req.body.tags) : '';
1893 const tags = tagsRaw ? tagsRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
1894 let inputPath = sourceType === 'google-sheets' ? sheetId : req.file.path;
1895 if (sourceType !== 'google-sheets' && req.file && req.file.originalname && req.file.originalname.toLowerCase().endsWith('.zip')) {
1896 const extractDir = path.join(tempDir, 'extracted');
1897 fs.mkdirSync(extractDir, { recursive: true });
1898 const zip = new AdmZip(req.file.path);
1899 // Zip-slip protection: every entry must resolve inside extractDir
1900 const extractDirResolved = path.resolve(extractDir) + path.sep;
1901 for (const entry of zip.getEntries()) {
1902 const entryResolved = path.resolve(extractDir, entry.entryName);
1903 if (entryResolved !== path.resolve(extractDir) && !entryResolved.startsWith(extractDirResolved)) {
1904 return res.status(400).json({ error: 'Invalid zip entry: path traversal detected', code: 'BAD_REQUEST' });
1905 }
1906 }
1907 zip.extractAllTo(extractDir, true);
1908 inputPath = extractDir;
1909 }
1910 const hctx = await resolveHostedBridgeContext(req, req.uid);
1911 if (!hctx.ok) {
1912 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
1913 }
1914 const vaultPath = path.join(tempDir, 'vault-work');
1915 fs.mkdirSync(vaultPath, { recursive: true });
1916 const result = await runImport(sourceType, inputPath, {
1917 project,
1918 outputDir,
1919 tags,
1920 vaultPath,
1921 ...(sheetsRange ? { sheetsRange } : {}),
1922 });
1923 const importStamp = mergeProvenanceFrontmatter({}, {
1924 sub: hctx.actorUid,
1925 kind: 'import',
1926 });
1927 /** @type {{ path: string, body: string, frontmatter: Record<string, unknown> }[]} */
1928 const notesForCanister = [];
1929 for (const item of result.imported || []) {
1930 if (item.path && typeof item.path === 'string') {
1931 try {
1932 writeNote(vaultPath, item.path, { frontmatter: importStamp });
1933 const safe = resolveVaultRelativePath(vaultPath, item.path);
1934 const fullPath = path.join(vaultPath, safe);
1935 const markdownFull = fs.readFileSync(fullPath, 'utf8');
1936 const parsed = parseFrontmatterAndBody(markdownFull);
1937 const fm =
1938 parsed.frontmatter && typeof parsed.frontmatter === 'object' && !Array.isArray(parsed.frontmatter)
1939 ? /** @type {Record<string, unknown>} */ ({ ...parsed.frontmatter })
1940 : {};
1941 notesForCanister.push({
1942 path: safe.replace(/\\/g, '/'),
1943 body: parsed.body || '',
1944 frontmatter: fm,
1945 });
1946 } catch (e) {
1947 console.error('[bridge] import prepare note for canister failed for', item.path, e?.message || e);
1948 return res.status(502).json({
1949 error: e.message || 'Canister write failed',
1950 code: 'BAD_GATEWAY',
1951 });
1952 }
1953 }
1954 }
1955 try {
1956 await postNotesBatchToCanister(
1957 hctx.effectiveCanisterUid,
1958 hctx.actorUid,
1959 hctx.vaultId,
1960 notesForCanister,
1961 );
1962 } catch (e) {
1963 console.error('[bridge] import canister batch write failed', e?.message || e);
1964 return res.status(502).json({
1965 error: e.message || 'Canister write failed',
1966 code: 'BAD_GATEWAY',
1967 });
1968 }
1969 return res.json({ imported: result.imported, count: result.count });
1970 } catch (e) {
1971 const msg = e.message || String(e);
1972 const clientError =
1973 /OPENAI_API_KEY|required for transcription|Unsupported format|file not found|not found:|Transcription failed|413|Payload Too Large|25MB|Whisper accepts|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(
1974 msg,
1975 );
1976 res.status(clientError ? 400 : 500).json({
1977 error: msg,
1978 code: clientError ? 'BAD_REQUEST' : 'RUNTIME_ERROR',
1979 });
1980 } finally {
1981 if (tempDir && fs.existsSync(tempDir)) {
1982 try {
1983 fs.rmSync(tempDir, { recursive: true, force: true });
1984 } catch (_) {}
1985 }
1986 }
1987 },
1988 );
1989
1990 /**
1991 * @param {unknown} raw
1992 * @returns {'auto' | 'bookmark' | 'extract'}
1993 */
1994 function normalizeBridgeImportUrlMode(raw) {
1995 const s = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
1996 if (s === 'bookmark' || s === 'extract' || s === 'auto') return s;
1997 return 'auto';
1998 }
1999
2000 /**
2001 * @param {unknown} body
2002 * @returns {string[]}
2003 */
2004 function tagsFromBridgeImportUrlBody(body) {
2005 const t = body && body.tags;
2006 if (Array.isArray(t)) return t.map((x) => String(x).trim()).filter(Boolean);
2007 if (typeof t === 'string') return t.split(',').map((s) => s.trim()).filter(Boolean);
2008 return [];
2009 }
2010
2011 app.post('/api/v1/import-url', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
2012 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-bridge-import-url-'));
2013 try {
2014 if (!CANISTER_URL) {
2015 return res.status(503).json({ error: 'Canister not configured', code: 'SERVICE_UNAVAILABLE' });
2016 }
2017 const body = req.body && typeof req.body === 'object' ? req.body : {};
2018 const urlStr = typeof body.url === 'string' ? body.url.trim() : '';
2019 if (!urlStr) return res.status(400).json({ error: 'url required', code: 'BAD_REQUEST' });
2020 const urlMode = normalizeBridgeImportUrlMode(body.mode);
2021 const project = body.project != null && String(body.project).trim() !== '' ? String(body.project).trim() : undefined;
2022 const outputDir =
2023 body.output_dir != null && String(body.output_dir).trim() !== '' ? String(body.output_dir).trim() : undefined;
2024 const tags = tagsFromBridgeImportUrlBody(body);
2025
2026 const hctx = await resolveHostedBridgeContext(req, req.uid);
2027 if (!hctx.ok) {
2028 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
2029 }
2030 const vaultPath = path.join(tempDir, 'vault-work');
2031 fs.mkdirSync(vaultPath, { recursive: true });
2032 const result = await runImport('url', urlStr, { project, outputDir, tags, vaultPath, urlMode });
2033 const importStamp = mergeProvenanceFrontmatter({}, {
2034 sub: hctx.actorUid,
2035 kind: 'import',
2036 });
2037 /** @type {{ path: string, body: string, frontmatter: Record<string, unknown> }[]} */
2038 const notesForCanister = [];
2039 for (const item of result.imported || []) {
2040 if (item.path && typeof item.path === 'string') {
2041 try {
2042 writeNote(vaultPath, item.path, { frontmatter: importStamp });
2043 const safe = resolveVaultRelativePath(vaultPath, item.path);
2044 const fullPath = path.join(vaultPath, safe);
2045 const markdownFull = fs.readFileSync(fullPath, 'utf8');
2046 const parsed = parseFrontmatterAndBody(markdownFull);
2047 const fm =
2048 parsed.frontmatter && typeof parsed.frontmatter === 'object' && !Array.isArray(parsed.frontmatter)
2049 ? /** @type {Record<string, unknown>} */ ({ ...parsed.frontmatter })
2050 : {};
2051 notesForCanister.push({
2052 path: safe.replace(/\\/g, '/'),
2053 body: parsed.body || '',
2054 frontmatter: fm,
2055 });
2056 } catch (e) {
2057 console.error('[bridge] import-url prepare note for canister failed for', item.path, e?.message || e);
2058 return res.status(502).json({
2059 error: e.message || 'Canister write failed',
2060 code: 'BAD_GATEWAY',
2061 });
2062 }
2063 }
2064 }
2065 try {
2066 await postNotesBatchToCanister(hctx.effectiveCanisterUid, hctx.actorUid, hctx.vaultId, notesForCanister);
2067 } catch (e) {
2068 console.error('[bridge] import-url canister batch write failed', e?.message || e);
2069 return res.status(502).json({
2070 error: e.message || 'Canister write failed',
2071 code: 'BAD_GATEWAY',
2072 });
2073 }
2074 return res.json({ imported: result.imported, count: result.count });
2075 } catch (e) {
2076 const msg = e.message || String(e);
2077 const clientError =
2078 /OPENAI_API_KEY|required for transcription|Unsupported format|file not found|not found:|Transcription failed|413|Payload Too Large|25MB|Whisper accepts|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(
2079 msg,
2080 );
2081 res.status(clientError ? 400 : 500).json({
2082 error: msg,
2083 code: clientError ? 'BAD_REQUEST' : 'RUNTIME_ERROR',
2084 });
2085 } finally {
2086 if (tempDir && fs.existsSync(tempDir)) {
2087 try {
2088 fs.rmSync(tempDir, { recursive: true, force: true });
2089 } catch (_) {}
2090 }
2091 }
2092 });
2093
2094 // ——— Index + Search (hosted: indexer runs in bridge, canister does not run Node) ———
2095 // BATCH_EMBED + INDEXER_EMBED_CONCURRENCY together drive how much wall time the index
2096 // step takes against Netlify's 60 s sync-function cap. With DeepInfra (BAAI/bge-large-en-v1.5)
2097 // per-batch latencies trending 2.5 – 8.5 s, the previous serial loop at BATCH=10 ran ~65 – 80 s
2098 // for a 251-chunk vault and got killed. Defaults below (BATCH=50, CONCURRENCY=5) bring that
2099 // same vault under ~10 – 15 s when a full re-embed is needed; the content-hash cache below
2100 // makes subsequent re-indexes a few seconds regardless of vault size.
2101 const BATCH_EMBED_DEFAULT = parseEmbedBatchSize(process.env.INDEXER_EMBED_BATCH_SIZE);
2102 const EMBED_CONCURRENCY_DEFAULT = parseEmbedConcurrency(process.env.INDEXER_EMBED_CONCURRENCY);
2103 const BATCH_UPSERT = 50;
2104 const SYNC_BUDGET_SECONDS = parseSyncBudgetSeconds(process.env.INDEXER_SYNC_BUDGET_SECONDS);
2105 const MAX_SYNC_CHUNKS = parseMaxSyncChunks(process.env.INDEXER_MAX_SYNC_CHUNKS);
2106
2107 /**
2108 * Kick off the `bridge-index-background` Netlify Function. Used by the
2109 * synchronous `POST /api/v1/index` handler when the preflight estimate exceeds
2110 * the sync budget (`SYNC_BUDGET_SECONDS`) or the chunk-count safety net
2111 * (`MAX_SYNC_CHUNKS`). The background function returns 202 instantly and runs
2112 * up to 15 min in a separate Lambda; this fetch only waits for that 202 so the
2113 * sync handler can return its own 202 to the browser without blocking on the
2114 * actual embed work.
2115 *
2116 * Two layers of auth on the inbound side (see `lib/bridge-internal-hmac.mjs`):
2117 * 1. The user JWT is forwarded verbatim so the background function still
2118 * runs `requireBridgeAuth` and the user must be a real authenticated user.
2119 * 2. An HMAC signature over (canisterUid, vaultId, jobId, ts) signed with
2120 * `SESSION_SECRET` proves the request originated from this sync handler
2121 * (the background URL is publicly addressable on Netlify).
2122 */
2123 async function kickOffBackgroundIndex(req, jobId, canisterUid, vaultId) {
2124 if (!SESSION_SECRET) {
2125 throw new Error(
2126 'kickOffBackgroundIndex: SESSION_SECRET is not set; cannot sign internal request',
2127 );
2128 }
2129 const ts = Date.now();
2130 const sig = signInternalRequest(SESSION_SECRET, { canisterUid, vaultId, jobId, ts });
2131 const protocol =
2132 req.protocol ||
2133 (req.headers['x-forwarded-proto'] && String(req.headers['x-forwarded-proto']).split(',')[0]) ||
2134 'https';
2135 const host = (req.get && req.get('host')) || req.headers.host;
2136 if (!host) {
2137 throw new Error('kickOffBackgroundIndex: cannot determine host header for background URL');
2138 }
2139 const url = `${protocol}://${host}/.netlify/functions/bridge-index-background`;
2140 const auth = req.headers.authorization || '';
2141 // Background functions on Netlify return 202 within ~50–100 ms regardless of
2142 // what the function body does; we only await that 202 so the sync handler
2143 // can immediately return its own 202 to the browser.
2144 //
2145 // CRITICAL (May 2026 hotfix): we MUST inspect `response.status`. fetch()
2146 // resolves successfully on 4xx/5xx responses (it only throws on network
2147 // errors), so without this check a non-202 response (function not deployed,
2148 // wrong host header, HMAC misconfiguration, future routing bug, etc.) would
2149 // be silently treated as success — the sync handler would return
2150 // `202 status:"background"` to the browser while no work runs in the
2151 // background. The job lock would then sit for its full 16-min TTL blocking
2152 // any retry. See `lib/bridge-index-kickoff-response.mjs` for full context
2153 // and the failure-mode test matrix in
2154 // `test/bridge-index-kickoff-response.test.mjs`.
2155 const response = await fetch(url, {
2156 method: 'POST',
2157 headers: {
2158 'content-type': 'application/json',
2159 authorization: auth,
2160 'x-vault-id': vaultId,
2161 'x-bridge-internal-uid': canisterUid,
2162 'x-bridge-internal-vault-id': vaultId,
2163 'x-bridge-internal-job-id': jobId,
2164 'x-bridge-internal-ts': String(ts),
2165 'x-bridge-internal-sig': sig,
2166 },
2167 body: '{}',
2168 });
2169 let body = '';
2170 try {
2171 body = await response.text();
2172 } catch (_) {
2173 // body read failure is non-fatal here — the helper accepts undefined body
2174 // and the status code alone is sufficient to detect the failure mode.
2175 }
2176 assertBackgroundKickoffOk(response, body);
2177 }
2178
2179 /**
2180 * Read-only snapshot of "is the index for this vault currently being rebuilt
2181 * in the background, and when did it last finish successfully?". The Hub UI
2182 * polls this on page load to render `Last indexed: 2 minutes ago` next to the
2183 * Re-index button and to disable the button while a background job is live.
2184 *
2185 * Same auth + scope as `POST /api/v1/index`: the user must be authenticated
2186 * AND have the vault in their effective hosted-bridge context.
2187 */
2188 app.get('/api/v1/index/status', requireBridgeAuth, async (req, res) => {
2189 const hctx = await resolveHostedBridgeContext(req, req.uid);
2190 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
2191 const canisterUid = hctx.effectiveCanisterUid;
2192 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
2193 const lastIndexed = req.blobStore
2194 ? await getLastIndexedAt(req.blobStore, { canisterUid, vaultId })
2195 : null;
2196 const jobLock = req.blobStore
2197 ? await peekJobLock(req.blobStore, { canisterUid, vaultId })
2198 : null;
2199 const inProgress =
2200 jobLock != null &&
2201 Number.isFinite(jobLock.expiresAt) &&
2202 jobLock.expiresAt > Date.now();
2203 res.json({
2204 lastIndexed,
2205 inProgress,
2206 job: inProgress ? jobLock : null,
2207 });
2208 });
2209
2210 app.post('/api/v1/index', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
2211 const uid = req.uid;
2212 const earlyVaultId = sanitizeVaultId(req.headers['x-vault-id']);
2213 const timer = createIndexTimer({ vaultId: earlyVaultId, canisterUid: null });
2214 const hctx = await resolveHostedBridgeContext(req, uid);
2215 timer.step('resolve_context', { ok: hctx.ok });
2216 if (!hctx.ok) {
2217 timer.finish({ ok: false, phase: 'resolve_context', status: hctx.status, code: hctx.code });
2218 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
2219 }
2220 const canisterUid = hctx.effectiveCanisterUid;
2221 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
2222 let exportRes;
2223 try {
2224 exportRes = await fetch(CANISTER_URL + '/api/v1/export', {
2225 method: 'GET',
2226 headers: canisterHeaders({ 'X-User-Id': canisterUid, 'X-Vault-Id': vaultId }),
2227 });
2228 } catch (e) {
2229 timer.finish({ ok: false, phase: 'canister_export_fetch', error: e?.message || String(e) });
2230 return res.status(502).json({ error: 'Could not reach canister', code: 'BAD_GATEWAY' });
2231 }
2232 if (!exportRes.ok) {
2233 timer.finish({ ok: false, phase: 'canister_export_status', status: exportRes.status });
2234 return res.status(502).json({ error: 'Canister export failed', code: 'BAD_GATEWAY', status: exportRes.status });
2235 }
2236 let vault;
2237 try {
2238 vault = await exportRes.json();
2239 } catch (_) {
2240 timer.finish({ ok: false, phase: 'canister_export_parse' });
2241 return res.status(502).json({ error: 'Invalid canister response', code: 'BAD_GATEWAY' });
2242 }
2243 let notes = vault.notes || [];
2244 timer.step('canister_export', { note_count: notes.length, scoped: Boolean(hctx.scope) });
2245 if (hctx.scope) {
2246 notes = applyScopeFilterToNotes(notes, hctx.scope);
2247 timer.step('scope_filter', { note_count_after: notes.length });
2248 }
2249 try {
2250 if (!globalThis.__knowtation_bridge_embed_logged) {
2251 globalThis.__knowtation_bridge_embed_logged = true;
2252 const c = getBridgeEmbeddingConfig();
2253 const hasOpenAiKey = Boolean(
2254 process.env.OPENAI_API_KEY && String(process.env.OPENAI_API_KEY).trim(),
2255 );
2256 console.log(
2257 '[bridge] embedding (no secrets):',
2258 JSON.stringify({
2259 provider: c.provider,
2260 model: c.model,
2261 ollama_url_set: Boolean(process.env.OLLAMA_URL && String(process.env.OLLAMA_URL).trim()),
2262 openai_key_set: hasOpenAiKey,
2263 }),
2264 );
2265 }
2266 const { chunkNote } = await import('../../lib/chunk.mjs');
2267 const { embedWithUsage, embeddingDimension } = await import('../../lib/embedding.mjs');
2268 const { createVectorStore } = await import('../../lib/vector-store.mjs');
2269 timer.step('import_modules');
2270
2271 const vectorsDir = await getVectorsDirForUser(req, canisterUid);
2272 const storeConfig = getBridgeStoreConfig(canisterUid, vectorsDir);
2273 timer.step('get_vectors_dir');
2274 const chunkOpts = resolveIndexerChunkOptions(process.env, storeConfig.embedding);
2275 const allChunks = [];
2276 for (const n of notes) {
2277 const note = {
2278 body: n.body || '',
2279 path: n.path || 'note.md',
2280 project: undefined,
2281 tags: [],
2282 date: undefined,
2283 };
2284 const chunks = chunkNote(note, chunkOpts);
2285 for (const c of chunks) allChunks.push(c);
2286 }
2287 // Tag every chunk with a versioned content hash + the namespaced store id so the
2288 // sqlite-vec backend's `getChunkHashes(vaultId)` lookup keys line up. The hash prefix
2289 // includes the active provider+model so a same-dimension model swap (e.g. BGE-large 1024
2290 // → BGE-m3 1024) automatically invalidates the cache instead of silently keeping stale
2291 // vectors. See `lib/chunk-content-hash.mjs:computeChunkContentHashTagged`.
2292 const embeddingConfigForHash = storeConfig.embedding;
2293 const chunksWithHash = allChunks.map((chunk) => ({
2294 chunk,
2295 storeId: `${vaultId}::${chunk.id}`,
2296 contentHash: computeChunkContentHashTagged(chunk, embeddingConfigForHash),
2297 }));
2298 timer.step('chunk_notes', { chunk_count: allChunks.length });
2299
2300 const dim = embeddingDimension(storeConfig.embedding);
2301 const store = await createVectorStore(storeConfig);
2302 await store.ensureCollection(dim);
2303 timer.step('ensure_collection', { dim });
2304
2305 // Empty vault: drop everything for this vault and persist (covers note-deletion case).
2306 if (chunksWithHash.length === 0) {
2307 let vectors_deleted = 0;
2308 if (typeof store.deleteByVaultId === 'function') {
2309 vectors_deleted = await store.deleteByVaultId(vaultId);
2310 }
2311 timer.step('delete_old_vectors_empty', { vectors_deleted });
2312 await persistVectorsToBlob(req, canisterUid, vectorsDir);
2313 timer.step('persist_vectors_empty');
2314 // Sidecar update so the Hub UI's "Last indexed" line stays correct even after
2315 // an all-notes-deleted re-index (notes=0 is a legitimate steady state).
2316 if (req.blobStore) {
2317 try {
2318 await setLastIndexedAt(req.blobStore, {
2319 canisterUid,
2320 vaultId,
2321 actorUid: sanitizeUserId(uid),
2322 notesProcessed: notes.length,
2323 chunksIndexed: 0,
2324 chunksEmbedded: 0,
2325 chunksSkippedCached: 0,
2326 vectorsDeleted: vectors_deleted,
2327 embeddingInputTokens: 0,
2328 durationMs: timer.totalMs(),
2329 mode: req.bridgeInternalRequest != null ? 'background' : 'sync',
2330 provider: storeConfig.embedding?.provider || null,
2331 model: storeConfig.embedding?.model || null,
2332 });
2333 } catch (sidecarErr) {
2334 // Sidecar write failure must not fail the index — UI just falls back to "never indexed".
2335 console.warn('[bridge] setLastIndexedAt failed (empty path):', sidecarErr?.message || sidecarErr);
2336 }
2337 }
2338 // If this is a background-mode invocation, release the lock so subsequent
2339 // re-indexes are not falsely blocked. Use expectedJobId so a stale background
2340 // function (whose lock has since been overwritten) cannot clobber a newer one.
2341 if (req.bridgeInternalRequest != null && req.blobStore) {
2342 try {
2343 await releaseJobLock(req.blobStore, {
2344 canisterUid,
2345 vaultId,
2346 expectedJobId: req.bridgeInternalRequest.jobId,
2347 });
2348 } catch (lockErr) {
2349 console.warn('[bridge] releaseJobLock failed (empty path):', lockErr?.message || lockErr);
2350 }
2351 }
2352 console.log(
2353 '[bridge] index',
2354 JSON.stringify({
2355 vault_id: vaultId,
2356 canister_uid: sanitizeUserId(canisterUid),
2357 notes_processed: notes.length,
2358 chunks_indexed: 0,
2359 vectors_deleted,
2360 chunks_skipped_cached: 0,
2361 }),
2362 );
2363 timer.finish({
2364 ok: true,
2365 notes_processed: notes.length,
2366 chunks_indexed: 0,
2367 vectors_deleted,
2368 chunks_skipped_cached: 0,
2369 });
2370 return res.json({
2371 ok: true,
2372 notesProcessed: notes.length,
2373 chunksIndexed: 0,
2374 embedding_input_tokens: 0,
2375 vectors_deleted,
2376 chunksSkippedCached: 0,
2377 });
2378 }
2379
2380 // Content-hash cache lookup. If the store doesn't expose getChunkHashes (older
2381 // backend or test mock), treat every chunk as cache miss — correct, just slower.
2382 let existingHashes = new Map();
2383 if (typeof store.getChunkHashes === 'function') {
2384 try {
2385 existingHashes = await store.getChunkHashes(vaultId);
2386 } catch (e) {
2387 console.warn(
2388 '[bridge] getChunkHashes failed; falling back to full re-embed for this vault:',
2389 e?.message || e,
2390 );
2391 existingHashes = new Map();
2392 }
2393 }
2394 const partitioned = partitionChunksForReindex(chunksWithHash, existingHashes);
2395 const toEmbed = partitioned.toEmbed;
2396 const chunks_skipped_cached = partitioned.skippedCachedCount;
2397 timer.step('cache_lookup', {
2398 cache_size: existingHashes.size,
2399 chunks_total: chunksWithHash.length,
2400 chunks_skipped_cached,
2401 chunks_to_embed: toEmbed.length,
2402 orphan_count: partitioned.orphanIds.length,
2403 });
2404
2405 // —— Auto-routing: sync vs background ——
2406 // The bridge runs as a Netlify synchronous function (60 s platform max). After the
2407 // OpenAI(1536)→DeepInfra(1024) switch, a 251-chunk full re-embed costs ~10–15 s
2408 // and a 1 500-chunk re-embed pushes past 30 s. To keep the snappy UX for the 99 %
2409 // case (small delta or cache-hit) AND eliminate timeout risk for the 1 % case
2410 // (first-time index, dim migration, big import), we estimate the embed wall-clock
2411 // here and either (a) continue inline OR (b) hand the work to the
2412 // `bridge-index-background` Netlify Function (15-min cap).
2413 //
2414 // The background path itself re-enters this same handler via
2415 // `req.bridgeInternalRequest` (set by the wrapper after HMAC verification); when
2416 // that's truthy we SKIP the routing decision and execute inline regardless of size.
2417 const isInternalBackgroundRequest = req.bridgeInternalRequest != null;
2418 const embeddingConfig = storeConfig.embedding;
2419 const BATCH_EMBED = BATCH_EMBED_DEFAULT;
2420 const EMBED_CONCURRENCY = EMBED_CONCURRENCY_DEFAULT;
2421 if (!isInternalBackgroundRequest && toEmbed.length > 0) {
2422 const estimatedSeconds = estimateEmbedSeconds({
2423 chunksToEmbed: toEmbed.length,
2424 batchSize: BATCH_EMBED,
2425 concurrency: EMBED_CONCURRENCY,
2426 });
2427 // `isFirstIndex` covers BOTH a true first-time index (no prior cache rows) AND
2428 // the post-dim-migration state where `ensureCollection` just dropped + recreated
2429 // the table (so `getChunkHashes` returned empty). Both require a full re-embed
2430 // and both should route to background regardless of estimate.
2431 const isFirstIndex = existingHashes.size === 0;
2432 const decision = shouldUseBackgroundIndex({
2433 chunksToEmbed: toEmbed.length,
2434 estimatedSeconds,
2435 syncBudgetSeconds: SYNC_BUDGET_SECONDS,
2436 maxSyncChunks: MAX_SYNC_CHUNKS,
2437 isFirstIndex,
2438 });
2439 timer.step('routing_decision', {
2440 chunks_to_embed: toEmbed.length,
2441 estimated_seconds: estimatedSeconds,
2442 is_first_index: isFirstIndex,
2443 sync_budget_seconds: SYNC_BUDGET_SECONDS,
2444 max_sync_chunks: MAX_SYNC_CHUNKS,
2445 decision: decision.shouldUseBackground ? 'background' : 'sync',
2446 reason: decision.reason,
2447 });
2448 if (decision.shouldUseBackground) {
2449 if (!req.blobStore) {
2450 // No Blob store available (local self-host without Netlify Blobs): we cannot
2451 // safely run the background path because lock + sidecar persistence would be
2452 // lost. Fall through to sync — local self-host is single-tenant and operators
2453 // can tolerate a longer wait.
2454 timer.step('routing_fallback_no_blobstore');
2455 } else {
2456 const lockResult = await acquireJobLock(req.blobStore, {
2457 canisterUid,
2458 vaultId,
2459 actorUid: sanitizeUserId(uid),
2460 chunksToEmbed: toEmbed.length,
2461 estimatedSeconds,
2462 reason: decision.reason,
2463 });
2464 if (!lockResult.acquired) {
2465 timer.finish({
2466 ok: true,
2467 phase: 'background_already_running',
2468 existing_job_id: lockResult.existing?.jobId || null,
2469 });
2470 return res.status(409).json({
2471 status: 'already_running',
2472 message:
2473 'A background re-index is already running for this vault. Refresh in a minute.',
2474 jobId: lockResult.existing?.jobId || null,
2475 startedAt: lockResult.existing?.startedAt || null,
2476 });
2477 }
2478 try {
2479 await kickOffBackgroundIndex(req, lockResult.jobId, canisterUid, vaultId);
2480 } catch (kickoffErr) {
2481 // Kickoff failed (network blip, missing SESSION_SECRET, etc.) — release the
2482 // lock so the user can retry, and surface the error.
2483 await releaseJobLock(req.blobStore, {
2484 canisterUid,
2485 vaultId,
2486 expectedJobId: lockResult.jobId,
2487 });
2488 timer.finish({
2489 ok: false,
2490 phase: 'background_kickoff',
2491 error: kickoffErr?.message || String(kickoffErr),
2492 });
2493 return res.status(502).json({
2494 error: 'Could not start background re-index',
2495 code: 'BACKGROUND_KICKOFF_FAILED',
2496 message: kickoffErr?.message || String(kickoffErr),
2497 });
2498 }
2499 timer.finish({
2500 ok: true,
2501 phase: 'background_started',
2502 job_id: lockResult.jobId,
2503 chunks_to_embed: toEmbed.length,
2504 estimated_seconds: estimatedSeconds,
2505 reason: decision.reason,
2506 });
2507 return res.status(202).json({
2508 status: 'background',
2509 jobId: lockResult.jobId,
2510 message:
2511 'Large re-index started in the background. Refresh in 1–2 minutes — search will use the new vectors as soon as the job finishes.',
2512 estimatedSeconds,
2513 chunksToEmbed: toEmbed.length,
2514 reason: decision.reason,
2515 });
2516 }
2517 }
2518 }
2519 // —— end auto-routing ——
2520
2521 const embedBatches = [];
2522 for (let i = 0; i < toEmbed.length; i += BATCH_EMBED) {
2523 embedBatches.push(toEmbed.slice(i, i + BATCH_EMBED));
2524 }
2525 let embedding_input_tokens = 0;
2526 let embed_total_ms = 0;
2527 let embed_max_batch_ms = 0;
2528 let embed_min_batch_ms = embedBatches.length > 0 ? Number.POSITIVE_INFINITY : 0;
2529 const embedBatchCount = embedBatches.length;
2530 // Result vectors keyed by toEmbed index (preserves order so the upsert step can
2531 // zip vectors[i] back to toEmbed[i].chunk without depending on completion order).
2532 const embedResults = await runWithConcurrency(
2533 embedBatches.map((batch, batchIndex) => async () => {
2534 const texts = batch.map((item) => item.chunk.text);
2535 const { vectors: batchVectors, embedding_input_tokens: batchTok } = await embedWithUsage(
2536 texts,
2537 embeddingConfig,
2538 { voyageInputType: 'document' },
2539 );
2540 return { batchIndex, batchVectors, batchTok };
2541 }),
2542 {
2543 concurrency: EMBED_CONCURRENCY,
2544 onSettled: ({ index, ok, ms, error }) => {
2545 if (!ok) {
2546 timer.step('embed_batch_error', {
2547 batch_index: index,
2548 embed_ms: ms,
2549 error: error?.message || String(error),
2550 });
2551 return;
2552 }
2553 embed_total_ms += ms;
2554 if (ms > embed_max_batch_ms) embed_max_batch_ms = ms;
2555 if (ms < embed_min_batch_ms) embed_min_batch_ms = ms;
2556 timer.step('embed_batch', {
2557 batch_index: index,
2558 batch_size: embedBatches[index].length,
2559 embed_ms: ms,
2560 });
2561 },
2562 },
2563 );
2564 const vectorsByEmbedIndex = new Array(toEmbed.length);
2565 for (const { batchIndex, batchVectors, batchTok } of embedResults) {
2566 embedding_input_tokens += batchTok;
2567 const start = batchIndex * BATCH_EMBED;
2568 const batch = embedBatches[batchIndex];
2569 for (let j = 0; j < batch.length; j++) {
2570 vectorsByEmbedIndex[start + j] = batchVectors[j] || [];
2571 }
2572 }
2573 timer.step('embed_total', {
2574 batches: embedBatchCount,
2575 embed_total_ms,
2576 embed_avg_batch_ms: embedBatchCount > 0 ? Math.round(embed_total_ms / embedBatchCount) : 0,
2577 embed_min_batch_ms: embed_min_batch_ms === Number.POSITIVE_INFINITY ? 0 : embed_min_batch_ms,
2578 embed_max_batch_ms,
2579 embedding_input_tokens,
2580 concurrency: EMBED_CONCURRENCY,
2581 batch_size: BATCH_EMBED,
2582 provider: embeddingConfig?.provider || null,
2583 model: embeddingConfig?.model || null,
2584 });
2585
2586 // Orphans = chunk_ids in the store but not in the current export (deleted/renamed notes).
2587 let vectors_deleted = 0;
2588 if (partitioned.orphanIds.length > 0 && typeof store.deleteByChunkIds === 'function') {
2589 vectors_deleted = await store.deleteByChunkIds(partitioned.orphanIds);
2590 } else if (
2591 partitioned.orphanIds.length === 0 &&
2592 existingHashes.size === 0 &&
2593 typeof store.deleteByVaultId === 'function'
2594 ) {
2595 // First run for this vault (no prior cache rows) — clear any leftover rows that
2596 // lacked content_hash but might still match the vault, so search cannot return paths
2597 // that no longer exist in the export.
2598 vectors_deleted = await store.deleteByVaultId(vaultId);
2599 }
2600 timer.step('delete_old_vectors', {
2601 vectors_deleted,
2602 orphan_count: partitioned.orphanIds.length,
2603 });
2604
2605 let upsert_total_ms = 0;
2606 const upsertBatchCount = Math.ceil(toEmbed.length / BATCH_UPSERT);
2607 for (let i = 0; i < toEmbed.length; i += BATCH_UPSERT) {
2608 const slice = toEmbed.slice(i, i + BATCH_UPSERT);
2609 const points = slice.map((item, j) => ({
2610 id: item.storeId,
2611 vector: vectorsByEmbedIndex[i + j] || [],
2612 text: item.chunk.text,
2613 path: item.chunk.path,
2614 vault_id: vaultId,
2615 project: item.chunk.project,
2616 tags: item.chunk.tags,
2617 date: item.chunk.date,
2618 causal_chain_id: item.chunk.causal_chain_id,
2619 entity: item.chunk.entity,
2620 episode_id: item.chunk.episode_id,
2621 content_hash: item.contentHash,
2622 }));
2623 const upsertStart = Date.now();
2624 await store.upsert(points);
2625 upsert_total_ms += Date.now() - upsertStart;
2626 }
2627 timer.step('upsert_total', {
2628 batches: upsertBatchCount,
2629 upsert_total_ms,
2630 upsert_avg_batch_ms: upsertBatchCount > 0 ? Math.round(upsert_total_ms / upsertBatchCount) : 0,
2631 points_upserted: toEmbed.length,
2632 });
2633 await persistVectorsToBlob(req, canisterUid, vectorsDir);
2634 timer.step('persist_vectors');
2635 // Sidecar update so the Hub UI's "Last indexed" line is correct after BOTH
2636 // the synchronous and the background path. The same record format is read
2637 // by `GET /api/v1/index/status` and rendered next to the Re-index button.
2638 if (req.blobStore) {
2639 try {
2640 await setLastIndexedAt(req.blobStore, {
2641 canisterUid,
2642 vaultId,
2643 actorUid: sanitizeUserId(uid),
2644 notesProcessed: notes.length,
2645 chunksIndexed: allChunks.length,
2646 chunksEmbedded: toEmbed.length,
2647 chunksSkippedCached: chunks_skipped_cached,
2648 vectorsDeleted: vectors_deleted,
2649 embeddingInputTokens: embedding_input_tokens,
2650 durationMs: timer.totalMs(),
2651 mode: req.bridgeInternalRequest != null ? 'background' : 'sync',
2652 provider: embeddingConfig?.provider || null,
2653 model: embeddingConfig?.model || null,
2654 });
2655 } catch (sidecarErr) {
2656 // Sidecar write failure must not fail the index — UI just falls back to "never indexed".
2657 console.warn('[bridge] setLastIndexedAt failed:', sidecarErr?.message || sidecarErr);
2658 }
2659 }
2660 // Background path: release the job lock so a future re-index is not falsely blocked.
2661 // `expectedJobId` ensures we only release OUR lock — if a stale background job
2662 // finishes after a fresh background job has already acquired a new lock (rare,
2663 // but possible if the first job exceeded the lock TTL), we leave the new lock alone.
2664 if (req.bridgeInternalRequest != null && req.blobStore) {
2665 try {
2666 await releaseJobLock(req.blobStore, {
2667 canisterUid,
2668 vaultId,
2669 expectedJobId: req.bridgeInternalRequest.jobId,
2670 });
2671 } catch (lockErr) {
2672 console.warn('[bridge] releaseJobLock failed:', lockErr?.message || lockErr);
2673 }
2674 }
2675 console.log(
2676 '[bridge] index',
2677 JSON.stringify({
2678 vault_id: vaultId,
2679 canister_uid: sanitizeUserId(canisterUid),
2680 notes_processed: notes.length,
2681 chunks_indexed: allChunks.length,
2682 chunks_skipped_cached,
2683 chunks_embedded: toEmbed.length,
2684 vectors_deleted,
2685 mode: req.bridgeInternalRequest != null ? 'background' : 'sync',
2686 }),
2687 );
2688 const indexResult = {
2689 ok: true,
2690 notesProcessed: notes.length,
2691 chunksIndexed: allChunks.length,
2692 chunksSkippedCached: chunks_skipped_cached,
2693 chunksEmbedded: toEmbed.length,
2694 embedding_input_tokens,
2695 vectors_deleted,
2696 };
2697 timer.finish({
2698 ok: true,
2699 notes_processed: notes.length,
2700 chunks_indexed: allChunks.length,
2701 chunks_skipped_cached,
2702 chunks_embedded: toEmbed.length,
2703 vectors_deleted,
2704 embedding_input_tokens,
2705 mode: req.bridgeInternalRequest != null ? 'background' : 'sync',
2706 });
2707 res.json(indexResult);
2708 fireBridgeCaptureEvent(
2709 'index',
2710 {
2711 note_count: notes.length,
2712 chunk_count: allChunks.length,
2713 chunks_skipped_cached,
2714 chunks_embedded: toEmbed.length,
2715 vectors_deleted,
2716 },
2717 sanitizeUserId(uid),
2718 vaultId,
2719 );
2720 return;
2721 } catch (e) {
2722 console.error('Bridge index error:', e);
2723 timer.finish({ ok: false, phase: 'catch', error: e?.message || String(e) });
2724 // Background path: release the lock on error so the operator can retry without
2725 // waiting for the 16-min TTL. We do this defensively regardless of whether the
2726 // error happened before or after the lock was acquired.
2727 if (req.bridgeInternalRequest != null && req.blobStore) {
2728 try {
2729 await releaseJobLock(req.blobStore, {
2730 canisterUid: req.bridgeInternalRequest.canisterUid,
2731 vaultId: req.bridgeInternalRequest.vaultId,
2732 expectedJobId: req.bridgeInternalRequest.jobId,
2733 });
2734 } catch (lockErr) {
2735 console.warn('[bridge] releaseJobLock failed (catch path):', lockErr?.message || lockErr);
2736 }
2737 }
2738 return res.status(500).json({
2739 error: 'Index failed',
2740 code: 'INTERNAL_ERROR',
2741 message: bridgeEmbedFailureMessage(e, 'index'),
2742 });
2743 }
2744 });
2745
2746 function truncateSnippet(text, maxChars = 300) {
2747 if (text == null || typeof text !== 'string') return '';
2748 const t = text.trim();
2749 if (t.length <= maxChars) return t;
2750 const slice = t.slice(0, maxChars);
2751 const lastSpace = slice.lastIndexOf(' ');
2752 return (lastSpace > maxChars / 2 ? slice.slice(0, lastSpace) : slice) + '…';
2753 }
2754
2755 /**
2756 * Batch document embeddings for hosted MCP `cluster` (and similar callers).
2757 * Auth + vault access mirror `POST /api/v1/search`: JWT in `Authorization`, `X-Vault-Id`,
2758 * `resolveHostedBridgeContext` (effective canister user + allowed vault ids + optional scope).
2759 * Embedding model/env match `POST /api/v1/index` via `getBridgeStoreConfig` + `embedWithUsage` with `voyageInputType: "document"`.
2760 */
2761 const HOSTED_EMBED_MAX_TEXTS = 200;
2762 const HOSTED_EMBED_MAX_CHARS_PER_TEXT = 1200;
2763
2764 app.post('/api/v1/embed', async (req, res) => {
2765 const auth = req.headers.authorization;
2766 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
2767 const uid = token ? userIdFromJwt(token) : null;
2768 if (!uid) {
2769 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2770 }
2771 const hctx = await resolveHostedBridgeContext(req, uid);
2772 if (!hctx.ok) {
2773 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
2774 }
2775 const canisterUid = hctx.effectiveCanisterUid;
2776 const rawTexts = req.body?.texts;
2777 if (!Array.isArray(rawTexts)) {
2778 return res.status(400).json({ error: 'texts array required', code: 'BAD_REQUEST' });
2779 }
2780 const texts = rawTexts
2781 .slice(0, HOSTED_EMBED_MAX_TEXTS)
2782 .map((t) => String(t ?? '').slice(0, HOSTED_EMBED_MAX_CHARS_PER_TEXT));
2783 if (texts.length === 0) {
2784 return res.status(400).json({ error: 'texts must be non-empty', code: 'BAD_REQUEST' });
2785 }
2786 try {
2787 const { embedWithUsage } = await import('../../lib/embedding.mjs');
2788 const vectorsDir = await getVectorsDirForUser(req, canisterUid);
2789 const storeConfig = getBridgeStoreConfig(canisterUid, vectorsDir);
2790 const embeddingConfig = storeConfig.embedding;
2791 let embedding_input_tokens = 0;
2792 const vectors = [];
2793 for (let i = 0; i < texts.length; i += BATCH_EMBED) {
2794 const batch = texts.slice(i, i + BATCH_EMBED);
2795 const { vectors: batchVectors, embedding_input_tokens: batchTok } = await embedWithUsage(
2796 batch,
2797 embeddingConfig,
2798 { voyageInputType: 'document' },
2799 );
2800 embedding_input_tokens += batchTok;
2801 for (const v of batchVectors) {
2802 vectors.push(v);
2803 }
2804 }
2805 return res.json({
2806 vectors,
2807 embedding_input_tokens,
2808 texts_count: texts.length,
2809 });
2810 } catch (e) {
2811 console.error('Bridge embed batch error:', e);
2812 return res.status(500).json({
2813 error: 'Embed failed',
2814 code: 'INTERNAL_ERROR',
2815 message: bridgeEmbedFailureMessage(e, 'embed'),
2816 });
2817 }
2818 });
2819
2820 app.post('/api/v1/search', async (req, res) => {
2821 const auth = req.headers.authorization;
2822 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
2823 const uid = token ? userIdFromJwt(token) : null;
2824 if (!uid) {
2825 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2826 }
2827 const hctx = await resolveHostedBridgeContext(req, uid);
2828 if (!hctx.ok) {
2829 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
2830 }
2831 const canisterUid = hctx.effectiveCanisterUid;
2832 const query = req.body?.query;
2833 // Auto-capture after successful response — fire-and-forget, does not affect latency.
2834 const _captureVaultId = sanitizeVaultId(req.headers['x-vault-id']);
2835 const _captureMode = req.body?.mode === 'keyword' ? 'keyword' : 'semantic';
2836 res.on('finish', () => {
2837 if (res.statusCode >= 200 && res.statusCode < 300 && query) {
2838 fireBridgeCaptureEvent('search', { query, mode: _captureMode }, sanitizeUserId(uid), _captureVaultId);
2839 }
2840 });
2841 if (!query || typeof query !== 'string') {
2842 return res.status(400).json({ error: 'query required', code: 'BAD_REQUEST' });
2843 }
2844 const limit = Math.max(1, Math.min(parseInt(req.body?.limit, 10) || 20, 100));
2845 const snippetChars = parseInt(req.body?.snippetChars, 10) || 300;
2846 try {
2847 const mode = req.body?.mode === 'keyword' ? 'keyword' : 'semantic';
2848 const bridgeVaultId = sanitizeVaultId(req.headers['x-vault-id']);
2849
2850 if (mode === 'keyword') {
2851 let exportRes;
2852 try {
2853 exportRes = await fetch(CANISTER_URL + '/api/v1/export', {
2854 method: 'GET',
2855 headers: canisterHeaders({ 'X-User-Id': canisterUid, 'X-Vault-Id': bridgeVaultId }),
2856 });
2857 } catch (_e) {
2858 return res.status(502).json({ error: 'Could not reach canister', code: 'BAD_GATEWAY' });
2859 }
2860 if (!exportRes.ok) {
2861 return res.status(502).json({ error: 'Canister export failed', code: 'BAD_GATEWAY', status: exportRes.status });
2862 }
2863 let vault;
2864 try {
2865 vault = await exportRes.json();
2866 } catch (_e) {
2867 return res.status(502).json({ error: 'Invalid canister response', code: 'BAD_GATEWAY' });
2868 }
2869 let rawNotes = vault.notes || [];
2870 if (hctx.scope) {
2871 rawNotes = applyScopeFilterToNotes(rawNotes, hctx.scope);
2872 }
2873 const { noteRecordFromExportPayload, keywordSearchNotesArray } = await import('../../lib/keyword-search.mjs');
2874 const { filterNotesByListOptions } = await import('../../lib/list-notes.mjs');
2875 let shaped = rawNotes.map((n) => noteRecordFromExportPayload(n));
2876 shaped = filterNotesByListOptions(shaped, {
2877 folder: req.body?.folder,
2878 project: req.body?.project,
2879 tag: req.body?.tag,
2880 since: req.body?.since,
2881 until: req.body?.until,
2882 chain: req.body?.chain,
2883 entity: req.body?.entity,
2884 episode: req.body?.episode,
2885 content_scope: req.body?.content_scope,
2886 });
2887 const fields =
2888 req.body?.fields === 'path' || req.body?.fields === 'full' ? req.body.fields : 'path+snippet';
2889 const out = keywordSearchNotesArray(shaped, query, {
2890 limit,
2891 order: req.body?.order,
2892 fields,
2893 snippetChars,
2894 match: req.body?.match === 'all_terms' ? 'all_terms' : 'phrase',
2895 countOnly: req.body?.count_only === true || req.body?.countOnly === true,
2896 });
2897 if (out.results && hctx.scope) {
2898 return res.json({ ...out, results: applyScopeFilterToNotes(out.results, hctx.scope) });
2899 }
2900 return res.json(out);
2901 }
2902
2903 const { embed } = await import('../../lib/embedding.mjs');
2904 const { createVectorStore } = await import('../../lib/vector-store.mjs');
2905 const { filterHitsByContentScope, resolveSearchFolderForContentScope } = await import('../../lib/approval-log.mjs');
2906 const { MAX_VECTOR_KNN } = await import('../../lib/vector-knn-limit.mjs');
2907
2908 const vectorsDir = await getVectorsDirForUser(req, canisterUid);
2909 const storeConfig = getBridgeStoreConfig(canisterUid, vectorsDir);
2910 const store = await createVectorStore(storeConfig);
2911 const [queryVector] = await embed([query], storeConfig.embedding, { voyageInputType: 'query' });
2912 if (!queryVector) {
2913 return res.status(500).json({ error: 'Embedding failed', code: 'INTERNAL_ERROR' });
2914 }
2915 const cs = req.body?.content_scope || 'all';
2916 const userFolder = req.body?.folder;
2917 const resolved = resolveSearchFolderForContentScope(cs, userFolder);
2918 if (resolved.impossible) {
2919 return res.json({ results: [], query, mode: 'semantic' });
2920 }
2921 let searchLimit = limit;
2922 if (resolved.wideNotesFetch) {
2923 searchLimit = Math.min(10000, Math.max(limit * 120, 2500));
2924 } else if (cs !== 'all') {
2925 searchLimit = Math.min(10000, Math.max(limit * 40, 800));
2926 }
2927 searchLimit = Math.min(searchLimit, MAX_VECTOR_KNN);
2928 const hits = await store.search(queryVector, {
2929 limit: searchLimit,
2930 vault_id: bridgeVaultId,
2931 project: req.body?.project,
2932 tag: req.body?.tag,
2933 folder: resolved.folder,
2934 since: req.body?.since,
2935 until: req.body?.until,
2936 order: req.body?.order,
2937 chain: req.body?.chain,
2938 entity: req.body?.entity,
2939 episode: req.body?.episode,
2940 });
2941 let scopedHits = filterHitsByContentScope(hits || [], cs);
2942 scopedHits = scopedHits.slice(0, limit);
2943 let results = scopedHits.map((h) => ({
2944 path: h.path,
2945 score: h.score,
2946 ...(typeof h.vec_distance === 'number' && Number.isFinite(h.vec_distance)
2947 ? { vec_distance: h.vec_distance }
2948 : {}),
2949 project: h.project ?? null,
2950 tags: h.tags ?? [],
2951 snippet: truncateSnippet(h.text, snippetChars),
2952 }));
2953 if (hctx.scope) {
2954 results = applyScopeFilterToNotes(results, hctx.scope);
2955 }
2956 return res.json({ results, query, mode: 'semantic' });
2957 } catch (e) {
2958 console.error('Bridge search error:', e);
2959 return res.status(500).json({
2960 error: 'Search failed',
2961 code: 'INTERNAL_ERROR',
2962 message: bridgeEmbedFailureMessage(e, 'search'),
2963 });
2964 }
2965 });
2966
2967 app.use((err, req, res, _next) => {
2968 if (res.headersSent) return;
2969 console.error('[bridge] unhandled error:', err?.stack || err?.message || err);
2970 let status = 500;
2971 if (err instanceof multer.MulterError) {
2972 if (err.code === 'LIMIT_FILE_SIZE') status = 413;
2973 else status = 400;
2974 } else if (typeof err.status === 'number' && err.status >= 400 && err.status < 600) {
2975 status = err.status;
2976 } else if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
2977 status = err.statusCode;
2978 }
2979 res.status(status).json({
2980 error: err.message || 'Internal error',
2981 code: err.code || 'INTERNAL_ERROR',
2982 });
2983 });
2984
2985 // ——— Memory endpoints (Phase 8) ———
2986
2987 /**
2988 * Fire-and-forget memory event capture for hosted bridge endpoints.
2989 * Uses Netlify Blobs when available (hosted), falls back to file-based for self-hosted.
2990 * Never throws, never delays the response.
2991 */
2992 function fireBridgeCaptureEvent(type, data, uid, vaultId) {
2993 (async () => {
2994 try {
2995 if (globalThis.__netlify_blob_store) {
2996 // Hosted: append to Netlify Blobs for durability across Lambda invocations.
2997 const { createMemoryEvent, MEMORY_EVENT_TYPES } = await import('../../lib/memory-event.mjs');
2998 if (!MEMORY_EVENT_TYPES.includes(type)) return;
2999 const event = createMemoryEvent(type, data, { vaultId: vaultId || 'default' });
3000 await blobsAppendMemoryEvent(uid, vaultId, event);
3001 } else {
3002 // Self-hosted: file-based.
3003 const { FileMemoryProvider } = await import('../../lib/memory-provider-file.mjs');
3004 const { MemoryManager } = await import('../../lib/memory.mjs');
3005 const mm = new MemoryManager(new FileMemoryProvider(bridgeMemoryDir(uid, vaultId || 'default')));
3006 if (mm.shouldCapture(type)) mm.store(type, data);
3007 }
3008 } catch (_) {}
3009 })();
3010 }
3011
3012 // ——— Calendar (hosted parity — step 12): event store on bridge DATA_DIR + notes from canister ———
3013
3014 /**
3015 * Fetches canister note metadata for calendar timeline merge. Returns an empty array when
3016 * the canister is unreachable so the events layer can still succeed.
3017 *
3018 * @param {string} canisterUid
3019 * @param {string} actorUid
3020 * @param {string} vaultId
3021 * @returns {Promise<Array<{ path: string, frontmatter: object, date?: string|null, updated?: string|null, project?: string|null, tags?: string[] }>>}
3022 */
3023 async function fetchCanisterNoteRecordsForTimeline(canisterUid, actorUid, vaultId) {
3024 if (!CANISTER_URL) return [];
3025 try {
3026 const upstream = await fetch(`${CANISTER_URL}/api/v1/notes?limit=10000&offset=0`, {
3027 headers: canisterHeaders({
3028 'x-user-id': canisterUid,
3029 'x-actor-id': actorUid,
3030 'x-vault-id': vaultId,
3031 }),
3032 });
3033 if (!upstream.ok) return [];
3034 const data = await upstream.json();
3035 if (!Array.isArray(data.notes)) return [];
3036 return data.notes
3037 .map((note) => {
3038 const path = typeof note.path === 'string' ? note.path.trim() : '';
3039 if (!path) return null;
3040 const fm = materializeListFrontmatter(note.frontmatter ?? {});
3041 return {
3042 path,
3043 frontmatter: note.frontmatter ?? {},
3044 date: typeof fm.date === 'string' ? fm.date : null,
3045 updated: typeof note.updated === 'string' ? note.updated : null,
3046 project: typeof fm.project === 'string' ? fm.project : null,
3047 tags: Array.isArray(note.tags) ? note.tags.map(String) : [],
3048 };
3049 })
3050 .filter(Boolean);
3051 } catch (_) {
3052 return [];
3053 }
3054 }
3055
3056 app.get('/api/v1/calendar/timeline', requireBridgeAuth, async (req, res) => {
3057 const from = typeof req.query.from === 'string' ? req.query.from.trim() : '';
3058 const to = typeof req.query.to === 'string' ? req.query.to.trim() : '';
3059 if (!from || !to) {
3060 return res.status(400).json({ error: '`from` and `to` are required', code: 'BAD_REQUEST' });
3061 }
3062 const hctx = await resolveHostedBridgeContext(req, req.uid);
3063 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
3064 try {
3065 const noteRecords = await fetchCanisterNoteRecordsForTimeline(
3066 hctx.effectiveCanisterUid,
3067 hctx.actorUid,
3068 hctx.vaultId,
3069 );
3070 const payload = await withCalendarBlobSync({
3071 blobStore: req.blobStore,
3072 dataDir: DATA_DIR,
3073 persist: false,
3074 run: () =>
3075 buildCalendarTimeline({
3076 dataDir: DATA_DIR,
3077 vaultId: hctx.vaultId,
3078 noteRecords,
3079 from,
3080 to,
3081 layers: req.query.layers,
3082 sourceCalendarIds: req.query.source_calendar_ids,
3083 scope: hctx.scope,
3084 }),
3085 });
3086 return res.json(payload);
3087 } catch (e) {
3088 const message = e?.message ? String(e.message) : 'Invalid timeline request';
3089 if (
3090 message.includes('Unsupported timeline layer')
3091 || message.includes('Invalid')
3092 || message.includes('required')
3093 || message.includes('before')
3094 ) {
3095 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
3096 }
3097 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
3098 }
3099 });
3100
3101 app.get('/api/v1/calendar/agent-context', requireBridgeAuth, async (req, res) => {
3102 const from = typeof req.query.from === 'string' ? req.query.from.trim() : '';
3103 const to = typeof req.query.to === 'string' ? req.query.to.trim() : '';
3104 if (!from || !to) {
3105 return res.status(400).json({ error: '`from` and `to` are required', code: 'BAD_REQUEST' });
3106 }
3107 const hctx = await resolveHostedBridgeContext(req, req.uid);
3108 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
3109 try {
3110 const payload = await withCalendarBlobSync({
3111 blobStore: req.blobStore,
3112 dataDir: DATA_DIR,
3113 persist: false,
3114 run: () =>
3115 retrieveAgentCalendarContext(DATA_DIR, hctx.vaultId, {
3116 from,
3117 to,
3118 agentContextTier: req.query.agent_context_tier,
3119 sourceCalendarIds: req.query.source_calendar_ids,
3120 }),
3121 });
3122 return res.json(payload);
3123 } catch (e) {
3124 const message = e?.message ? String(e.message) : 'Invalid agent context request';
3125 if (
3126 message.includes('agent_context_tier')
3127 || message.includes('Invalid')
3128 || message.includes('required')
3129 || message.includes('before')
3130 ) {
3131 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
3132 }
3133 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
3134 }
3135 });
3136
3137 app.get('/api/v1/calendar/source-calendars', requireBridgeAuth, async (req, res) => {
3138 const hctx = await resolveHostedBridgeSettingsContext(req, req.uid);
3139 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
3140 if (!hctx.allowedVaultIds.includes(vaultId)) {
3141 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
3142 }
3143 try {
3144 await withCalendarBlobSync({
3145 blobStore: req.blobStore,
3146 dataDir: DATA_DIR,
3147 persist: false,
3148 run: async () => undefined,
3149 });
3150 return res.json({
3151 schema: 'knowtation.source_calendars/v0',
3152 vault_id: vaultId,
3153 source_calendars: listSourceCalendarsForClient(DATA_DIR, vaultId),
3154 });
3155 } catch (e) {
3156 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3157 }
3158 });
3159
3160 app.patch('/api/v1/calendar/source-calendars/:id', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
3161 const sourceCalendarId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
3162 if (!sourceCalendarId) {
3163 return res.status(400).json({ error: 'source calendar id is required', code: 'BAD_REQUEST' });
3164 }
3165 const hctx = await resolveHostedBridgeContext(req, req.uid);
3166 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
3167 try {
3168 const patch = parseSourceCalendarPatchBody(req.body);
3169 const result = await withCalendarBlobSync({
3170 blobStore: req.blobStore,
3171 dataDir: DATA_DIR,
3172 run: () => patchSourceCalendar(DATA_DIR, hctx.vaultId, sourceCalendarId, patch),
3173 });
3174 return res.json({
3175 schema: 'knowtation.source_calendar_patch/v0',
3176 vault_id: hctx.vaultId,
3177 policy_agent_context_tier_max_cap: result.policy_agent_context_tier_max_cap,
3178 source_calendar: result.source_calendar,
3179 });
3180 } catch (e) {
3181 const message = e?.message ? String(e.message) : 'Patch failed';
3182 if (e?.code === 'POLICY_CAP_EXCEEDED') {
3183 return res.status(403).json({ error: message, code: 'POLICY_CAP_EXCEEDED' });
3184 }
3185 if (message.includes('not found')) {
3186 return res.status(404).json({ error: message, code: 'NOT_FOUND' });
3187 }
3188 if (
3189 message.includes('must be')
3190 || message.includes('required')
3191 || message.includes('exceeds policy')
3192 ) {
3193 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
3194 }
3195 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
3196 }
3197 });
3198
3199 app.post('/api/v1/calendar/events/import', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
3200 const hctx = await resolveHostedBridgeContext(req, req.uid);
3201 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
3202 const body = req.body && typeof req.body === 'object' ? req.body : {};
3203 const icsText = typeof body.ics_text === 'string' ? body.ics_text : '';
3204 if (!icsText.trim()) {
3205 return res.status(400).json({ error: 'ics_text (string) is required', code: 'BAD_REQUEST' });
3206 }
3207 try {
3208 const result = await withCalendarBlobSync({
3209 blobStore: req.blobStore,
3210 dataDir: DATA_DIR,
3211 run: () =>
3212 importIcsIntoVault(DATA_DIR, hctx.vaultId, {
3213 icsText,
3214 displayName: typeof body.display_name === 'string' ? body.display_name : undefined,
3215 sourceCalendarId: typeof body.source_calendar_id === 'string' ? body.source_calendar_id : undefined,
3216 connectorId: typeof body.connector_id === 'string' ? body.connector_id : undefined,
3217 defaultTimezone: typeof body.default_timezone === 'string' ? body.default_timezone : undefined,
3218 }),
3219 });
3220 return res.status(200).json({
3221 schema: 'knowtation.calendar_import/v0',
3222 vault_id: hctx.vaultId,
3223 ...result,
3224 });
3225 } catch (e) {
3226 const message = e?.message ? String(e.message) : 'Import failed';
3227 if (
3228 message.includes('not found')
3229 || message.includes('required')
3230 || message.includes('exceeds')
3231 || message.includes('ICS')
3232 ) {
3233 return res.status(400).json({ error: message, code: 'BAD_REQUEST' });
3234 }
3235 return res.status(500).json({ error: message, code: 'RUNTIME_ERROR' });
3236 }
3237 });
3238
3239 // Calendar OAuth connectors (Phase 1D hosted parity — INF-KN-1): bridge event store + OAuth callback.
3240 app.get('/api/v1/calendar/connectors/callback', async (req, res) => {
3241 try {
3242 const result = await withCalendarBlobSync({
3243 blobStore: req.blobStore,
3244 dataDir: DATA_DIR,
3245 run: async () => {
3246 const mod = await import('../../lib/calendar/google-oauth-connector.mjs');
3247 const googleClient = mod.createProductionGoogleClient
3248 ? mod.createProductionGoogleClient()
3249 : mod.createFakeGoogleClient();
3250 return mod.handleGoogleConnectorCallback({
3251 dataDir: DATA_DIR,
3252 query: req.query,
3253 googleClient,
3254 env: process.env,
3255 });
3256 },
3257 });
3258 if (result.redirect) {
3259 return res.redirect(result.status, result.redirect);
3260 }
3261 return res.status(result.status).json({ code: result.code });
3262 } catch (e) {
3263 return res.status(500).json({ error: 'Callback failed', code: 'RUNTIME_ERROR' });
3264 }
3265 });
3266
3267 app.post('/api/v1/calendar/connectors', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
3268 const hctx = await resolveHostedBridgeContext(req, req.uid);
3269 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
3270 const result = await withCalendarBlobSync({
3271 blobStore: req.blobStore,
3272 dataDir: DATA_DIR,
3273 run: () =>
3274 handleBeginGoogleConnector({
3275 dataDir: DATA_DIR,
3276 vaultId: hctx.vaultId,
3277 body: req.body,
3278 env: process.env,
3279 }),
3280 });
3281 if (!result.ok) {
3282 return res.status(result.status).json({ error: result.error ?? 'Not authorized', code: result.code });
3283 }
3284 return res.status(result.status).json(result.payload);
3285 });
3286
3287 app.get('/api/v1/calendar/connectors', requireBridgeAuth, async (req, res) => {
3288 const hctx = await resolveHostedBridgeSettingsContext(req, req.uid);
3289 const vaultId = sanitizeVaultId(req.headers['x-vault-id']);
3290 if (!hctx.allowedVaultIds.includes(vaultId)) {
3291 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
3292 }
3293 const result = await withCalendarBlobSync({
3294 blobStore: req.blobStore,
3295 dataDir: DATA_DIR,
3296 persist: false,
3297 run: () =>
3298 handleListGoogleConnectors({
3299 dataDir: DATA_DIR,
3300 vaultId,
3301 }),
3302 });
3303 if (!result.ok) {
3304 return res.status(result.status).json({ error: result.error ?? 'Not authorized', code: result.code });
3305 }
3306 return res.json(result.payload);
3307 });
3308
3309 app.post('/api/v1/calendar/connectors/:id/sync', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
3310 const hctx = await resolveHostedBridgeContext(req, req.uid);
3311 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
3312 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
3313 try {
3314 const result = await withCalendarBlobSync({
3315 blobStore: req.blobStore,
3316 dataDir: DATA_DIR,
3317 run: async () => {
3318 const mod = await import('../../lib/calendar/google-oauth-connector.mjs');
3319 const googleClient = mod.createProductionGoogleClient
3320 ? mod.createProductionGoogleClient()
3321 : mod.createFakeGoogleClient();
3322 return mod.handleSyncGoogleConnector({
3323 dataDir: DATA_DIR,
3324 vaultId: hctx.vaultId,
3325 connectorId,
3326 googleClient,
3327 env: process.env,
3328 });
3329 },
3330 });
3331 if (!result.ok) {
3332 return res.status(result.status).json({ code: result.code });
3333 }
3334 return res.status(result.status).json(result.payload);
3335 } catch (e) {
3336 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3337 }
3338 });
3339
3340 app.delete('/api/v1/calendar/connectors/:id', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
3341 const hctx = await resolveHostedBridgeContext(req, req.uid);
3342 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
3343 const connectorId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
3344 try {
3345 const result = await withCalendarBlobSync({
3346 blobStore: req.blobStore,
3347 dataDir: DATA_DIR,
3348 run: async () => {
3349 const mod = await import('../../lib/calendar/google-oauth-connector.mjs');
3350 const googleClient = mod.createProductionGoogleClient
3351 ? mod.createProductionGoogleClient()
3352 : mod.createFakeGoogleClient();
3353 return mod.handleRevokeGoogleConnector({
3354 dataDir: DATA_DIR,
3355 vaultId: hctx.vaultId,
3356 connectorId,
3357 googleClient,
3358 env: process.env,
3359 });
3360 },
3361 });
3362 if (!result.ok) {
3363 return res.status(result.status).json({ code: result.code });
3364 }
3365 return res.status(result.status).json(result.payload);
3366 } catch (e) {
3367 return res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3368 }
3369 });
3370
3371 // Agent delegation (hosted parity — 7C-L1): same handler family as self-hosted hub/server.mjs.
3372 registerBridgeDelegationRoutes(app, {
3373 dataDir: DATA_DIR,
3374 canisterUrl: CANISTER_URL,
3375 canisterHeaders,
3376 requireBridgeAuth,
3377 resolveHostedBridgeContext,
3378 });
3379
3380 // Task read + write propose (hosted parity — 2G): same handler family as self-hosted hub/server.mjs.
3381 registerBridgeTaskRoutes(app, {
3382 dataDir: DATA_DIR,
3383 canisterUrl: CANISTER_URL,
3384 canisterHeaders,
3385 requireBridgeAuth,
3386 resolveHostedBridgeContext,
3387 effectiveRole,
3388 loadRoles,
3389 });
3390
3391 registerBridgePathRoutes(app, {
3392 dataDir: DATA_DIR,
3393 canisterUrl: CANISTER_URL,
3394 canisterHeaders,
3395 requireBridgeAuth,
3396 resolveHostedBridgeContext,
3397 effectiveRole,
3398 loadRoles,
3399 });
3400
3401 // Flow authoring write propose (hosted parity — FLOW-WRITE-LIVE-GATEWAY-PROXY).
3402 registerBridgeFlowRoutes(app, {
3403 dataDir: DATA_DIR,
3404 canisterUrl: CANISTER_URL,
3405 canisterHeaders,
3406 requireBridgeAuth,
3407 resolveHostedBridgeContext,
3408 effectiveRole,
3409 loadRoles,
3410 });
3411
3412 // Flow capture observe/list/propose/dismiss (hosted parity — FLOW-CAPTURE-LIVE-KN-b).
3413 registerBridgeFlowCaptureRoutes(app, {
3414 dataDir: DATA_DIR,
3415 canisterUrl: CANISTER_URL,
3416 canisterHeaders,
3417 requireBridgeAuth,
3418 resolveHostedBridgeContext,
3419 effectiveRole,
3420 loadRoles,
3421 });
3422
3423 // Flow run / consent (hosted parity — SITE-FINISH-FLOW-RUN-KN-b / §FR.0.4).
3424 // FLOW_RUN_WRITES_ENABLED + FLOW_AUTOMATABLE_EXECUTION_ENABLED stay default OFF.
3425 registerBridgeFlowRunRoutes(app, {
3426 dataDir: DATA_DIR,
3427 canisterUrl: CANISTER_URL,
3428 canisterHeaders,
3429 requireBridgeAuth,
3430 resolveHostedBridgeContext,
3431 effectiveRole,
3432 loadRoles,
3433 });
3434
3435 // Media write surfaces: propose/consent/apply-approved + attachment list/get
3436 // (hosted parity — SEC-SEAM-MEDIA-b). Gates default off; blob-backed stores.
3437 registerBridgeMediaRoutes(app, {
3438 dataDir: DATA_DIR,
3439 canisterUrl: CANISTER_URL,
3440 canisterHeaders,
3441 requireBridgeAuth,
3442 resolveHostedBridgeContext,
3443 effectiveRole,
3444 loadRoles,
3445 });
3446
3447 // Docs connectors (Drive OAuth + Notion Hub-key — KN-DOCS-SYNC-b). Gates hard-coded false.
3448 registerBridgeDocsRoutes(app, {
3449 dataDir: DATA_DIR,
3450 requireBridgeAuth,
3451 requireBridgeEditorOrAdmin,
3452 resolveHostedBridgeContext,
3453 resolveHostedBridgeSettingsContext,
3454 sanitizeVaultId,
3455 });
3456
3457 // External Agent Protocol (7D-b-b)
3458 registerBridgeExternalAgentRoutes(app, {
3459 dataDir: DATA_DIR,
3460 requireBridgeAuth,
3461 resolveHostedBridgeContext,
3462 });
3463
3464 function bridgeMemoryAuth(req) {
3465 const auth = req.headers.authorization;
3466 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
3467 const uid = token ? userIdFromJwt(token) : null;
3468 const vaultId = sanitizeVaultId(req.headers['x-vault-id'] || req.query.vault_id);
3469 const scope = req.query.scope === 'global' ? 'global' : 'vault';
3470 return { uid: uid ? sanitizeUserId(uid) : null, vaultId, scope };
3471 }
3472
3473 function bridgeMemoryDir(uid, vaultId, scope) {
3474 if (scope === 'global') {
3475 return path.join(DATA_DIR, 'memory', uid, '_global');
3476 }
3477 return path.join(DATA_DIR, 'memory', uid, vaultId);
3478 }
3479
3480 app.get('/api/v1/memory/:key', async (req, res) => {
3481 const { uid, vaultId } = bridgeMemoryAuth(req);
3482 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3483 try {
3484 const { FileMemoryProvider } = await import('../../lib/memory-provider-file.mjs');
3485 const { MemoryManager } = await import('../../lib/memory.mjs');
3486 const provider = new FileMemoryProvider(bridgeMemoryDir(uid, vaultId));
3487 const mm = new MemoryManager(provider);
3488 const event = mm.getLatest(req.params.key);
3489 if (!event) return res.json({ key: req.params.key, value: null, updated_at: null });
3490 res.json({ key: req.params.key, value: event.data, updated_at: event.ts, id: event.id });
3491 } catch (e) {
3492 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3493 }
3494 });
3495
3496 app.post('/api/v1/memory/store', requireBridgeAuth, requireBridgeEditorOrAdmin, express.json(), async (req, res) => {
3497 const { uid, vaultId } = bridgeMemoryAuth(req);
3498 try {
3499 const { FileMemoryProvider } = await import('../../lib/memory-provider-file.mjs');
3500 const { MemoryManager } = await import('../../lib/memory.mjs');
3501 const provider = new FileMemoryProvider(bridgeMemoryDir(uid, vaultId));
3502 const mm = new MemoryManager(provider);
3503 const { key, value, ttl } = req.body || {};
3504 if (!key || !value) return res.status(400).json({ error: 'key and value required', code: 'BAD_REQUEST' });
3505 const data = typeof value === 'object' ? { key, ...value } : { key, text: String(value) };
3506 const result = mm.store('user', data, { vaultId, ttl });
3507 res.json(result);
3508 } catch (e) {
3509 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3510 }
3511 });
3512
3513 app.get('/api/v1/memory', async (req, res) => {
3514 const { uid, vaultId } = bridgeMemoryAuth(req);
3515 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3516 try {
3517 let events;
3518 if (globalThis.__netlify_blob_store) {
3519 // Hosted: read from Blobs.
3520 events = await blobsGetMemoryEvents(uid, vaultId);
3521 if (req.query.type) events = events.filter((e) => e.type === req.query.type);
3522 if (req.query.since) events = events.filter((e) => e.ts >= req.query.since);
3523 if (req.query.until) events = events.filter((e) => e.ts <= req.query.until);
3524 events.sort((a, b) => (b.ts > a.ts ? 1 : b.ts < a.ts ? -1 : 0));
3525 events = events.slice(0, Math.min(parseInt(req.query.limit) || 20, 100));
3526 } else {
3527 const { FileMemoryProvider } = await import('../../lib/memory-provider-file.mjs');
3528 const { MemoryManager } = await import('../../lib/memory.mjs');
3529 const mm = new MemoryManager(new FileMemoryProvider(bridgeMemoryDir(uid, vaultId)));
3530 events = mm.list({
3531 type: req.query.type || undefined,
3532 since: req.query.since || undefined,
3533 until: req.query.until || undefined,
3534 limit: Math.min(parseInt(req.query.limit) || 20, 100),
3535 });
3536 }
3537 res.json({ events, count: events.length });
3538 } catch (e) {
3539 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3540 }
3541 });
3542
3543 app.post('/api/v1/memory/search', express.json(), async (req, res) => {
3544 const { uid, vaultId } = bridgeMemoryAuth(req);
3545 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3546 res.json({ results: [], count: 0, note: 'Hosted memory search requires vector provider (future).' });
3547 });
3548
3549 app.delete('/api/v1/memory/clear', requireBridgeAuth, requireBridgeEditorOrAdmin, async (req, res) => {
3550 const { uid, vaultId } = bridgeMemoryAuth(req);
3551 try {
3552 const { FileMemoryProvider } = await import('../../lib/memory-provider-file.mjs');
3553 const { MemoryManager } = await import('../../lib/memory.mjs');
3554 const provider = new FileMemoryProvider(bridgeMemoryDir(uid, vaultId));
3555 const mm = new MemoryManager(provider);
3556 const result = mm.clear({
3557 type: req.query.type || undefined,
3558 before: req.query.before || undefined,
3559 });
3560 res.json(result);
3561 } catch (e) {
3562 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3563 }
3564 });
3565
3566 app.get('/api/v1/memory-stats', async (req, res) => {
3567 const { uid, vaultId } = bridgeMemoryAuth(req);
3568 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3569 try {
3570 const { FileMemoryProvider } = await import('../../lib/memory-provider-file.mjs');
3571 const { MemoryManager } = await import('../../lib/memory.mjs');
3572 const provider = new FileMemoryProvider(bridgeMemoryDir(uid, vaultId));
3573 const mm = new MemoryManager(provider);
3574 res.json(mm.stats());
3575 } catch (e) {
3576 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
3577 }
3578 });
3579
3580 // ——— Hosted Consolidation (Phase 10 / Stream 1) ———
3581
3582 // ——— Blobs-backed memory helpers (hosted path) ———
3583
3584 /** Netlify Blobs key for a user's raw memory events. */
3585 function memoryBlobKey(uid, vaultId) {
3586 return `memory/${uid}/${vaultId || 'default'}/events`;
3587 }
3588
3589 /** Load memory events from Netlify Blobs (hosted) or return [] if unavailable. */
3590 async function blobsGetMemoryEvents(uid, vaultId) {
3591 const store = globalThis.__netlify_blob_store;
3592 if (!store) return [];
3593 try {
3594 const raw = await store.get(memoryBlobKey(uid, vaultId), { type: 'text' });
3595 if (!raw) return [];
3596 return JSON.parse(raw) || [];
3597 } catch (_) { return []; }
3598 }
3599
3600 /** Persist memory events to Netlify Blobs, capped at 500 events. */
3601 async function blobsSetMemoryEvents(uid, vaultId, events) {
3602 const store = globalThis.__netlify_blob_store;
3603 if (!store) return;
3604 try {
3605 await store.set(memoryBlobKey(uid, vaultId), JSON.stringify(events.slice(-500)));
3606 } catch (_) {}
3607 }
3608
3609 /** Append a single event to Blobs memory store (read-modify-write). */
3610 async function blobsAppendMemoryEvent(uid, vaultId, event) {
3611 const events = await blobsGetMemoryEvents(uid, vaultId);
3612 events.push(event);
3613 await blobsSetMemoryEvents(uid, vaultId, events);
3614 }
3615
3616 // ——— Consolidation cost tracking ———
3617
3618 function utcDateString() {
3619 return new Date().toISOString().slice(0, 10);
3620 }
3621
3622 function utcMonthString() {
3623 return new Date().toISOString().slice(0, 7);
3624 }
3625
3626 /** Blobs key for per-user consolidation cost record. */
3627 function consolidationCostBlobKey(uid) {
3628 return `memory/${uid}/consolidation-cost`;
3629 }
3630
3631 /** Load consolidation cost record — Blobs on hosted, file on self-hosted. */
3632 async function loadConsolidationCost(uid) {
3633 const store = globalThis.__netlify_blob_store;
3634 if (store) {
3635 try {
3636 const raw = await store.get(consolidationCostBlobKey(uid), { type: 'text' });
3637 if (!raw) return {};
3638 return JSON.parse(raw) || {};
3639 } catch (_) { return {}; }
3640 }
3641 const filePath = path.join(DATA_DIR, 'consolidation', uid + '_cost.json');
3642 try {
3643 const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
3644 return raw && typeof raw === 'object' ? raw : {};
3645 } catch (_) { return {}; }
3646 }
3647
3648 /** Persist consolidation cost record — Blobs on hosted, file on self-hosted. */
3649 async function saveConsolidationCost(uid, data) {
3650 const store = globalThis.__netlify_blob_store;
3651 if (store) {
3652 try {
3653 await store.set(consolidationCostBlobKey(uid), JSON.stringify(data));
3654 } catch (_) {}
3655 return;
3656 }
3657 const filePath = path.join(DATA_DIR, 'consolidation', uid + '_cost.json');
3658 const dir = path.dirname(filePath);
3659 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3660 fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
3661 }
3662
3663 async function recordConsolidationPass(uid, costUsd) {
3664 const rec = await loadConsolidationCost(uid);
3665 const today = utcDateString();
3666 const month = utcMonthString();
3667 const prevCostDate = rec.cost_date;
3668 const prevMonth = rec.pass_month;
3669 return {
3670 last_pass: new Date().toISOString(),
3671 cost_today_usd: prevCostDate === today ? Number((rec.cost_today_usd || 0) + costUsd) : costUsd,
3672 cost_date: today,
3673 cost_cap_usd: process.env.CONSOLIDATION_COST_CAP_USD ? parseFloat(process.env.CONSOLIDATION_COST_CAP_USD) : null,
3674 pass_count_month: prevMonth === month ? (rec.pass_count_month || 0) + 1 : 1,
3675 pass_month: month,
3676 };
3677 }
3678
3679 /**
3680 * POST /api/v1/memory/consolidate
3681 * Body: { dry_run?, passes?, lookback_hours?, max_events_per_pass?, max_topics_per_pass?, llm?: { max_tokens? } }
3682 * Response: { topics, total_events, verify, discover, cost_usd, pass_id }
3683 */
3684 app.post('/api/v1/memory/consolidate', requireBridgeAuth, requireBridgeEditorOrAdmin, express.json(), async (req, res) => {
3685 const { uid, vaultId } = bridgeMemoryAuth(req);
3686
3687 const llmApiKey = process.env.CONSOLIDATION_LLM_API_KEY || process.env.OPENAI_API_KEY;
3688 if (!llmApiKey) {
3689 return res.status(503).json({
3690 error: 'No LLM API key configured for hosted consolidation (CONSOLIDATION_LLM_API_KEY or OPENAI_API_KEY).',
3691 code: 'LLM_NOT_CONFIGURED',
3692 });
3693 }
3694
3695 const mergedBody =
3696 req.body && typeof req.body === 'object' ? { ...req.body } : {};
3697
3698 const { dry_run, passes } = mergedBody;
3699
3700 // 30-minute server-side cooldown on real (non-dry-run) passes to prevent runaway costs.
3701 // Automated scheduler runs respect their own configured interval; this guards manual triggers.
3702 if (!dry_run) {
3703 try {
3704 const costRec = await loadConsolidationCost(uid);
3705 const lastPassAt = costRec?.last_pass;
3706 if (lastPassAt) {
3707 const elapsedMs = Date.now() - new Date(lastPassAt).getTime();
3708 const cooldownMs = 30 * 60 * 1000;
3709 if (elapsedMs < cooldownMs) {
3710 const waitMin = Math.ceil((cooldownMs - elapsedMs) / 60_000);
3711 return res.status(429).json({
3712 error: `Consolidation available again in ${waitMin} minute${waitMin === 1 ? '' : 's'}.`,
3713 code: 'RATE_LIMITED',
3714 retry_after_minutes: waitMin,
3715 });
3716 }
3717 }
3718 } catch (_) {
3719 // If cost record can't be read, allow the pass through — don't block on a read error.
3720 }
3721 }
3722
3723 try {
3724 const { FileMemoryProvider } = await import('../../lib/memory-provider-file.mjs');
3725 const { MemoryManager } = await import('../../lib/memory.mjs');
3726 const { consolidateMemory } = await import('../../lib/memory-consolidate.mjs');
3727 const { computeCallCost } = await import('../../lib/daemon-cost.mjs');
3728 const { createMemoryEvent } = await import('../../lib/memory-event.mjs');
3729
3730 // Hosted (Blobs): load events into a temp FileMemoryProvider so consolidateMemory
3731 // can read and write to it, then sync remaining events back to Blobs.
3732 // Self-hosted: use the normal file-based memory directory.
3733 let mm;
3734 let tempDir = null;
3735 const isHostedBlobs = Boolean(globalThis.__netlify_blob_store);
3736
3737 if (isHostedBlobs) {
3738 const rawEvents = await blobsGetMemoryEvents(uid, vaultId);
3739 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-mm-'));
3740 if (rawEvents.length > 0) {
3741 fs.writeFileSync(
3742 path.join(tempDir, 'events.jsonl'),
3743 rawEvents.map((e) => JSON.stringify(e)).join('\n') + '\n',
3744 'utf8',
3745 );
3746 }
3747 mm = new MemoryManager(new FileMemoryProvider(tempDir));
3748 } else {
3749 mm = new MemoryManager(new FileMemoryProvider(bridgeMemoryDir(uid, vaultId)));
3750 }
3751
3752 const maxTok =
3753 mergedBody.llm && typeof mergedBody.llm === 'object' && mergedBody.llm.max_tokens != null
3754 ? Math.floor(Number(mergedBody.llm.max_tokens))
3755 : 1024;
3756 const lbH =
3757 mergedBody.lookback_hours != null && Number.isFinite(Number(mergedBody.lookback_hours))
3758 ? Number(mergedBody.lookback_hours)
3759 : 24;
3760 const maxEv =
3761 mergedBody.max_events_per_pass != null && Number.isFinite(Number(mergedBody.max_events_per_pass))
3762 ? Number(mergedBody.max_events_per_pass)
3763 : 200;
3764 const maxTop =
3765 mergedBody.max_topics_per_pass != null && Number.isFinite(Number(mergedBody.max_topics_per_pass))
3766 ? Number(mergedBody.max_topics_per_pass)
3767 : 10;
3768
3769 const consolidationConfig = {
3770 data_dir: isHostedBlobs ? os.tmpdir() : DATA_DIR,
3771 llm: {
3772 provider: 'openai',
3773 api_key: llmApiKey,
3774 model: process.env.CONSOLIDATION_LLM_MODEL || 'gpt-4o-mini',
3775 },
3776 daemon: {
3777 lookback_hours: lbH,
3778 max_events_per_pass: maxEv,
3779 max_topics_per_pass: maxTop,
3780 llm: { max_tokens: Number.isFinite(maxTok) ? maxTok : 1024 },
3781 },
3782 memory: {
3783 provider: 'file',
3784 encrypt: process.env.CONSOLIDATION_MEMORY_ENCRYPT === 'true',
3785 },
3786 };
3787
3788 // Track LLM call cost via a wrapping llmFn.
3789 let totalCostUsd = 0;
3790 const { completeChat } = await import('../../lib/llm-complete.mjs');
3791 const trackingLlmFn = async (cfg, callOpts) => {
3792 const rawResponse = await completeChat(consolidationConfig, callOpts);
3793 totalCostUsd += computeCallCost(callOpts, rawResponse);
3794 return rawResponse;
3795 };
3796
3797 const result = await consolidateMemory(consolidationConfig, {
3798 mm,
3799 dryRun: Boolean(dry_run),
3800 passes: passes ?? undefined,
3801 llmFn: dry_run ? undefined : trackingLlmFn,
3802 });
3803
3804 const pass_id = 'cpass_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6);
3805
3806 if (!dry_run) {
3807 const updated = await recordConsolidationPass(uid, totalCostUsd);
3808 await saveConsolidationCost(uid, updated);
3809
3810 // Store pass-level summary event.
3811 const passEvent = createMemoryEvent('consolidation_pass', {
3812 topics_count: Array.isArray(result.topics) ? result.topics.length : (result.topics ?? 0),
3813 total_events: result.total_events,
3814 cost_usd: totalCostUsd,
3815 pass_id,
3816 verify: result.verify ?? null,
3817 discover: result.discover ?? null,
3818 });
3819
3820 if (isHostedBlobs) {
3821 // Sync remaining events (post-consolidation) + pass summary back to Blobs.
3822 const remaining = mm.list({ limit: 500 });
3823 await blobsSetMemoryEvents(uid, vaultId, [...remaining, passEvent]);
3824 } else {
3825 mm.store('consolidation_pass', passEvent.data);
3826 }
3827 }
3828
3829 // Clean up temp dir used for hosted path.
3830 if (tempDir) {
3831 try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {}
3832 }
3833
3834 return res.json({
3835 topics: result.topics,
3836 total_events: result.total_events,
3837 verify: result.verify ?? null,
3838 discover: result.discover ?? null,
3839 cost_usd: totalCostUsd,
3840 pass_id,
3841 dry_run: result.dry_run,
3842 });
3843 } catch (e) {
3844 console.error('[bridge] POST /api/v1/memory/consolidate', e?.message);
3845 res.status(500).json({ error: e.message || 'Consolidation failed', code: 'RUNTIME_ERROR' });
3846 }
3847 });
3848
3849 /**
3850 * GET /api/v1/memory/consolidate/status
3851 * Response: { last_pass, cost_today_usd, cost_cap_usd, pass_count_month }
3852 */
3853 app.get('/api/v1/memory/consolidate/status', async (req, res) => {
3854 const { uid } = bridgeMemoryAuth(req);
3855 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3856
3857 try {
3858 const rec = await loadConsolidationCost(uid);
3859 const today = utcDateString();
3860 const month = utcMonthString();
3861 const passCountMonth = rec.pass_month === month ? (rec.pass_count_month || 0) : 0;
3862
3863 // Cooldown: minutes until the next manual consolidation is available.
3864 const lastPass = rec.last_pass ?? null;
3865 let cooldownMinutes = 0;
3866 if (lastPass) {
3867 const elapsedMs = Date.now() - new Date(lastPass).getTime();
3868 const remaining = 30 * 60 * 1000 - elapsedMs;
3869 cooldownMinutes = remaining > 0 ? Math.ceil(remaining / 60_000) : 0;
3870 }
3871
3872 return res.json({
3873 last_pass: lastPass,
3874 pass_count_month: passCountMonth,
3875 cooldown_minutes: cooldownMinutes,
3876 // Legacy cost fields kept for backward compat
3877 cost_today_usd: rec.cost_date === today ? (rec.cost_today_usd || 0) : 0,
3878 cost_cap_usd: process.env.CONSOLIDATION_COST_CAP_USD
3879 ? parseFloat(process.env.CONSOLIDATION_COST_CAP_USD)
3880 : null,
3881 });
3882 } catch (e) {
3883 console.error('[bridge] GET /api/v1/memory/consolidate/status', e?.message);
3884 res.status(500).json({ error: e.message || 'Internal error', code: 'RUNTIME_ERROR' });
3885 }
3886 });
3887
3888 if (!isServerless) {
3889 if (!CANISTER_URL || !SESSION_SECRET) {
3890 console.error('Bridge: CANISTER_URL and SESSION_SECRET (or HUB_JWT_SECRET) are required.');
3891 console.error(' Add them to the repo root .env (bridge loads ../../.env) or export in your shell.');
3892 console.error(' Template: hub/bridge/.env.example');
3893 process.exit(1);
3894 }
3895 app.listen(PORT, () => {
3896 console.log('Knowtation Hub Bridge listening on http://localhost:' + PORT);
3897 console.log(' Canister: ' + CANISTER_URL);
3898 console.log(' GitHub connect: ' + (process.env.GITHUB_CLIENT_ID ? 'enabled' : 'not configured'));
3899 console.log(' Index/Search: ' + (process.env.EMBEDDING_PROVIDER || 'ollama') + ' (run POST /api/v1/index to index)');
3900 });
3901 }
3902
3903 export { app };
File History 9 commits
sha256:9eaa2a7ec9aaf5866cd03d0deadf843cca97778299d1857b5452f71d92a1880a feat(f24): flip DOCS_NOTION_HUB_KEY_AUTHORIZED=true (Tier 3) Human minor 17 days ago
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 41 days ago
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 57 days ago
sha256:873e30b7fafe601346295f8f4289f388f21d8f715f28584d5481899ba2b714fc Merge pull request #249 from aaronrene/muse-mirror Agent 74 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 77 days ago
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 90 days ago
sha256:f4def6a1a567d25eac87e879d96235c0588804a6c9b770d3958e74b22231db59 fix(test): align hub.js cache-bust contract with hub integr… Human 96 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 97 days ago