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