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