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