/** * REST API routes for GNO web UI. * All routes return JSON with consistent error format. * * @module src/serve/routes/api */ // node:fs/promises structure ops + realpath have no Bun equivalent import { readdir, realpath } from "node:fs/promises"; // node:path has no Bun equivalent import { posix as pathPosix } from "node:path"; import type { Collection, CollectionModelOverrides, Config, EgressPolicy, ModelPreset, } from "../../config/types"; import type { JobManager } from "../../core/job-manager"; import type { ProjectAffinityScoringInput } from "../../pipeline/project-affinity"; import type { AskResult, Citation, QueryModeInput, SearchOptions, } from "../../pipeline/types"; import type { SqliteAdapter } from "../../store/sqlite/adapter"; import type { ActivationVerificationReceipt, DocumentRow, StorePort, StoreResult, } from "../../store/types"; import type { DocumentEventBus } from "../doc-events"; import type { EmbedScheduler } from "../embed-scheduler"; import type { StartJobError } from "../jobs"; import type { ResidentStatus } from "../status-model"; import type { CollectionWatchService } from "../watch-service"; import { getIndexDbPath } from "../../app/constants"; import { buildVerifiedAsk } from "../../app/verified-ask"; import { modelsPull } from "../../cli/commands/models/pull"; import { addCollection, removeCollection, updateCollection, } from "../../collection"; import { fingerprintContentTypeMetadataRules, normalizeContentTypes, } from "../../config"; import { type PublicCaptureInput } from "../../core/capture"; import { projectCollectionEgressPolicy } from "../../core/collection-egress-policy-projection"; import { CollectionEgressPolicyService, type CollectionEgressPolicyState, type EgressRelaxationConfirmation, } from "../../core/collection-egress-policy-service"; import { parsePolicySetBody } from "../../core/collection-egress-policy-validation"; import { type ConnectorVerificationCode, getConnectorVerificationRemediation, } from "../../core/connector-verifier"; import { buildEditableCopyContent, deriveEditableCopyRelPath, getDocumentCapabilities, } from "../../core/document-capabilities"; import { EgressAuditService } from "../../core/egress-audit"; import { atomicWrite, copyFilePath, createFolderPath, revealFilePath, trashFilePath, } from "../../core/file-ops"; import { assertFileRefactorSyncConverged, buildRefactorWarnings, planCreateFolder, planDuplicateRefactor, } from "../../core/file-refactors"; import { MemoryError, type MemoryErrorCode, MemoryService, type RecallInput, type RememberInput, } from "../../core/memory"; import { hasContentMutation, recordContentMutation, recordIndexMutation, } from "../../core/mutation-generations"; import { resolveNoteCreatePlan, sanitizeNoteFilename, type NoteCollisionPolicy, } from "../../core/note-creation"; import { getNotePreset, NOTE_PRESETS, resolveNotePreset, type NotePresetId, } from "../../core/note-presets"; import { ProjectAffinityInputError, resolveRemoteProjectAffinity, } from "../../core/project-affinity-surface"; import { projectRecordEvidenceMetadata } from "../../core/record-metadata"; import { retrievalTraceFilters, startRetrievalTraceRequest, } from "../../core/retrieval-trace-request"; import { evidenceFromExactDocument, RetrievalTraceSession, } from "../../core/retrieval-trace-session"; import { extractSections } from "../../core/sections"; import { normalizeStructuredQueryInput } from "../../core/structured-query"; import { normalizeTag, parseAndValidateTagFilter, validateTag, } from "../../core/tags"; import { metadataPredicateSchema, normalizeMetadataPredicate, type MetadataPredicate, } from "../../core/typed-metadata"; import { validateRelPath } from "../../core/validation"; import { writeLeasePath } from "../../core/write-lease"; import { defaultSyncService, type SyncResult, withContentTypeRules, } from "../../ingestion"; import { updateFrontmatterTags } from "../../ingestion/frontmatter"; import { getCollectionEffectiveModels, getCollectionModelSources, getModelConfig, getPreset, listPresets, resolveModelUri, } from "../../llm/registry"; import { answerTraceTerminalStatus, generateGroundedAnswer, processAnswerResultWithTrace, } from "../../pipeline/answer"; import { diagnoseQueryTarget } from "../../pipeline/diagnose"; import { searchHybrid } from "../../pipeline/hybrid"; import { validateQueryModes } from "../../pipeline/query-modes"; import { searchBm25 } from "../../pipeline/search"; import { derivePublishArtifactFilename, isPublishVisibility, type PublishVisibility, } from "../../publish/artifact"; import { exportPublishArtifact } from "../../publish/export-service"; import { buildBrowseTree, normalizeBrowsePath } from "../browse-tree"; import { classifyResidentCaptureError, executeResidentCapturePlan, planResidentCapture, type ResidentCaptureDependencies, } from "../capture-service"; import { parseClosedJson } from "../closed-json"; import { applyConfigChange, applyConfigChangeTyped } from "../config-sync"; import { getConnectorStatuses, installConnector, verifyInstalledConnector, } from "../connectors"; import { downloadState, reloadServerContext, resetDownloadState, type ServerContext, } from "../context"; import { applyCanonicalFileRefactor, buildCanonicalRefactorPlan, buildFileRefactorApplyDeps, mapApplyResultToHttpSuccess, parseRefactorApplyConfirmation, resolveMoveTarget, resolveRenameTarget, toRefactorPlanResponse, type FileRefactorHttpError, } from "../file-refactor-http"; import { analyzeImportPath } from "../import-preview"; import { getActiveJob, getJobStatus, startJob } from "../jobs"; import { isLocalClientRequest, type RequestPeerServer, } from "../request-locality"; import { requestRetrievalTraceId, withRetrievalTraceHeader, } from "../retrieval-trace"; import { buildAppStatus, type StatusBuildDeps } from "../status"; import { handleChanges, handleDiff, handleImpact } from "./changes"; /** Mutable context holder for hot-reloading presets */ export interface ContextHolder { current: ServerContext; config: Config; actualConfigPath?: string; scheduler: EmbedScheduler | null; eventBus: DocumentEventBus | null; watchService: CollectionWatchService | null; jobManager?: JobManager; markContentMutation?: () => void; markIndexMutation?: () => void; invalidateEgressPolicy?: () => Promise<{ policyEpoch: string; queuedJobsInvalidated: number; sessionsInvalidated: number; staleWorkMustRetry: true; }>; startBackgroundWork?: ( operation: (signal: AbortSignal) => Promise ) => boolean; } async function syncResidentCollection( ctxHolder: ContextHolder, collection: Collection, store: SqliteAdapter, options: Parameters[2], syncCollection: typeof defaultSyncService.syncCollection = defaultSyncService.syncCollection.bind( defaultSyncService ) ): ReturnType { const result = await syncCollection(collection, store, options); recordContentMutation(result, ctxHolder.markContentMutation); return result; } // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface ApiError { error: { code: string; message: string; details?: Record; }; } export interface InstallConnectorRequestBody { connectorId: string; reinstall?: boolean; } export interface VerifyConnectorRequestBody { connectorId: string; collection: string; } interface ConnectorRouteDeps { getStatuses: typeof getConnectorStatuses; verify: ( id: string, store: StorePort, collection: string, options?: { force?: boolean }, overrides?: { cwd?: string; homeDir?: string } ) => Promise>; } const DEFAULT_CONNECTOR_ROUTE_DEPS: ConnectorRouteDeps = { getStatuses: getConnectorStatuses, verify: verifyInstalledConnector, }; export interface SearchRequestBody { query: string; projectHints?: string[]; // Only BM25 supported in web UI (vector/hybrid require LLM deps) limit?: number; minScore?: number; collection?: string; intent?: string; exclude?: string; since?: string; until?: string; /** Comma-separated category filters */ category?: string; author?: string; filter?: MetadataPredicate; /** Comma-separated tags - filter to docs having ALL (AND) */ tagsAll?: string; /** Comma-separated tags - filter to docs having ANY (OR) */ tagsAny?: string; } export interface QueryRequestBody { query: string; projectHints?: string[]; limit?: number; minScore?: number; collection?: string; lang?: string; intent?: string; candidateLimit?: number; exclude?: string; since?: string; until?: string; /** Comma-separated category filters */ category?: string; author?: string; filter?: MetadataPredicate; queryModes?: QueryModeInput[]; noExpand?: boolean; noRerank?: boolean; noGraph?: boolean; graph?: boolean; explain?: boolean; /** Comma-separated tags - filter to docs having ALL (AND) */ tagsAll?: string; /** Comma-separated tags - filter to docs having ANY (OR) */ tagsAny?: string; } export interface QueryDiagnoseRequestBody extends QueryRequestBody { target?: string; } export interface AskRequestBody { query: string; projectHints?: string[]; limit?: number; collection?: string; lang?: string; intent?: string; candidateLimit?: number; exclude?: string; queryModes?: QueryModeInput[]; since?: string; until?: string; /** Comma-separated category filters */ category?: string; author?: string; filter?: MetadataPredicate; maxAnswerTokens?: number; verify?: boolean; contextBudgetTokens?: number; contextBudgetBytes?: number; minScore?: number; noExpand?: boolean; noRerank?: boolean; graph?: boolean; noGraph?: boolean; explain?: boolean; /** Comma-separated tags - filter to docs having ALL (AND) */ tagsAll?: string; /** Comma-separated tags - filter to docs having ANY (OR) */ tagsAny?: string; } const ASK_REQUEST_KEYS = new Set([ "query", "projectHints", "limit", "collection", "lang", "intent", "candidateLimit", "exclude", "queryModes", "since", "until", "category", "author", "filter", "maxAnswerTokens", "verify", "contextBudgetTokens", "contextBudgetBytes", "minScore", "noExpand", "noRerank", "graph", "noGraph", "explain", "tagsAll", "tagsAny", ]); export interface CreateCollectionRequestBody { path: string; name?: string; pattern?: string; include?: string; exclude?: string; gitPull?: boolean; } export interface UpdateCollectionRequestBody { models?: { embed?: string | null; rerank?: string | null; expand?: string | null; gen?: string | null; }; } export interface UpdateCollectionEgressPolicyRequestBody { policy: EgressPolicy; confirmation?: EgressRelaxationConfirmation; } export interface ClearCollectionEmbeddingsRequestBody { mode?: "stale" | "all"; } export interface CollectionResponse { name: string; path: string; pattern: string; include: string[]; exclude: string[]; updateCmd?: string; languageHint?: string; models?: CollectionModelOverrides; effectiveModels: { embed: string; rerank: string; expand: string; gen: string; }; modelSources: { embed: "override" | "preset" | "default"; rerank: "override" | "preset" | "default"; expand: "override" | "preset" | "default"; gen: "override" | "preset" | "default"; }; activePresetId: string; egressPolicy: CollectionEgressPolicyState | null; } function serializeCollection( config: Config, collection: Collection ): CollectionResponse { const modelConfig = getModelConfig(config); return { name: collection.name, path: collection.path, pattern: collection.pattern, include: collection.include, exclude: collection.exclude, updateCmd: collection.updateCmd, languageHint: collection.languageHint, models: collection.models, effectiveModels: getCollectionEffectiveModels(config, collection.name), modelSources: getCollectionModelSources(config, collection.name), activePresetId: modelConfig.activePreset, egressPolicy: (() => { const result = new CollectionEgressPolicyService({ getConfig: () => config, }).get(collection.name); return result.ok ? result.value : null; })(), }; } export interface ImportPreviewRequestBody { path: string; name?: string; } export interface SyncRequestBody { collection?: string; gitPull?: boolean; } export interface CreateDocRequestBody { collection: string; relPath?: string; title?: string; folderPath?: string; content?: string; overwrite?: boolean; collisionPolicy?: NoteCollisionPolicy; presetId?: NotePresetId; /** Tags to add to document (written to frontmatter for markdown) */ tags?: string[]; } export interface CreateCaptureRequestBody extends PublicCaptureInput {} export interface RenameDocRequestBody { name: string; uri?: string; /** Exact plan digest from preview. */ planDigest: string; /** Exact destructive confirmation token. */ confirmation: string; schemaVersion: string; } export interface MoveDocRequestBody { folderPath: string; name?: string; uri?: string; /** Exact plan digest from preview. */ planDigest: string; /** Exact destructive confirmation token. */ confirmation: string; schemaVersion: string; } export interface DuplicateDocRequestBody { folderPath?: string; name?: string; uri?: string; } export interface CreateFolderRequestBody { collection: string; parentPath?: string; name: string; } export interface RefactorPlanRequestBody { operation: "rename" | "move" | "duplicate"; name?: string; folderPath?: string; uri?: string; } export interface UpdateDocRequestBody { /** New content (optional if only updating tags) */ content?: string; /** Tags to set (replaces existing tags) */ tags?: string[]; /** Expected source hash for optimistic concurrency */ expectedSourceHash?: string; /** Expected source modified timestamp for optimistic concurrency */ expectedModifiedAt?: string; /** Exact document URI when docid is not unique across duplicate content */ uri?: string; } export interface CreateEditableCopyRequestBody { collection?: string; relPath?: string; uri?: string; } /** POST /api/memory/remember body: the shared core contract, verbatim. */ export interface MemoryRememberRequestBody extends RememberInput {} /** POST /api/memory/recall body: the shared core contract, verbatim. */ export interface MemoryRecallRequestBody extends RecallInput {} export interface PublishExportRequestBody { encryptionPassphrase?: string; slug?: string; summary?: string; target: string; title?: string; visibility?: PublishVisibility; } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function jsonResponse(data: unknown, status = 200): Response { return Response.json(data, { status }); } function errorResponse( code: string, message: string, status = 400, details?: Record ): Response { return jsonResponse( { error: { code, message, ...(details ? { details } : {}), }, }, status ); } function parseRestMetadataFilter( value: unknown ): | { ok: true; filter: MetadataPredicate | undefined } | { ok: false; response: Response } { if (value === undefined) return { ok: true, filter: undefined }; const parsed = metadataPredicateSchema.safeParse(value); if (!parsed.success) { const issue = parsed.error.issues[0]; const path = ["filter", ...(issue?.path ?? [])].join("."); return { ok: false, response: errorResponse( "VALIDATION", `${path}: ${issue?.message ?? "Invalid predicate"}`, 400, { field: path } ), }; } return { ok: true, filter: normalizeMetadataPredicate(parsed.data) }; } function fileRefactorHttpErrorResponse(error: FileRefactorHttpError): Response { return errorResponse(error.code, error.message, error.status, error.details); } interface RestTraceStart { session: RetrievalTraceSession | null; error: Response | null; } async function startRestTrace( ctx: ServerContext, input: { query: string; goal?: string; filters?: Record; pipeline: string; modelUris?: string[]; } ): Promise { const started = await startRetrievalTraceRequest({ store: ctx.store, config: ctx.config, query: input.query, goal: input.goal, filters: input.filters, pipeline: input.pipeline, indexName: ctx.indexName, modelUris: input.modelUris, }); return started.ok ? { session: started.value, error: null } : { session: null, error: errorResponse( "RUNTIME", `Retrieval trace start failed: ${started.error.message}`, 500 ), }; } async function finishRestTrace( request: Request, session: RetrievalTraceSession | null, status: "completed" | "partial" | "failed" | "cancelled", response: Response ): Promise { if (!session) return response; const terminalStatus = request.signal.aborted || status === "cancelled" ? "cancelled" : status; const finished = await session.finish(terminalStatus); if (!finished.ok) { return withRetrievalTraceHeader( errorResponse( "RUNTIME", `Retrieval trace finalization failed: ${finished.error.message}`, 500 ), session ); } return withRetrievalTraceHeader(response, session); } function jobConflictResponse(jobResult: StartJobError): Response { return errorResponse("CONFLICT", jobResult.error, 409, { activeJobId: jobResult.activeJobId, }); } function parseCommaSeparatedValues(input: string): string[] { return Array.from( new Set( input .split(",") .map((value) => value.trim().toLowerCase()) .filter(Boolean) ) ); } function hashContent(content: string): string { const hasher = new Bun.CryptoHasher("sha256"); hasher.update(content); return hasher.digest("hex"); } function readRequestedUriFromUrl(req: Request): string | undefined { const value = new URL(req.url).searchParams.get("uri"); return value?.trim() ? value : undefined; } interface SourceMeta { absPath?: string; relPath: string; mime: string; ext: string; modifiedAt?: string; sizeBytes?: number; sourceHash?: string; } function getCollectionByName( collections: Config["collections"], collectionName: string ) { return collections.find( (c) => c.name.toLowerCase() === collectionName.toLowerCase() ); } async function resolveAbsoluteDocPath( collections: Config["collections"], doc: { collection: string; relPath: string; recordSourcePath?: string | null; } ): Promise<{ collection: Config["collections"][number]; fullPath: string; } | null> { const collection = getCollectionByName(collections, doc.collection); if (!collection) { return null; } const nodePath = await import("node:path"); // no bun equivalent let safeRelPath: string; try { safeRelPath = validateRelPath(doc.recordSourcePath ?? doc.relPath); } catch { return null; } return { collection, fullPath: nodePath.join(collection.path, safeRelPath), }; } function isAbsoluteFilesystemPath(pathValue: string): boolean { return /^(?:\/(?:Users|home|var|tmp|private|Volumes)\/|[A-Za-z]:[\\/])/.test( pathValue ); } export type RealpathFn = (path: string) => Promise; /** * Lexical containment, then realpath containment (symlink-escape defense). * Only candidate ENOENT falls back to the lexical verdict; other realpath * errors fail closed. Both root and candidate are canonicalized when present. * Exported for adversarial unit tests (I1-04 non-ENOENT fail-closed). */ export async function isPathWithinRoot( root: string, candidate: string, realpathFn: RealpathFn = realpath ): Promise { const nodePath = await import("node:path"); // no bun equivalent const relative = nodePath.relative(root, candidate); const lexicallyWithin = relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative)); if (!lexicallyWithin) { return false; } let resolvedRoot: string; try { resolvedRoot = await realpathFn(root); } catch { // Root must resolve; fail closed return false; } let resolvedCandidate: string; try { resolvedCandidate = await realpathFn(candidate); } catch (error) { const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined; // Genuinely missing file: accept lexical verdict so callers can 404 later if (code === "ENOENT") { return true; } // EACCES, ELOOP, etc. fail closed return false; } const resolvedRelative = nodePath.relative(resolvedRoot, resolvedCandidate); return ( resolvedRelative === "" || (!resolvedRelative.startsWith("..") && !nodePath.isAbsolute(resolvedRelative)) ); } async function listCollectionRelPaths( store: Pick, collection: string ): Promise { const result = await store.listDocuments(collection); if (!result.ok) { throw new Error(result.error.message); } return result.value.map((entry) => entry.relPath); } const BROWSE_HIDDEN_DIRS = new Set([".git", ".obsidian", "node_modules"]); async function listCollectionFolders( collections: Array<{ name: string; path: string }> ): Promise> { const folders: Array<{ collection: string; path: string }> = []; async function walk( collection: string, rootPath: string, relativePath = "" ): Promise { const currentPath = relativePath ? `${rootPath}/${relativePath}` : rootPath; let entries; try { entries = await readdir(currentPath, { withFileTypes: true, }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory()) { continue; } if (entry.name.startsWith(".") || BROWSE_HIDDEN_DIRS.has(entry.name)) { continue; } const nextRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; folders.push({ collection, path: normalizeBrowsePath(nextRelativePath), }); await walk(collection, rootPath, nextRelativePath); } } for (const collection of collections) { await walk(collection.name, collection.path); } return folders; } async function getRefactorSnapshot( store: Partial>, documentId: number ) { if (!store.getLinksForDoc || !store.getBacklinksForDoc) { return { backlinks: 0, wikiLinks: 0, markdownLinks: 0, }; } const [linksResult, backlinksResult] = await Promise.all([ store.getLinksForDoc(documentId), store.getBacklinksForDoc(documentId), ]); if (!linksResult.ok) { throw new Error(linksResult.error.message); } if (!backlinksResult.ok) { throw new Error(backlinksResult.error.message); } return { backlinks: backlinksResult.value.length, wikiLinks: linksResult.value.filter((entry) => entry.linkType === "wiki") .length, markdownLinks: linksResult.value.filter( (entry) => entry.linkType === "markdown" ).length, }; } async function buildSourceMeta( collections: Config["collections"], doc: { collection: string; relPath: string; recordSourcePath?: string | null; sourceMime: string; sourceExt: string; sourceMtime?: string | null; sourceSize?: number; sourceHash?: string; } ): Promise { const relPath = doc.recordSourcePath ?? doc.relPath; const resolved = await resolveAbsoluteDocPath(collections, { collection: doc.collection, relPath, }); return { absPath: resolved?.fullPath, relPath, mime: doc.sourceMime, ext: doc.sourceExt, modifiedAt: doc.sourceMtime ?? undefined, sizeBytes: doc.sourceSize, sourceHash: doc.sourceHash, }; } const capabilitiesForDocument = ( doc: Pick< DocumentRow, "sourceExt" | "sourceMime" | "mirrorHash" | "recordKey" >, contentAvailable = doc.mirrorHash !== null ) => getDocumentCapabilities({ sourceExt: doc.sourceExt, sourceMime: doc.sourceMime, contentAvailable, recordKey: doc.recordKey, }); async function resolveDocumentReference( store: Pick, docId: string, requestedUri?: string ): Promise< | { ok: true; value: DocumentRow | null } | { ok: false; error: { message: string } } > { if (requestedUri) { const byUri = await store.getDocumentByUri(requestedUri); if (!byUri.ok) { return { ok: false, error: byUri.error }; } return { ok: true, value: byUri.value }; } const byDocid = await store.getDocumentByDocid(docId); if (!byDocid.ok) { return { ok: false, error: byDocid.error }; } return { ok: true, value: byDocid.value }; } function parseQueryModesInput(value: unknown): { queryModes?: QueryModeInput[]; error?: Response; } { if (value === undefined) { return {}; } if (!Array.isArray(value)) { return { error: errorResponse( "VALIDATION", "queryModes must be an array of { mode, text } objects" ), }; } const queryModes: QueryModeInput[] = []; let hydeCount = 0; for (const [index, entry] of value.entries()) { if (!entry || typeof entry !== "object") { return { error: errorResponse( "VALIDATION", `queryModes[${index}] must be an object` ), }; } const mode = (entry as { mode?: unknown }).mode; const text = (entry as { text?: unknown }).text; if (mode !== "term" && mode !== "intent" && mode !== "hyde") { return { error: errorResponse( "VALIDATION", `queryModes[${index}].mode must be one of: term, intent, hyde` ), }; } if (typeof text !== "string" || !text.trim()) { return { error: errorResponse( "VALIDATION", `queryModes[${index}].text must be a non-empty string` ), }; } if (mode === "hyde") { hydeCount += 1; if (hydeCount > 1) { return { error: errorResponse( "VALIDATION", "Only one hyde mode is allowed in queryModes" ), }; } } queryModes.push({ mode, text: text.trim() }); } const validated = validateQueryModes(queryModes); if (!validated.ok) { return { error: errorResponse("VALIDATION", validated.error.message), }; } return { queryModes: validated.value }; } function normalizeStructuredQueryBody( query: string, queryModes: QueryModeInput[] | undefined ): { query?: string; queryModes?: QueryModeInput[]; error?: Response; } { const normalized = normalizeStructuredQueryInput(query, queryModes ?? []); if (!normalized.ok) { return { error: errorResponse("VALIDATION", normalized.error.message), }; } return { query: normalized.value.query, queryModes: normalized.value.queryModes.length > 0 ? normalized.value.queryModes : undefined, }; } // ───────────────────────────────────────────────────────────────────────────── // Route Handlers // ───────────────────────────────────────────────────────────────────────────── /** * GET /api/health * Health check endpoint. */ export function handleHealth(): Response { return jsonResponse({ ok: true }); } const statusBuilds = new WeakMap< ServerContext, Promise>> >(); async function getCoalescedAppStatus( ctx: ServerContext, deps?: StatusBuildDeps ): Promise>> { const existing = statusBuilds.get(ctx); if (existing) { return existing; } const build = buildAppStatus(ctx, deps ?? {}); statusBuilds.set(ctx, build); try { return await build; } finally { if (statusBuilds.get(ctx) === build) { statusBuilds.delete(ctx); } } } /** * GET /api/status * Returns index status matching status.schema.json. */ export async function handleStatus( ctx: ServerContext, deps?: StatusBuildDeps ): Promise { try { const status = await getCoalescedAppStatus(ctx, deps); return jsonResponse(status); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to get status", 500 ); } } export function handleResidentStatus( getStatus: () => ResidentStatus ): Response { return jsonResponse(getStatus()); } /** * GET /api/collections * Returns list of collections. */ export async function handleCollections(config: Config): Promise { return jsonResponse( config.collections.map((c) => serializeCollection(config, c)) ); } const collectionPolicyService = ( ctxHolder: ContextHolder, store: SqliteAdapter, collection: string ): CollectionEgressPolicyService => new CollectionEgressPolicyService({ getConfig: () => ctxHolder.config, mutateConfig: (mutate) => applyConfigChangeTyped( ctxHolder, store, (config) => mutate(config), undefined, { projectStore: (targetStore, config) => projectCollectionEgressPolicy(targetStore, config, collection), } ), onPolicyChanged: async () => (await ctxHolder.invalidateEgressPolicy?.()) ?? { policyEpoch: "egress-epoch-standalone", queuedJobsInvalidated: 0, sessionsInvalidated: 0, staleWorkMustRetry: true, }, }); export function handleCollectionEgressPolicy( ctxHolder: ContextHolder, name: string ): Response { const state = new CollectionEgressPolicyService({ getConfig: () => ctxHolder.config, }).get(name); if (state.ok) return jsonResponse(state.value); return errorResponse( state.code === "NOT_FOUND" ? "NOT_FOUND" : "VALIDATION", state.error, state.code === "NOT_FOUND" ? 404 : 400 ); } export async function handleUpdateCollectionEgressPolicy( ctxHolder: ContextHolder, store: SqliteAdapter, name: string, req: Request ): Promise { const parsed = await parseClosedJson(req); if (!parsed.ok) return errorResponse("VALIDATION", parsed.error, 400); const input = parsePolicySetBody(parsed.value, name); if (!input.ok) return errorResponse("VALIDATION", input.error, 400); const result = await collectionPolicyService(ctxHolder, store, name).set( input.value ); if (!result.ok) { const status = result.code === "NOT_FOUND" ? 404 : result.code === "EGRESS_RELAXATION_CONFIRMATION_REQUIRED" ? 409 : 400; return errorResponse(result.code, result.error, status); } return jsonResponse(result.value); } export async function handleCollectionEgressCheck( ctxHolder: ContextHolder, req: Request ): Promise { const parsed = await parseClosedJson(req); if (!parsed.ok) return errorResponse("VALIDATION", parsed.error, 400); const result = new CollectionEgressPolicyService({ getConfig: () => ctxHolder.config, }).check(parsed.value); return result.ok ? jsonResponse(result.value) : errorResponse("VALIDATION", result.error, 400); } export async function handleEgressAuditList( store: SqliteAdapter, req: Request ): Promise { const url = new URL(req.url); const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined; const result = await new EgressAuditService(store).list({ limit, cursor: url.searchParams.get("cursor") ?? undefined, }); return result.ok ? jsonResponse(result.value) : errorResponse(result.error.code, result.error.message); } export async function handleEgressAuditShow( store: SqliteAdapter, auditId: string ): Promise { const result = await new EgressAuditService(store).show(auditId); return result.ok ? jsonResponse(result.value) : errorResponse( result.error.code, result.error.message, result.error.code === "NOT_FOUND" ? 404 : 400 ); } export async function handleEgressAuditStatus( store: SqliteAdapter ): Promise { const result = await new EgressAuditService(store).status(); return result.ok ? jsonResponse(result.value) : errorResponse(result.error.code, result.error.message); } export async function handleEgressAuditDelete( store: SqliteAdapter, auditId: string ): Promise { const result = await new EgressAuditService(store).delete(auditId); return result.ok ? jsonResponse(result.value) : errorResponse( result.error.code, result.error.message, result.error.code === "NOT_FOUND" ? 404 : 400 ); } export async function handleEgressAuditPurge( store: SqliteAdapter ): Promise { const result = await new EgressAuditService(store).purge(); return result.ok ? jsonResponse(result.value) : errorResponse(result.error.code, result.error.message); } /** * POST /api/publish/export * Build a gno.sh-compatible publish artifact for a collection or single doc. */ export async function handlePublishExport( config: Config, store: SqliteAdapter, req: Request, configPath?: string ): Promise { let body: PublishExportRequestBody; try { body = (await req.json()) as PublishExportRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.target || typeof body.target !== "string") { return errorResponse("VALIDATION", "Missing or invalid target"); } if (body.slug !== undefined && typeof body.slug !== "string") { return errorResponse("VALIDATION", "slug must be a string"); } if (body.summary !== undefined && typeof body.summary !== "string") { return errorResponse("VALIDATION", "summary must be a string"); } if ( body.encryptionPassphrase !== undefined && typeof body.encryptionPassphrase !== "string" ) { return errorResponse("VALIDATION", "encryptionPassphrase must be a string"); } if (body.title !== undefined && typeof body.title !== "string") { return errorResponse("VALIDATION", "title must be a string"); } if (body.visibility !== undefined && !isPublishVisibility(body.visibility)) { return errorResponse( "VALIDATION", "visibility must be public, secret-link, invite-only, or encrypted" ); } try { const { artifact, assetSummary, warnings } = await exportPublishArtifact({ collections: config.collections, options: { configPath, encryptionPassphrase: body.encryptionPassphrase, routeSlug: body.slug, summary: body.summary, title: body.title, visibility: body.visibility, }, store, target: body.target.trim(), }); return jsonResponse({ artifact, assetSummary, fileName: derivePublishArtifactFilename(artifact), uploadUrl: "https://gno.sh/studio", warnings, }); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to export publish artifact", 500 ); } } /** * POST /api/collections * Create a new collection and start sync job. */ export async function handleCreateCollection( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request ): Promise { let body: CreateCollectionRequestBody; try { body = (await req.json()) as CreateCollectionRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } // Validate required fields if (!body.path || typeof body.path !== "string") { return errorResponse("VALIDATION", "Missing or invalid path"); } // Validate optional fields have correct types if (body.name !== undefined && typeof body.name !== "string") { return errorResponse("VALIDATION", "name must be a string"); } if (body.pattern !== undefined && typeof body.pattern !== "string") { return errorResponse("VALIDATION", "pattern must be a string"); } if ( body.include !== undefined && typeof body.include !== "string" && !Array.isArray(body.include) ) { return errorResponse("VALIDATION", "include must be a string or array"); } if ( body.exclude !== undefined && typeof body.exclude !== "string" && !Array.isArray(body.exclude) ) { return errorResponse("VALIDATION", "exclude must be a string or array"); } if (body.gitPull !== undefined && typeof body.gitPull !== "boolean") { return errorResponse("VALIDATION", "gitPull must be a boolean"); } // Derive name from path if not provided const path = await import("node:path"); // no bun equivalent const name = body.name || path.basename(body.path); // Persist config and sync to DB (mutation happens inside with fresh config) const syncResult = await applyConfigChangeTyped( ctxHolder, store, async (cfg) => { const addResult = await addCollection(cfg, { path: body.path, name, pattern: body.pattern, include: body.include, exclude: body.exclude, }); if (!addResult.ok) { return { ok: false, error: addResult.message, code: addResult.code }; } return { ok: true, config: addResult.config, value: addResult.collection, }; } ); if (!syncResult.ok) { // Map mutation error codes to HTTP status codes const statusMap: Record = { DUPLICATE: 409, DUPLICATE_PATH: 409, PATH_NOT_FOUND: 400, }; const status = statusMap[syncResult.code] ?? 500; return errorResponse(syncResult.code, syncResult.error, status); } const collection = syncResult.value ?? syncResult.config.collections.find((c) => c.path === body.path.trim()) ?? syncResult.config.collections.find( (c) => c.name === name || c.name === name.toLowerCase() ); if (!collection) { return errorResponse("RUNTIME", "Collection not found after add", 500); } await ctxHolder.invalidateEgressPolicy?.(); const jobResult = await startJob( "add", async (): Promise => { const result = await syncResidentCollection( ctxHolder, collection, store, withContentTypeRules( { gitPull: body.gitPull, runUpdateCmd: true, }, syncResult.config ) ); if (result.filesAdded > 0 || result.filesUpdated > 0) { if (ctxHolder.scheduler) { await ctxHolder.scheduler.triggerNow(); } } return { collections: [result], totalDurationMs: result.durationMs, totalFilesProcessed: result.filesProcessed, totalFilesAdded: result.filesAdded, totalFilesUpdated: result.filesUpdated, totalFilesErrored: result.filesErrored, totalFilesSkipped: result.filesSkipped, }; }, ctxHolder.jobManager ); if (!jobResult.ok) { return jobConflictResponse(jobResult); } return jsonResponse( { jobId: jobResult.jobId, collection: { name: collection.name, path: collection.path }, }, 202 ); } /** * POST /api/import/preview * Preview what GNO will import from a folder before indexing starts. */ export async function handleImportPreview( ctxHolder: ContextHolder, req: Request ): Promise { let body: ImportPreviewRequestBody; try { body = (await req.json()) as ImportPreviewRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.path || typeof body.path !== "string") { return errorResponse("VALIDATION", "Missing or invalid path"); } if (body.name !== undefined && typeof body.name !== "string") { return errorResponse("VALIDATION", "name must be a string"); } try { return jsonResponse({ preview: await analyzeImportPath(ctxHolder.config, body.path, body.name), }); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to preview import", 500 ); } } /** * DELETE /api/collections/:name * Remove a collection from config. * Note: Does NOT remove indexed documents - they remain in DB until re-sync * or manual cleanup. This preserves data for potential recovery. */ export async function handleDeleteCollection( ctxHolder: ContextHolder, store: SqliteAdapter, name: string ): Promise { // Persist config and sync to DB (mutation happens inside with fresh config) const syncResult = await applyConfigChange(ctxHolder, store, (cfg) => { const removeResult = removeCollection(cfg, { name }); if (!removeResult.ok) { return { ok: false, error: removeResult.message, code: removeResult.code, }; } return { ok: true, config: removeResult.config }; }); if (!syncResult.ok) { // Map mutation error codes to HTTP status codes const statusMap: Record = { NOT_FOUND: 404, HAS_REFERENCES: 400, }; const status = statusMap[syncResult.code] ?? 500; return errorResponse(syncResult.code, syncResult.error, status); } await ctxHolder.invalidateEgressPolicy?.(); ctxHolder.markContentMutation?.(); ctxHolder.markIndexMutation?.(); return jsonResponse({ success: true, collection: name, note: "Collection removed from config. Indexed documents remain in DB.", }); } /** * PATCH /api/collections/:name * Update collection model overrides. */ export async function handleUpdateCollection( ctxHolder: ContextHolder, store: SqliteAdapter, name: string, req: Request ): Promise { let body: UpdateCollectionRequestBody; try { body = (await req.json()) as UpdateCollectionRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (body.models !== undefined && typeof body.models !== "object") { return errorResponse("VALIDATION", "models must be an object"); } if (!body.models) { return errorResponse("VALIDATION", "Missing models patch"); } for (const [role, value] of Object.entries(body.models)) { if (!["embed", "rerank", "expand", "gen"].includes(role)) { return errorResponse("VALIDATION", `Unknown model role: ${role}`); } if (value !== undefined && value !== null && typeof value !== "string") { return errorResponse("VALIDATION", `${role} must be a string or null`); } } const syncResult = await applyConfigChangeTyped( ctxHolder, store, (cfg) => { const result = updateCollection(cfg, { name, models: body.models, }); if (!result.ok) { return Promise.resolve({ ok: false as const, error: result.message, code: result.code, }); } return Promise.resolve({ ok: true as const, config: result.config, value: result.collection, }); } ); if (!syncResult.ok) { const statusMap: Record = { NOT_FOUND: 404, VALIDATION: 400, }; const status = statusMap[syncResult.code] ?? 500; return errorResponse(syncResult.code, syncResult.error, status); } const collection = syncResult.value ?? syncResult.config.collections.find( (item) => item.name === name.toLowerCase() ); if (!collection) { return errorResponse("RUNTIME", "Collection not found after update", 500); } return jsonResponse({ success: true, collection: serializeCollection(syncResult.config, collection), }); } /** * POST /api/collections/:name/embeddings/clear * Clear stale or all embeddings for a collection. */ export async function handleClearCollectionEmbeddings( ctxHolder: ContextHolder, store: SqliteAdapter, name: string, req: Request ): Promise { let body: ClearCollectionEmbeddingsRequestBody; try { body = (await req.json()) as ClearCollectionEmbeddingsRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } const mode = body.mode ?? "stale"; if (mode !== "stale" && mode !== "all") { return errorResponse("VALIDATION", "mode must be 'stale' or 'all'"); } const collection = ctxHolder.config.collections.find( (item) => item.name === name.toLowerCase() ); if (!collection) { return errorResponse("NOT_FOUND", `Collection not found: ${name}`, 404); } const activeModel = resolveModelUri( ctxHolder.config, "embed", undefined, collection.name ); const result = await store.clearEmbeddingsForCollection(collection.name, { mode, activeModel, }); if (!result.ok) { const status = result.error.code === "INVALID_INPUT" ? 400 : 500; return errorResponse(result.error.code, result.error.message, status); } recordIndexMutation(result.value.deletedVectors, ctxHolder.markIndexMutation); return jsonResponse({ success: true, stats: result.value, note: mode === "all" ? `Run gno embed --collection ${collection.name} to rebuild active embeddings.` : result.value.protectedSharedVectors > 0 ? "Some shared vectors were retained because other active collections still use the same content." : undefined, }); } /** * POST /api/sync * Trigger re-index of all or specific collection. */ export async function handleSync( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request ): Promise { let body: SyncRequestBody = {}; try { const text = await req.text(); if (text) { body = JSON.parse(text) as SyncRequestBody; } } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } // Validate optional fields if (body.collection !== undefined && typeof body.collection !== "string") { return errorResponse("VALIDATION", "collection must be a string"); } if (body.gitPull !== undefined && typeof body.gitPull !== "boolean") { return errorResponse("VALIDATION", "gitPull must be a boolean"); } // Get collections to sync (case-insensitive matching) const collectionName = body.collection?.toLowerCase(); const collections = collectionName ? ctxHolder.config.collections.filter( (c) => c.name.toLowerCase() === collectionName ) : ctxHolder.config.collections; if (body.collection && collections.length === 0) { return errorResponse( "NOT_FOUND", `Collection not found: ${body.collection}`, 404 ); } if (collections.length === 0) { return errorResponse("VALIDATION", "No collections to sync"); } // Start background sync job const jobResult = await startJob( "sync", async (): Promise => { const result = await defaultSyncService.syncAll( collections, store, withContentTypeRules( { gitPull: body.gitPull, runUpdateCmd: true, }, ctxHolder.config ) ); recordContentMutation(result, ctxHolder.markContentMutation); if (hasContentMutation(result)) { if (ctxHolder.scheduler) { await ctxHolder.scheduler.triggerNow(); } } return result; }, ctxHolder.jobManager ); if (!jobResult.ok) { return jobConflictResponse(jobResult); } return jsonResponse({ jobId: jobResult.jobId }, 202); } /** * GET /api/docs * Query params: collection, limit (default 20), offset (default 0), tagsAll, tagsAny * Returns paginated document list. */ export async function handleDocs( store: SqliteAdapter, url: URL ): Promise { const collection = url.searchParams.get("collection") || undefined; const pathPrefix = normalizeBrowsePath(url.searchParams.get("pathPrefix")); const directChildrenOnlyParam = ( url.searchParams.get("directChildrenOnly") ?? "" ) .trim() .toLowerCase(); const sortFieldRaw = (url.searchParams.get("sortField") ?? "modified") .trim() .toLowerCase(); const sortOrderRaw = (url.searchParams.get("sortOrder") ?? "desc") .trim() .toLowerCase(); // Validate limit: positive integer, max 100 const limitParam = Number(url.searchParams.get("limit")); if ( url.searchParams.has("limit") && (Number.isNaN(limitParam) || limitParam < 1) ) { return errorResponse("VALIDATION", "limit must be a positive integer"); } const limit = Math.min(limitParam || 20, 100); // Validate offset: non-negative integer const offsetParam = Number(url.searchParams.get("offset")); if ( url.searchParams.has("offset") && (Number.isNaN(offsetParam) || offsetParam < 0) ) { return errorResponse("VALIDATION", "offset must be a non-negative integer"); } const offset = offsetParam || 0; if (sortFieldRaw !== "modified" && !/^[a-z0-9_]+$/.test(sortFieldRaw)) { return errorResponse( "VALIDATION", "sortField must be 'modified' or a lowercase frontmatter date key" ); } if (sortOrderRaw !== "asc" && sortOrderRaw !== "desc") { return errorResponse("VALIDATION", "sortOrder must be 'asc' or 'desc'"); } const sortOrder: "asc" | "desc" = sortOrderRaw === "asc" ? "asc" : "desc"; const directChildrenOnly = directChildrenOnlyParam === "1" || directChildrenOnlyParam === "true"; if (pathPrefix && !collection) { return errorResponse( "VALIDATION", "pathPrefix requires a collection filter" ); } // Parse tag filters let tagsAll: string[] | undefined; let tagsAny: string[] | undefined; const tagsAllParam = url.searchParams.get("tagsAll"); if (tagsAllParam) { try { tagsAll = parseAndValidateTagFilter(tagsAllParam); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAll" ); } } const tagsAnyParam = url.searchParams.get("tagsAny"); if (tagsAnyParam) { try { tagsAny = parseAndValidateTagFilter(tagsAnyParam); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAny" ); } } const dateFieldsResult = await store.getCollectionDateFields(collection); if (!dateFieldsResult.ok) { return errorResponse("RUNTIME", dateFieldsResult.error.message, 500); } const availableDateFields = dateFieldsResult.value; if ( sortFieldRaw !== "modified" && !availableDateFields.includes(sortFieldRaw) ) { return errorResponse( "VALIDATION", `Unknown sortField: ${sortFieldRaw} for current collection` ); } const result = await store.listDocumentsPaginated({ collection, limit, offset, pathPrefix: pathPrefix || undefined, directChildrenOnly, tagsAll, tagsAny, sortField: sortFieldRaw, sortOrder, }); if (!result.ok) { return errorResponse("RUNTIME", result.error.message, 500); } const { documents, total } = result.value; return jsonResponse({ documents: documents.map((doc) => ({ docid: doc.docid, uri: doc.uri, title: doc.title, collection: doc.collection, relPath: doc.recordSourcePath ?? doc.relPath, sourceExt: doc.sourceExt, sourceMime: doc.sourceMime, updatedAt: doc.updatedAt, })), total, limit, offset, pathPrefix, directChildrenOnly, availableDateFields, sortField: sortFieldRaw, sortOrder, }); } export async function handleBrowseTree( store: SqliteAdapter ): Promise { const [collectionsResult, documentsResult] = await Promise.all([ store.getCollections(), store.listActiveDocumentsForBrowse(), ]); if (!collectionsResult.ok) { return errorResponse("RUNTIME", collectionsResult.error.message, 500); } if (!documentsResult.ok) { return errorResponse("RUNTIME", documentsResult.error.message, 500); } const folders = await listCollectionFolders(collectionsResult.value); const collections = buildBrowseTree( collectionsResult.value, documentsResult.value, folders ); return jsonResponse({ collections, totalCollections: collections.length, totalDocuments: documentsResult.value.length, }); } export async function handleNotePresets(): Promise { return jsonResponse({ presets: NOTE_PRESETS.map((preset) => ({ id: preset.id, label: preset.label, description: preset.description, defaultTags: preset.defaultTags ?? [], frontmatter: preset.frontmatter ?? {}, preview: resolveNotePreset({ presetId: preset.id, title: "Untitled", })?.content, })), }); } export async function handleDocSections( store: SqliteAdapter, docId: string, req?: Request ): Promise { const docResult = await resolveDocumentReference( store, docId, req ? readRequestedUriFromUrl(req) : undefined ); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; if (!doc.mirrorHash) { return jsonResponse({ sections: [] }); } const contentResult = await store.getContent(doc.mirrorHash); if (!contentResult.ok || contentResult.value === null) { return errorResponse("RUNTIME", "Mirror content unavailable", 409); } return jsonResponse({ sections: extractSections(contentResult.value), }); } /** * GET /api/docs/autocomplete * Query params: query, collection, limit */ export async function handleDocsAutocomplete( store: SqliteAdapter, url: URL ): Promise { const query = (url.searchParams.get("query") ?? "").trim().toLowerCase(); const collection = url.searchParams.get("collection") || undefined; const limit = Math.min(Number(url.searchParams.get("limit") ?? "8") || 8, 20); const result = await store.listDocuments(collection); if (!result.ok) { return errorResponse("RUNTIME", result.error.message, 500); } const candidates = result.value .filter((doc) => doc.active) .map((doc) => ({ docid: doc.docid, uri: doc.uri, title: doc.title ?? doc.relPath .split("/") .pop() ?.replace(/\.[^.]+$/, "") ?? doc.relPath, collection: doc.collection, })) .filter((doc) => { if (!query) return true; const haystack = `${doc.title} ${doc.uri}`.toLowerCase(); return haystack.includes(query); }) .sort((a, b) => a.title.localeCompare(b.title)) .slice(0, limit); return jsonResponse({ docs: candidates }); } /** * GET /api/doc * Query params: uri (required) * Returns single document with content. */ export async function handleDoc( store: SqliteAdapter, config: Config, url: URL, request?: Request ): Promise { const uri = url.searchParams.get("uri"); if (!uri) { return errorResponse("VALIDATION", "Missing uri parameter"); } const docResult = await store.getDocumentByUri(uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; let content: string | null = null; if (doc.mirrorHash) { const contentResult = await store.getContent(doc.mirrorHash); if (contentResult.ok && contentResult.value) { content = contentResult.value; } } // Get tags for this document let tags: string[] = []; const tagsResult = await store.getTagsForDoc(doc.id); if (tagsResult.ok) { tags = tagsResult.value.map((t) => t.tag); } const contentAvailable = content !== null; const capabilities = capabilitiesForDocument(doc, contentAvailable); const source = await buildSourceMeta(config.collections, doc); const record = projectRecordEvidenceMetadata(doc); const relPath = doc.recordSourcePath ?? doc.relPath; const responseData = { docid: doc.docid, uri: doc.uri, title: doc.title, content, contentAvailable, typedMetadata: doc.typedMetadata ?? null, metadataError: doc.metadataError ?? null, collection: doc.collection, relPath, tags, source, record, capabilities, }; const continuationTraceId = request ? requestRetrievalTraceId(request) : undefined; if (!continuationTraceId) return jsonResponse(responseData); const resumed = await RetrievalTraceSession.resume({ store, config: config.retrievalTraces, traceId: continuationTraceId, }); if (!resumed.ok) { return errorResponse("RUNTIME", resumed.error.message, 500); } const traceSession = resumed.value; if (!traceSession) return jsonResponse(responseData); if (content !== null) { const endLine = content.split("\n").length; const evidence = evidenceFromExactDocument({ docid: doc.docid, uri: doc.uri, sourceHash: doc.sourceHash, mirrorHash: doc.mirrorHash ?? undefined, content, startLine: 1, endLine, }); if (evidence) { const got = await traceSession.recordEvidence("get", [evidence]); if (!got.ok) { return withRetrievalTraceHeader( errorResponse("RUNTIME", got.error.message, 500), traceSession ); } const opened = await traceSession.recordEvidence("open", [evidence]); if (!opened.ok) { return withRetrievalTraceHeader( errorResponse("RUNTIME", opened.error.message, 500), traceSession ); } } } return withRetrievalTraceHeader(jsonResponse(responseData), traceSession); } /** * Parse a single-range `Range: bytes=…` header per RFC 9110. * Multi-range requests are rejected here; the caller returns 416 with * Content-Range `bytes * / ` (RFC unsatisfiable form; see serve path). */ function parseSingleByteRange( rangeHeader: string, size: number ): | { ok: true; start: number; end: number } | { ok: false; reason: "malformed" | "unsatisfiable" } { const trimmed = rangeHeader.trim(); // Multi-range: not supported — signal so caller can return 416 if (trimmed.includes(",")) { return { ok: false, reason: "malformed" }; } const match = /^bytes=(\d*)-(\d*)$/u.exec(trimmed); if (!match || (match[1] === "" && match[2] === "")) { return { ok: false, reason: "malformed" }; } const startTok = match[1] ?? ""; const endTok = match[2] ?? ""; let start: number; let end: number; if (startTok === "") { // suffix: bytes=-N const suffix = Number.parseInt(endTok, 10); if (!Number.isFinite(suffix) || suffix <= 0) { return { ok: false, reason: "malformed" }; } if (size === 0) { return { ok: false, reason: "unsatisfiable" }; } start = Math.max(0, size - suffix); end = size - 1; } else { start = Number.parseInt(startTok, 10); if (!Number.isFinite(start) || start < 0) { return { ok: false, reason: "malformed" }; } if (endTok === "") { end = size - 1; } else { end = Number.parseInt(endTok, 10); if (!Number.isFinite(end) || end < 0) { return { ok: false, reason: "malformed" }; } } } if (size === 0 || start >= size || end < start) { return { ok: false, reason: "unsatisfiable" }; } end = Math.min(end, size - 1); return { ok: true, start, end }; } /** * Revalidate-on-every-open: the browser asks once per open and reuses the * cached bytes on 304. Deliberately replaces fn-112's `no-store` (fn-136 R3). */ export const DOC_ASSET_CACHE_CONTROL = "private, max-age=0, must-revalidate"; const WEAK_ETAG_PREFIX = "W/"; /** Strong, quoted validator from file size and mtime (fn-136 R3). */ export function docAssetEtag(file: { size: number; lastModified: number; }): string { return `"${file.size.toString(16)}-${file.lastModified.toString(16)}"`; } /** * RFC 9110 §13.1.2 If-None-Match: `*` or any listed validator matching under * weak comparison (a `W/` prefix is ignored on the client's side). * * The list is split on a bare comma. Our validators (`"-"`) * never contain one; a foreign tag that does would only produce a false * negative (full body instead of 304), never a false match. */ export function etagMatches( ifNoneMatch: string | null | undefined, etag: string ): boolean { if (!ifNoneMatch) { return false; } const trimmed = ifNoneMatch.trim(); if (trimmed === "*") { return true; } for (const candidate of trimmed.split(",")) { const value = candidate.trim(); const strong = value.startsWith(WEAK_ETAG_PREFIX) ? value.slice(WEAK_ETAG_PREFIX.length) : value; if (strong === etag) { return true; } } return false; } /** * GET|HEAD /api/doc-asset * Query params: * - path (required): relative to current doc, or absolute filesystem path * - uri (required for relative paths): current document uri * * Supports single-range Range requests (206/416). Multi-range → 416 (I1-03). * HEAD mirrors GET status/headers with empty body (I1-02). * Strong ETag from size + mtime; a matching If-None-Match answers 304 before * any Range handling, so full and ranged GETs revalidate alike (fn-136 R3). */ export async function handleDocAsset( store: SqliteAdapter, config: Config, url: URL, request?: Request ): Promise { const assetPath = url.searchParams.get("path")?.trim(); if (!assetPath) { return errorResponse("VALIDATION", "Missing path parameter"); } let resolvedPath: string | null = null; if (isAbsoluteFilesystemPath(assetPath)) { for (const collection of config.collections) { if (await isPathWithinRoot(collection.path, assetPath)) { resolvedPath = assetPath; break; } } if (!resolvedPath) { return errorResponse( "FORBIDDEN", "Absolute asset path is outside configured collections", 403 ); } } else { const uri = url.searchParams.get("uri"); if (!uri) { return errorResponse( "VALIDATION", "uri is required for relative asset paths" ); } const docResult = await store.getDocumentByUri(uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const resolvedDoc = await resolveAbsoluteDocPath( config.collections, docResult.value ); if (!resolvedDoc) { return errorResponse( "NOT_FOUND", "Document path could not be resolved", 404 ); } const nodePath = await import("node:path"); // no bun equivalent const candidate = nodePath.resolve( nodePath.dirname(resolvedDoc.fullPath), assetPath ); if (!(await isPathWithinRoot(resolvedDoc.collection.path, candidate))) { return errorResponse( "FORBIDDEN", "Asset path escapes collection root", 403 ); } resolvedPath = candidate; } const file = Bun.file(resolvedPath); if (!(await file.exists())) { return errorResponse("NOT_FOUND", "Asset not found", 404); } const isHead = (request?.method ?? "GET").toUpperCase() === "HEAD"; const filename = resolvedPath.split(/[\\/]/u).at(-1) ?? "document"; const etag = docAssetEtag(file); const headers = new Headers({ "Accept-Ranges": "bytes", "Cache-Control": DOC_ASSET_CACHE_CONTROL, "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(filename)}`, "Content-Type": file.type || "application/octet-stream", ETag: etag, }); if (etagMatches(request?.headers.get("If-None-Match"), etag)) { // 304 carries the revalidation headers of the 200 set (RFC 9110 §15.4.5) // and nothing that describes a body. const notModified = new Headers(headers); notModified.delete("Content-Disposition"); notModified.delete("Content-Type"); return new Response(null, { status: 304, headers: notModified }); } const rangeHeader = request?.headers.get("Range"); if (!rangeHeader) { headers.set("Content-Length", String(file.size)); if (isHead) { return new Response(null, { status: 200, headers }); } return new Response(file, { headers }); } // Multi-range: unsupported → 416 with bytes */size (I1-03) if (rangeHeader.includes(",")) { headers.set("Content-Range", `bytes */${file.size}`); // No Content-Length for empty 416 body headers.delete("Content-Length"); return new Response(null, { status: 416, headers }); } const parsed = parseSingleByteRange(rangeHeader, file.size); if (!parsed.ok) { headers.set("Content-Range", `bytes */${file.size}`); headers.delete("Content-Length"); return new Response(null, { status: 416, headers }); } const { start, end } = parsed; const length = end - start + 1; headers.set("Content-Length", String(length)); headers.set("Content-Range", `bytes ${start}-${end}/${file.size}`); if (isHead) { // Empty body; do not slice/stream the file for HEAD return new Response(null, { status: 206, headers }); } return new Response(file.slice(start, end + 1), { status: 206, headers }); } /** * GET /api/tags * Query params: collection, prefix * Returns tag list with document counts. */ export async function handleTags( store: SqliteAdapter, url: URL ): Promise { const collectionRaw = url.searchParams.get("collection") || undefined; const prefixRaw = url.searchParams.get("prefix") || undefined; // Normalize collection to lowercase if provided const collection = collectionRaw?.toLowerCase(); // Validate and normalize prefix using tag grammar let prefix: string | undefined; if (prefixRaw) { const normalized = normalizeTag(prefixRaw); // Strip trailing slash for prefix queries (allows "project/" to find "project/*") const prefixToValidate = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; // Only validate if non-empty (empty prefix = list all) if (prefixToValidate.length > 0 && !validateTag(prefixToValidate)) { return errorResponse( "VALIDATION", `Invalid prefix: "${prefixRaw}". Must follow tag format.` ); } // Use stripped version for query (store expects prefix without trailing slash) prefix = prefixToValidate || undefined; } const result = await store.getTagCounts({ collection, prefix }); if (!result.ok) { return errorResponse("RUNTIME", result.error.message, 500); } const tags = result.value; return jsonResponse({ tags, meta: { totalTags: tags.length, ...(collection && { collection }), ...(prefix && { prefix }), }, }); } /** * POST /api/docs/:id/deactivate * Deactivate a document (soft delete - does not remove file from disk). */ export async function handleDeactivateDoc( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req?: Request ): Promise { // Get document to verify it exists and get collection/relPath const docResult = await resolveDocumentReference( store, docId, req ? readRequestedUriFromUrl(req) : undefined ); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; // Mark as inactive const result = await store.markInactive(doc.collection, [doc.relPath]); if (!result.ok) { return errorResponse("RUNTIME", result.error.message, 500); } if (result.value > 0) { ctxHolder.markContentMutation?.(); ctxHolder.markIndexMutation?.(); } return jsonResponse({ success: true, docId: doc.docid, path: doc.uri, warning: "File still exists on disk. Will be re-indexed unless excluded.", }); } export async function handleRenameDoc( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req: Request, deps?: { syncCollection?: typeof defaultSyncService.syncCollection; } ): Promise { let body: RenameDocRequestBody; try { body = (await req.json()) as RenameDocRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.name || typeof body.name !== "string") { return errorResponse("VALIDATION", "Missing or invalid name"); } if (body.name.includes("/") || body.name.includes("\\")) { return errorResponse("VALIDATION", "name must be a file name, not a path"); } if (body.uri !== undefined && typeof body.uri !== "string") { return errorResponse("VALIDATION", "uri must be a string"); } const confirmation = parseRefactorApplyConfirmation(body); if ("code" in confirmation) { return fileRefactorHttpErrorResponse(confirmation); } const docResult = await resolveDocumentReference(store, docId, body.uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const capabilities = capabilitiesForDocument(doc); if (!capabilities.editable) { return errorResponse( "READ_ONLY", capabilities.reason ?? "This document cannot be renamed in place from GNO.", 409 ); } const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } const { collection, fullPath } = resolvedDocPath; const file = Bun.file(fullPath); if (!(await file.exists())) { return errorResponse("FILE_NOT_FOUND", "Source file no longer exists", 404); } let target; try { target = resolveRenameTarget({ collection: collection.name, currentRelPath: doc.relPath, nextName: body.name, }); } catch (error) { return errorResponse( "VALIDATION", error instanceof Error ? error.message : "Invalid rename target" ); } if (target.nextRelPath === doc.relPath) { return errorResponse("VALIDATION", "New name matches current file"); } const nodePath = await import("node:path"); // no bun equivalent const nextFullPath = nodePath.join(collection.path, target.nextRelPath); try { const plan = await buildCanonicalRefactorPlan({ operation: "rename", doc, collection, sourceFullPath: fullPath, target, store, sourceEditable: capabilities.editable, }); const syncCollection = deps?.syncCollection ?? ((collectionArg, storeArg, optionsArg) => defaultSyncService.syncCollection(collectionArg, storeArg, optionsArg)); ctxHolder.watchService?.suppress(fullPath); ctxHolder.watchService?.suppress(nextFullPath); const applyResult = await applyCanonicalFileRefactor({ plan, confirmation, deps: buildFileRefactorApplyDeps({ collection, store, syncAfterCommit: async () => { const syncResult = await syncResidentCollection( ctxHolder, collection, store, withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config), syncCollection ); assertFileRefactorSyncConverged(syncResult); }, }), signal: req.signal, }); const mapped = mapApplyResultToHttpSuccess({ plan, result: applyResult, targetFullPath: nextFullPath, operationLabel: "renamed", }); if ("code" in mapped) { return fileRefactorHttpErrorResponse(mapped); } ctxHolder.eventBus?.emit({ type: "document-changed", uri: mapped.uri, collection: collection.name, relPath: mapped.relPath, origin: "save", changedAt: new Date().toISOString(), }); return jsonResponse(mapped); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to rename document", 500 ); } } export async function handleRefactorPlan( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req: Request ): Promise { let body: RefactorPlanRequestBody; try { body = (await req.json()) as RefactorPlanRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } const docResult = await resolveDocumentReference(store, docId, body.uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const capabilities = capabilitiesForDocument(doc); if (!capabilities.editable) { return errorResponse( "READ_ONLY", capabilities.reason ?? "This document cannot be refactored in place from GNO.", 409 ); } const collection = getCollectionByName( ctxHolder.config.collections, doc.collection ); if (!collection) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } try { if (body.operation === "rename") { if (!body.name?.trim()) { return errorResponse("VALIDATION", "Missing or invalid name"); } const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } const target = resolveRenameTarget({ collection: collection.name, currentRelPath: doc.relPath, nextName: body.name.trim(), }); const plan = await buildCanonicalRefactorPlan({ operation: "rename", doc, collection, sourceFullPath: resolvedDocPath.fullPath, target, store, sourceEditable: capabilities.editable, }); return jsonResponse(toRefactorPlanResponse(plan)); } if (body.operation === "move") { if (!body.folderPath?.trim()) { return errorResponse("VALIDATION", "Missing or invalid folderPath"); } const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } const target = resolveMoveTarget({ collection: collection.name, currentRelPath: doc.relPath, folderPath: body.folderPath.trim(), nextName: body.name?.trim(), }); const plan = await buildCanonicalRefactorPlan({ operation: "move", doc, collection, sourceFullPath: resolvedDocPath.fullPath, target, store, sourceEditable: capabilities.editable, }); return jsonResponse(toRefactorPlanResponse(plan)); } if (body.operation === "duplicate") { const snapshot = await getRefactorSnapshot(store, doc.id); const plan = planDuplicateRefactor({ collection: collection.name, currentRelPath: doc.relPath, folderPath: body.folderPath?.trim(), nextName: body.name?.trim(), existingRelPaths: await listCollectionRelPaths(store, collection.name), }); return jsonResponse({ operation: body.operation, ...plan, refactorWarnings: buildRefactorWarnings(snapshot), }); } return errorResponse("VALIDATION", "Unsupported operation"); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to build refactor plan", 500 ); } } export async function handleTrashDoc( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req?: Request, deps?: { trashFilePath?: typeof trashFilePath; syncCollection?: typeof defaultSyncService.syncCollection; } ): Promise { const docResult = await resolveDocumentReference( store, docId, req ? readRequestedUriFromUrl(req) : undefined ); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } const { collection, fullPath } = resolvedDocPath; const capabilities = capabilitiesForDocument(doc); if (!capabilities.editable) { return errorResponse( "READ_ONLY", capabilities.reason ?? "This document cannot be trashed in place from GNO.", 409 ); } try { const syncCollection = deps?.syncCollection ?? ((collectionArg, storeArg, optionsArg) => defaultSyncService.syncCollection(collectionArg, storeArg, optionsArg)); ctxHolder.watchService?.suppress(fullPath); await (deps?.trashFilePath ?? trashFilePath)(fullPath); const markInactiveResult = await store.markInactive(doc.collection, [ doc.relPath, ]); if (!markInactiveResult.ok) { return errorResponse( "RUNTIME", `File moved to Trash, but removing it from the current index failed: ${markInactiveResult.error.message}`, 500 ); } let warning: string | undefined; try { await syncResidentCollection( ctxHolder, collection, store, withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config), syncCollection ); } catch { warning = "File moved to Trash, but index refresh failed. Run Update All to reconcile the workspace."; } ctxHolder.eventBus?.emit({ type: "document-changed", uri: doc.uri, collection: doc.collection, relPath: doc.relPath, origin: "save", changedAt: new Date().toISOString(), }); return jsonResponse({ success: true, docId: doc.docid, path: fullPath, note: "Moved to Trash and removed from the current index.", warning, }); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to trash document", 500 ); } } export async function handleMoveDoc( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req: Request, deps?: { syncCollection?: typeof defaultSyncService.syncCollection; } ): Promise { let body: MoveDocRequestBody; try { body = (await req.json()) as MoveDocRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.folderPath || typeof body.folderPath !== "string") { return errorResponse("VALIDATION", "Missing or invalid folderPath"); } if (body.name !== undefined && typeof body.name !== "string") { return errorResponse("VALIDATION", "name must be a string"); } if (body.uri !== undefined && typeof body.uri !== "string") { return errorResponse("VALIDATION", "uri must be a string"); } const confirmation = parseRefactorApplyConfirmation(body); if ("code" in confirmation) { return fileRefactorHttpErrorResponse(confirmation); } const docResult = await resolveDocumentReference(store, docId, body.uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const capabilities = capabilitiesForDocument(doc); if (!capabilities.editable) { return errorResponse( "READ_ONLY", capabilities.reason ?? "This document cannot be moved in place from GNO.", 409 ); } const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } const { collection, fullPath } = resolvedDocPath; let target; try { target = resolveMoveTarget({ collection: collection.name, currentRelPath: doc.relPath, folderPath: body.folderPath, nextName: body.name?.trim(), }); } catch (error) { return errorResponse( "VALIDATION", error instanceof Error ? error.message : "Invalid move target" ); } const nodePath = await import("node:path"); // no bun equivalent const nextFullPath = nodePath.join(collection.path, target.nextRelPath); try { const plan = await buildCanonicalRefactorPlan({ operation: "move", doc, collection, sourceFullPath: fullPath, target, store, sourceEditable: capabilities.editable, }); const syncCollection = deps?.syncCollection ?? ((collectionArg, storeArg, optionsArg) => defaultSyncService.syncCollection(collectionArg, storeArg, optionsArg)); ctxHolder.watchService?.suppress(fullPath); ctxHolder.watchService?.suppress(nextFullPath); const applyResult = await applyCanonicalFileRefactor({ plan, confirmation, deps: buildFileRefactorApplyDeps({ collection, store, syncAfterCommit: async () => { const syncResult = await syncResidentCollection( ctxHolder, collection, store, withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config), syncCollection ); assertFileRefactorSyncConverged(syncResult); }, }), signal: req.signal, }); const mapped = mapApplyResultToHttpSuccess({ plan, result: applyResult, targetFullPath: nextFullPath, operationLabel: "moved", }); if ("code" in mapped) { return fileRefactorHttpErrorResponse(mapped); } ctxHolder.eventBus?.emit({ type: "document-changed", uri: mapped.uri, collection: collection.name, relPath: mapped.relPath, origin: "save", changedAt: new Date().toISOString(), }); return jsonResponse(mapped); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to move document", 500 ); } } export async function handleDuplicateDoc( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req: Request ): Promise { let body: DuplicateDocRequestBody; try { body = (await req.json()) as DuplicateDocRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (body.name !== undefined && typeof body.name !== "string") { return errorResponse("VALIDATION", "name must be a string"); } if (body.folderPath !== undefined && typeof body.folderPath !== "string") { return errorResponse("VALIDATION", "folderPath must be a string"); } if (body.uri !== undefined && typeof body.uri !== "string") { return errorResponse("VALIDATION", "uri must be a string"); } const docResult = await resolveDocumentReference(store, docId, body.uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const capabilities = capabilitiesForDocument(doc); if (!capabilities.editable) { return errorResponse( "READ_ONLY", capabilities.reason ?? "This document cannot be duplicated in place from GNO.", 409 ); } const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } const { collection, fullPath } = resolvedDocPath; let plan; try { plan = planDuplicateRefactor({ collection: collection.name, currentRelPath: doc.relPath, folderPath: body.folderPath?.trim(), nextName: body.name?.trim(), existingRelPaths: await listCollectionRelPaths(store, collection.name), }); } catch (error) { return errorResponse( "VALIDATION", error instanceof Error ? error.message : "Invalid duplicate target" ); } const nodePath = await import("node:path"); // no bun equivalent const nextFullPath = nodePath.join(collection.path, plan.nextRelPath); try { const { mkdir } = await import("node:fs/promises"); // structure ops need fs await mkdir(nodePath.dirname(nextFullPath), { recursive: true }); await copyFilePath(fullPath, nextFullPath); let warning: string | undefined; try { await syncResidentCollection( ctxHolder, collection, store, withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config) ); } catch { warning = "File duplicated on disk, but index refresh failed. Run Update All to reconcile the workspace."; } ctxHolder.eventBus?.emit({ type: "document-changed", uri: plan.nextUri, collection: collection.name, relPath: plan.nextRelPath, origin: "create", changedAt: new Date().toISOString(), }); return jsonResponse({ success: true, uri: plan.nextUri, path: nextFullPath, relPath: plan.nextRelPath, refactorWarnings: buildRefactorWarnings( await getRefactorSnapshot(store, doc.id) ), warning, }); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to duplicate document", 500 ); } } export async function handleCreateFolder( ctxHolder: ContextHolder, req: Request ): Promise { let body: CreateFolderRequestBody; try { body = (await req.json()) as CreateFolderRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.collection || typeof body.collection !== "string") { return errorResponse("VALIDATION", "Missing or invalid collection"); } if (!body.name || typeof body.name !== "string") { return errorResponse("VALIDATION", "Missing or invalid folder name"); } if (body.parentPath !== undefined && typeof body.parentPath !== "string") { return errorResponse("VALIDATION", "parentPath must be a string"); } const collection = getCollectionByName( ctxHolder.config.collections, body.collection ); if (!collection) { return errorResponse( "NOT_FOUND", `Collection not found: ${body.collection}`, 404 ); } try { const folderPath = planCreateFolder({ parentPath: body.parentPath, name: body.name, }); const nodePath = await import("node:path"); // no bun equivalent const fullPath = nodePath.join(collection.path, folderPath); await createFolderPath(fullPath); return jsonResponse({ success: true, collection: collection.name, folderPath, path: fullPath, }); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to create folder", 500 ); } } /** * POST /api/docs/:id/reveal * Opens the source file in the host's file manager. Only a local client may * open windows on the server host: a request judged remote by the locality * rule is refused before the document is resolved. */ export async function handleRevealDoc( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req?: Request, deps?: { revealFilePath?: typeof revealFilePath; server?: RequestPeerServer; } ): Promise { // Fail closed: no request (no peer to judge) is treated as remote. if (!req || !isLocalClientRequest(req, deps?.server)) { return errorResponse( "FORBIDDEN", "Reveal is only available to a local client", 403 ); } const docResult = await resolveDocumentReference( store, docId, req ? readRequestedUriFromUrl(req) : undefined ); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } try { await (deps?.revealFilePath ?? revealFilePath)(resolvedDocPath.fullPath); return jsonResponse({ success: true, path: resolvedDocPath.fullPath, }); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to reveal document", 500 ); } } /** * PUT /api/docs/:id * Update an existing document's content and/or tags. */ export async function handleUpdateDoc( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req: Request, deps?: { syncCollection?: typeof defaultSyncService.syncCollection; } ): Promise { let body: UpdateDocRequestBody; try { body = (await req.json()) as UpdateDocRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } // At least one of content or tags must be provided const hasContent = body.content !== undefined; const hasTags = body.tags !== undefined; if (!hasContent && !hasTags) { return errorResponse("VALIDATION", "Must provide content or tags"); } // Validate content if provided (allow empty string) if (hasContent && typeof body.content !== "string") { return errorResponse("VALIDATION", "content must be a string"); } if ( body.expectedSourceHash !== undefined && typeof body.expectedSourceHash !== "string" ) { return errorResponse("VALIDATION", "expectedSourceHash must be a string"); } if ( body.expectedModifiedAt !== undefined && typeof body.expectedModifiedAt !== "string" ) { return errorResponse("VALIDATION", "expectedModifiedAt must be a string"); } if (body.uri !== undefined && typeof body.uri !== "string") { return errorResponse("VALIDATION", "uri must be a string"); } // Validate tags if provided let normalizedTags: string[] | undefined; if (hasTags) { if (!Array.isArray(body.tags)) { return errorResponse("VALIDATION", "tags must be an array"); } normalizedTags = []; for (const tag of body.tags) { if (typeof tag !== "string") { return errorResponse("VALIDATION", "Each tag must be a string"); } const normalized = normalizeTag(tag); if (!validateTag(normalized)) { return errorResponse( "VALIDATION", `Invalid tag: "${tag}". Tags must be lowercase, alphanumeric with hyphens/dots/slashes.` ); } normalizedTags.push(normalized); } } // Get document to verify it exists const docResult = await resolveDocumentReference(store, docId, body.uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const capabilities = capabilitiesForDocument(doc); if (hasContent && !capabilities.editable) { return errorResponse( "READ_ONLY", capabilities.reason ?? "This document cannot be edited in place. Create an editable markdown copy instead.", 409 ); } const resolvedDocPath = await resolveAbsoluteDocPath( ctxHolder.config.collections, doc ); if (!resolvedDocPath) { return errorResponse( "NOT_FOUND", `Collection not found: ${doc.collection}`, 404 ); } const { collection, fullPath } = resolvedDocPath; // Verify file exists const file = Bun.file(fullPath); if (!(await file.exists())) { return errorResponse("FILE_NOT_FOUND", "Source file no longer exists", 404); } if (body.expectedSourceHash || body.expectedModifiedAt) { const currentBytes = await file.bytes(); const currentSourceHash = hashContent( new TextDecoder().decode(currentBytes) ); const { stat } = await import("node:fs/promises"); // no Bun structure stat parity const currentModifiedAt = (await stat(fullPath)).mtime.toISOString(); if ( (body.expectedSourceHash && body.expectedSourceHash !== currentSourceHash) || (body.expectedModifiedAt && body.expectedModifiedAt !== currentModifiedAt) ) { return jsonResponse( { error: { code: "CONFLICT", message: "Document changed on disk. Reload before saving.", }, currentVersion: { sourceHash: currentSourceHash, modifiedAt: currentModifiedAt, }, }, 409 ); } } let writeBack: "applied" | "skipped_unsupported" | undefined; try { // Determine final content to write let contentToWrite: string | undefined; if (hasContent) { contentToWrite = body.content; } // Handle tag writeback for Markdown files if (hasTags && normalizedTags) { if (capabilities.tagsWriteback) { // Read current content if we're only updating tags const source = contentToWrite ?? (await file.text()); contentToWrite = updateFrontmatterTags(source, normalizedTags); writeBack = "applied"; } else { writeBack = "skipped_unsupported"; } // Update tags in DB (user source since this is a user action) const tagResult = await store.setDocTags(doc.id, normalizedTags, "user"); if (!tagResult.ok) { return errorResponse("RUNTIME", tagResult.error.message, 500); } } let currentSourceHash = doc.sourceHash; let currentModifiedAt = doc.sourceMtime; // Write file if we have content to write if (contentToWrite !== undefined) { ctxHolder.watchService?.suppress(fullPath); await atomicWrite(fullPath, contentToWrite); currentSourceHash = hashContent(contentToWrite); const { stat } = await import("node:fs/promises"); // no Bun structure stat parity currentModifiedAt = (await stat(fullPath)).mtime.toISOString(); } // Build proper file:// URI using node:url const { pathToFileURL } = await import("node:url"); const fileUri = pathToFileURL(fullPath).href; // Run sync via job system (non-blocking) only if content changed // Note: embedding handled separately by embed-scheduler (not inline) let jobId: string | null = null; if (contentToWrite !== undefined) { const jobResult = await startJob( "sync", async (): Promise => { const result = await syncResidentCollection( ctxHolder, collection, store, withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config), deps?.syncCollection ); // Notify scheduler after sync completes ctxHolder.scheduler?.notifySyncComplete([doc.docid]); ctxHolder.eventBus?.emit({ type: "document-changed", uri: doc.uri, collection: doc.collection, relPath: doc.relPath, origin: "save", changedAt: new Date().toISOString(), }); return { collections: [result], totalDurationMs: result.durationMs, totalFilesProcessed: result.filesProcessed, totalFilesAdded: result.filesAdded, totalFilesUpdated: result.filesUpdated, totalFilesErrored: result.filesErrored, totalFilesSkipped: result.filesSkipped, }; }, ctxHolder.jobManager ); jobId = jobResult.ok ? jobResult.jobId : null; } return jsonResponse({ success: true, docId: doc.docid, uri: fileUri, path: fullPath, jobId, writeBack, version: { sourceHash: currentSourceHash, modifiedAt: currentModifiedAt, }, }); } catch (e) { return errorResponse( "RUNTIME", `Failed to update document: ${e instanceof Error ? e.message : String(e)}`, 500 ); } } /** * POST /api/docs/:id/editable-copy * Create a markdown copy for a read-only/converted document. */ export async function handleCreateEditableCopy( ctxHolder: ContextHolder, store: SqliteAdapter, docId: string, req: Request ): Promise { let body: CreateEditableCopyRequestBody = {}; try { const text = await req.text(); if (text) { body = JSON.parse(text) as CreateEditableCopyRequestBody; } } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (body.collection !== undefined && typeof body.collection !== "string") { return errorResponse("VALIDATION", "collection must be a string"); } if (body.relPath !== undefined && typeof body.relPath !== "string") { return errorResponse("VALIDATION", "relPath must be a string"); } if (body.uri !== undefined && typeof body.uri !== "string") { return errorResponse("VALIDATION", "uri must be a string"); } const docResult = await resolveDocumentReference(store, docId, body.uri); if (!docResult.ok) { return errorResponse("RUNTIME", docResult.error.message, 500); } if (!docResult.value) { return errorResponse("NOT_FOUND", "Document not found", 404); } const doc = docResult.value; const contentAvailable = doc.mirrorHash !== null; const capabilities = capabilitiesForDocument(doc, contentAvailable); if (capabilities.editable) { return errorResponse( "VALIDATION", "Document is already editable in place; use the normal update route instead." ); } if (!doc.mirrorHash) { return errorResponse( "RUNTIME", "Editable copy unavailable because converted content is missing.", 409 ); } const contentResult = await store.getContent(doc.mirrorHash); if (!contentResult.ok || contentResult.value === null) { return errorResponse( "RUNTIME", "Editable copy unavailable because converted content is missing.", 409 ); } const tagsResult = await store.getTagsForDoc(doc.id); const tags = tagsResult.ok ? tagsResult.value.map((tag) => tag.tag) : []; const targetCollectionName = body.collection ?? doc.collection; const targetCollection = getCollectionByName( ctxHolder.config.collections, targetCollectionName ); if (!targetCollection) { return errorResponse( "NOT_FOUND", `Collection not found: ${targetCollectionName}`, 404 ); } let relPath = body.relPath; if (!relPath) { const listResult = await store.listDocuments(targetCollection.name); const existingRelPaths = listResult.ok ? listResult.value.map((entry) => entry.relPath) : []; relPath = deriveEditableCopyRelPath( doc.recordSourcePath ?? doc.relPath, existingRelPaths ); } const title = doc.title ?? (doc.recordSourcePath ?? doc.relPath) .split("/") .pop() ?.replace(/\.[^.]+$/, "") ?? "Copy"; const content = buildEditableCopyContent({ title, sourceDocid: doc.docid, sourceUri: doc.uri, sourceMime: doc.sourceMime, sourceExt: doc.sourceExt, content: contentResult.value, tags, }); const createReq = new Request("http://localhost/api/docs", { method: "POST", body: JSON.stringify({ collection: targetCollection.name, relPath, content, tags, } satisfies CreateDocRequestBody), }); return handleCreateDoc(ctxHolder, store, createReq); } /** * POST /api/capture * Capture a note with structured provenance. */ export async function handleCreateCapture( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request, deps: Omit = {} ): Promise { let body: CreateCaptureRequestBody; try { body = (await req.json()) as CreateCaptureRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.collection || typeof body.collection !== "string") { return errorResponse("VALIDATION", "Missing or invalid collection"); } if (body.content !== undefined && typeof body.content !== "string") { return errorResponse("VALIDATION", "content must be a string"); } if (body.title !== undefined && typeof body.title !== "string") { return errorResponse("VALIDATION", "title must be a string"); } if (body.relPath !== undefined && typeof body.relPath !== "string") { return errorResponse("VALIDATION", "relPath must be a string"); } if (body.folderPath !== undefined && typeof body.folderPath !== "string") { return errorResponse("VALIDATION", "folderPath must be a string"); } if ("overwrite" in body) { return errorResponse( "VALIDATION", "overwrite is not supported by /api/capture; use collisionPolicy instead" ); } const planned = await planResidentCapture(ctxHolder, store, { ...body, collection: body.collection, }); if (!planned.ok) { return errorResponse(planned.code, planned.message, planned.status); } // Write + lexical sync complete under the shared write lease before the // response: 201 only once the capture is retrievable (fn-132 R1). try { const result = await executeResidentCapturePlan(ctxHolder, store, planned, { ...deps, mode: "await-sync", }); return jsonResponse(result.body, result.status); } catch (error) { const shape = classifyResidentCaptureError(error, planned); return errorResponse( shape.code, shape.message, shape.status, shape.details ); } } // ───────────────────────────────────────────────────────────────────────────── // Memory (remember / recall) // ───────────────────────────────────────────────────────────────────────────── const HTTP_CREATED = 201; const HTTP_NOT_FOUND = 404; const HTTP_CONFLICT = 409; const HTTP_INTERNAL = 500; /** HTTP status per stable memory error code; the code itself is the wire code. */ const MEMORY_ERROR_STATUS: Readonly> = { MEMORY_TEXT_REQUIRED: 400, MEMORY_TEXT_TOO_LARGE: 400, MEMORY_QUERY_REQUIRED: 400, MEMORY_BUDGET_INVALID: 400, MEMORY_COLLECTION_REQUIRED: 400, MEMORY_COLLECTION_NOT_FOUND: HTTP_NOT_FOUND, MEMORY_COLLECTION_UNMANAGED: 400, MEMORY_SCOPES_REQUIRED: 400, MEMORY_SCOPES_INVALID: 400, MEMORY_IDENTITY_REQUIRED: 400, MEMORY_DECISION_INVALID: 400, MEMORY_PREDECESSOR_REQUIRED: 400, MEMORY_PREDECESSOR_NOT_FOUND: HTTP_NOT_FOUND, MEMORY_PREDECESSOR_HASH_MISMATCH: HTTP_CONFLICT, MEMORY_SUPERSEDE_CONFLICT: HTTP_CONFLICT, MEMORY_FENCED_REPLAY: 400, MEMORY_FENCED_DERIVED: 400, MEMORY_WRITE_LEASE_BUSY: HTTP_CONFLICT, MEMORY_SYNC_FAILED: HTTP_INTERNAL, MEMORY_SUPERSEDE_PROJECTION_FAILED: HTTP_INTERNAL, MEMORY_QUERY_FAILED: HTTP_INTERNAL, }; export interface MemoryRouteDeps { /** Shared `.mcp-write.lock` path; defaults to the resident index's lease. */ lockPath?: string; lockWaitMs?: number; } function memoryErrorResponse(error: unknown, fallback: string): Response { if (error instanceof MemoryError) { return errorResponse( error.code, error.message, MEMORY_ERROR_STATUS[error.code] ); } return errorResponse( "RUNTIME", `${fallback}: ${error instanceof Error ? error.message : String(error)}`, HTTP_INTERNAL ); } async function readMemoryBody( req: Request ): Promise< | { ok: true; body: Record } | { ok: false; response: Response } > { let body: unknown; try { body = await req.json(); } catch { return { ok: false, response: errorResponse("VALIDATION", "Invalid JSON body"), }; } if (typeof body !== "object" || body === null || Array.isArray(body)) { return { ok: false, response: errorResponse( "VALIDATION", "Request body must be a JSON object" ), }; } return { ok: true, body: body as Record }; } /** * The service owns the shared write lease; this adapter never takes it. * Semantic matching/retrieval is enabled only when the resident context has * an embedding port (and a vector index for recall); otherwise the service * reports lexical-only mode in the result. */ function createMemoryService( ctxHolder: ContextHolder, store: SqliteAdapter, deps: MemoryRouteDeps ): MemoryService { const ctx = ctxHolder.current; return new MemoryService({ store, config: ctx.config, collections: ctx.config.collections, lockPath: deps.lockPath ?? writeLeasePath(getIndexDbPath(ctx.indexName)), lockWaitMs: deps.lockWaitMs, embedPort: ctx.embedPort, vectorIndex: ctx.vectorIndex, }); } /** * POST /api/memory/remember * Store a fact (or propose candidates) in a memory-managed collection. * Returns 201 when a record was written, 200 otherwise. */ export async function handleMemoryRemember( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request, deps: MemoryRouteDeps = {} ): Promise { const parsed = await readMemoryBody(req); if (!parsed.ok) return parsed.response; try { const result = await createMemoryService(ctxHolder, store, deps).remember( parsed.body as unknown as MemoryRememberRequestBody ); const wrote = result.outcome === "added" || result.outcome === "superseded"; if (wrote) ctxHolder.markContentMutation?.(); return jsonResponse(result, wrote ? HTTP_CREATED : 200); } catch (error) { return memoryErrorResponse(error, "Failed to remember"); } } /** * POST /api/memory/recall * Budgeted, cited recall of current facts in the caller's explicit scopes. */ export async function handleMemoryRecall( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request, deps: MemoryRouteDeps = {} ): Promise { const parsed = await readMemoryBody(req); if (!parsed.ok) return parsed.response; try { const result = await createMemoryService(ctxHolder, store, deps).recall( parsed.body as unknown as MemoryRecallRequestBody ); return jsonResponse(result); } catch (error) { return memoryErrorResponse(error, "Failed to recall"); } } /** * POST /api/docs * Create a new document in a collection. * Returns 202 with jobId for async sync. */ export async function handleCreateDoc( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request, deps?: { syncCollection?: typeof defaultSyncService.syncCollection; } ): Promise { let body: CreateDocRequestBody; try { body = (await req.json()) as CreateDocRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } // Validate required fields with type checks if (!body.collection || typeof body.collection !== "string") { return errorResponse("VALIDATION", "Missing or invalid collection"); } if (body.relPath !== undefined && typeof body.relPath !== "string") { return errorResponse("VALIDATION", "relPath must be a string"); } if (body.title !== undefined && typeof body.title !== "string") { return errorResponse("VALIDATION", "title must be a string"); } if (body.folderPath !== undefined && typeof body.folderPath !== "string") { return errorResponse("VALIDATION", "folderPath must be a string"); } if (body.content !== undefined && typeof body.content !== "string") { return errorResponse("VALIDATION", "content must be a string"); } if (body.overwrite !== undefined && typeof body.overwrite !== "boolean") { return errorResponse("VALIDATION", "overwrite must be a boolean"); } if ( body.collisionPolicy !== undefined && body.collisionPolicy !== "error" && body.collisionPolicy !== "open_existing" && body.collisionPolicy !== "create_with_suffix" ) { return errorResponse( "VALIDATION", "collisionPolicy must be one of: error, open_existing, create_with_suffix" ); } if (body.presetId !== undefined && !getNotePreset(body.presetId)) { return errorResponse("VALIDATION", "Unknown presetId"); } // Validate tags if provided let validatedTags: string[] = []; if (body.tags && Array.isArray(body.tags)) { try { validatedTags = parseAndValidateTagFilter(body.tags.join(",")); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tags" ); } } // Find collection (case-insensitive) const collectionName = body.collection.toLowerCase(); const collection = ctxHolder.config.collections.find( (c) => c.name.toLowerCase() === collectionName ); if (!collection) { return errorResponse( "NOT_FOUND", `Collection not found: ${body.collection}`, 404 ); } const existingRelPaths = await listCollectionRelPaths(store, collection.name); let createPlan; try { createPlan = resolveNoteCreatePlan( { collection: collection.name, relPath: body.relPath, title: body.title?.trim() || (body.relPath ? pathPosix.basename(body.relPath).replace(/\.[^.]+$/, "") : sanitizeNoteFilename("untitled")), folderPath: body.folderPath, collisionPolicy: body.collisionPolicy, }, existingRelPaths ); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : String(e), 409 ); } const normalizedRelPath = createPlan.relPath; const nodePath = await import("node:path"); // no bun equivalent const fullPath = nodePath.join(collection.path, normalizedRelPath); if (createPlan.openedExisting) { const existingDocResult = await store.getDocument( collection.name, normalizedRelPath ); if (!existingDocResult.ok) { return errorResponse("RUNTIME", existingDocResult.error.message, 500); } if (!existingDocResult.value) { return errorResponse( "CONFLICT", "File exists, but indexed document could not be resolved", 409 ); } return jsonResponse({ uri: existingDocResult.value.uri, path: fullPath, relPath: normalizedRelPath, created: false, openedExisting: true, note: "Existing note opened.", }); } try { // Check if file already exists const file = Bun.file(fullPath); if ((await file.exists()) && !body.overwrite) { return errorResponse( "CONFLICT", "File already exists. Set overwrite=true to replace.", 409 ); } // Ensure parent directory exists const parentDir = nodePath.dirname(fullPath); const { mkdir } = await import("node:fs/promises"); // structure ops need fs await mkdir(parentDir, { recursive: true }); // Inject tags into frontmatter for markdown files const presetContent = body.presetId ? resolveNotePreset({ presetId: body.presetId, title: body.title?.trim() || pathPosix.basename(normalizedRelPath).replace(/\.[^.]+$/, ""), tags: validatedTags, body: body.content, }) : null; let contentToWrite = presetContent?.content ?? body.content ?? `# ${ body.title?.trim() || pathPosix.basename(normalizedRelPath).replace(/\.[^.]+$/, "") }\n`; const ext = nodePath.extname(normalizedRelPath).toLowerCase(); if (validatedTags.length > 0 && (ext === ".md" || ext === ".markdown")) { contentToWrite = updateFrontmatterTags(contentToWrite, validatedTags); } ctxHolder.watchService?.suppress(fullPath); await atomicWrite(fullPath, contentToWrite); // Build gno:// URI for the created document const posixRelPath = normalizedRelPath.split(nodePath.sep).join("/"); const gnoUri = `gno://${collection.name}/${posixRelPath}`; // Run sync via job system (non-blocking) // Note: embedding handled separately by embed-scheduler (not inline) const jobResult = await startJob( "sync", async (): Promise => { const result = await syncResidentCollection( ctxHolder, collection, store, withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config), deps?.syncCollection ); // Notify scheduler after sync completes (use gnoUri as docid placeholder) // The sync will create a proper docid, but we don't have it here yet // Using normalizedRelPath as identifier since docid is generated during sync ctxHolder.scheduler?.notifySyncComplete([normalizedRelPath]); ctxHolder.eventBus?.emit({ type: "document-changed", uri: gnoUri, collection: collection.name, relPath: normalizedRelPath, origin: "create", changedAt: new Date().toISOString(), }); return { collections: [result], totalDurationMs: result.durationMs, totalFilesProcessed: result.filesProcessed, totalFilesAdded: result.filesAdded, totalFilesUpdated: result.filesUpdated, totalFilesErrored: result.filesErrored, totalFilesSkipped: result.filesSkipped, }; }, ctxHolder.jobManager ); return jsonResponse( { uri: gnoUri, path: fullPath, jobId: jobResult.ok ? jobResult.jobId : null, relPath: normalizedRelPath, created: true, openedExisting: false, createdWithSuffix: createPlan.createdWithSuffix, note: jobResult.ok ? "File created. Sync job started - poll /api/jobs/:id for status." : "File created. Sync skipped (another job running).", }, 202 ); } catch (e) { return errorResponse( "RUNTIME", `Failed to create document: ${e instanceof Error ? e.message : String(e)}`, 500 ); } } /** * POST /api/search * Body: { query, mode?, limit?, minScore?, collection? } * Returns search results. */ export async function handleSearch( contextOrStore: ServerContext | SqliteAdapter, req: Request ): Promise { const context = "store" in contextOrStore ? contextOrStore : (null as ServerContext | null); const store = "store" in contextOrStore ? contextOrStore.store : contextOrStore; let body: SearchRequestBody; try { body = (await req.json()) as SearchRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (body === null || typeof body !== "object" || Array.isArray(body)) { return errorResponse("VALIDATION", "Request body must be an object"); } if (!body.query || typeof body.query !== "string") { return errorResponse("VALIDATION", "Missing or invalid query"); } const rawQuery = body.query.trim(); if (!rawQuery) { return errorResponse("VALIDATION", "Query cannot be empty"); } // Validate limit: positive integer if ( body.limit !== undefined && (typeof body.limit !== "number" || body.limit < 1) ) { return errorResponse("VALIDATION", "limit must be a positive integer"); } // Validate minScore: number between 0 and 1 if ( body.minScore !== undefined && (typeof body.minScore !== "number" || body.minScore < 0 || body.minScore > 1) ) { return errorResponse( "VALIDATION", "minScore must be a number between 0 and 1" ); } if (body.since !== undefined && typeof body.since !== "string") { return errorResponse("VALIDATION", "since must be a string"); } if (body.until !== undefined && typeof body.until !== "string") { return errorResponse("VALIDATION", "until must be a string"); } if (body.intent !== undefined && typeof body.intent !== "string") { return errorResponse("VALIDATION", "intent must be a string"); } if (body.exclude !== undefined && typeof body.exclude !== "string") { return errorResponse( "VALIDATION", "exclude must be a comma-separated string" ); } if (body.category !== undefined && typeof body.category !== "string") { return errorResponse( "VALIDATION", "category must be a comma-separated string" ); } if (body.author !== undefined && typeof body.author !== "string") { return errorResponse("VALIDATION", "author must be a string"); } // Parse tag filters let tagsAll: string[] | undefined; let tagsAny: string[] | undefined; if (body.tagsAll) { try { tagsAll = parseAndValidateTagFilter(body.tagsAll); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAll" ); } } if (body.tagsAny) { try { tagsAny = parseAndValidateTagFilter(body.tagsAny); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAny" ); } } const categories = body.category ? parseCommaSeparatedValues(body.category) : undefined; const exclude = body.exclude ? parseCommaSeparatedValues(body.exclude) : undefined; const author = body.author?.trim() || undefined; const parsedFilter = parseRestMetadataFilter(body.filter); if (!parsedFilter.ok) return parsedFilter.response; const filter = parsedFilter.filter; let projectAffinity: ProjectAffinityScoringInput | undefined; try { projectAffinity = context ? await resolveRemoteProjectAffinity(context.config, body.projectHints) : undefined; } catch (error) { return errorResponse( "VALIDATION", error instanceof ProjectAffinityInputError ? error.message : "Invalid project hints" ); } // Only BM25 supported in web UI (vector/hybrid require LLM ports) const options: SearchOptions = { limit: Math.min(body.limit || 10, 50), minScore: body.minScore, collection: body.collection, intent: body.intent?.trim() || undefined, exclude, tagsAll, tagsAny, since: body.since, until: body.until, categories, author, filter, projectAffinity, contentTypeRules: context ? normalizeContentTypes(context.config.contentTypes ?? []).rules : undefined, }; const trace = context ? await startRestTrace(context, { query: rawQuery, filters: retrievalTraceFilters(options), pipeline: "bm25", }) : { session: null, error: null }; if (trace.error) return trace.error; const result = await searchBm25(store, rawQuery, { ...options, traceSession: trace.session ?? undefined, }); if (!result.ok) { return finishRestTrace( req, trace.session, "failed", result.error.code === "INVALID_INPUT" ? errorResponse("VALIDATION", result.error.message, 400) : errorResponse("RUNTIME", result.error.message, 500) ); } return withRetrievalTraceHeader(jsonResponse(result.value), trace.session); } /** * POST /api/query * Body: { query, limit?, minScore?, collection?, lang?, queryModes?, noExpand?, noRerank? } * Returns hybrid search results (BM25 + vector + expansion + reranking). */ export async function handleQuery( ctx: ServerContext, req: Request ): Promise { let body: QueryRequestBody; try { body = (await req.json()) as QueryRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.query || typeof body.query !== "string") { return errorResponse("VALIDATION", "Missing or invalid query"); } const rawQuery = body.query.trim(); if (!rawQuery) { return errorResponse("VALIDATION", "Query cannot be empty"); } // Validate limit if ( body.limit !== undefined && (typeof body.limit !== "number" || body.limit < 1) ) { return errorResponse("VALIDATION", "limit must be a positive integer"); } // Validate minScore if ( body.minScore !== undefined && (typeof body.minScore !== "number" || body.minScore < 0 || body.minScore > 1) ) { return errorResponse( "VALIDATION", "minScore must be a number between 0 and 1" ); } if (body.since !== undefined && typeof body.since !== "string") { return errorResponse("VALIDATION", "since must be a string"); } if (body.until !== undefined && typeof body.until !== "string") { return errorResponse("VALIDATION", "until must be a string"); } if (body.intent !== undefined && typeof body.intent !== "string") { return errorResponse("VALIDATION", "intent must be a string"); } if (body.exclude !== undefined && typeof body.exclude !== "string") { return errorResponse( "VALIDATION", "exclude must be a comma-separated string" ); } if ( body.candidateLimit !== undefined && (typeof body.candidateLimit !== "number" || body.candidateLimit < 1) ) { return errorResponse( "VALIDATION", "candidateLimit must be a positive integer" ); } if (body.category !== undefined && typeof body.category !== "string") { return errorResponse( "VALIDATION", "category must be a comma-separated string" ); } if (body.author !== undefined && typeof body.author !== "string") { return errorResponse("VALIDATION", "author must be a string"); } if (body.explain !== undefined && typeof body.explain !== "boolean") { return errorResponse("VALIDATION", "explain must be a boolean"); } const { queryModes, error: queryModesError } = parseQueryModesInput( body.queryModes ); if (queryModesError) { return queryModesError; } const { query, queryModes: normalizedQueryModes, error: structuredQueryError, } = normalizeStructuredQueryBody(rawQuery, queryModes); if (structuredQueryError) { return structuredQueryError; } const normalizedQuery = query ?? rawQuery; // Parse tag filters let tagsAll: string[] | undefined; let tagsAny: string[] | undefined; if (body.tagsAll) { try { tagsAll = parseAndValidateTagFilter(body.tagsAll); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAll" ); } } if (body.tagsAny) { try { tagsAny = parseAndValidateTagFilter(body.tagsAny); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAny" ); } } const categories = body.category ? parseCommaSeparatedValues(body.category) : undefined; const exclude = body.exclude ? parseCommaSeparatedValues(body.exclude) : undefined; const author = body.author?.trim() || undefined; const parsedFilter = parseRestMetadataFilter(body.filter); if (!parsedFilter.ok) return parsedFilter.response; const filter = parsedFilter.filter; let projectAffinity: ProjectAffinityScoringInput | undefined; try { projectAffinity = await resolveRemoteProjectAffinity( ctx.config, body.projectHints ); } catch (error) { return errorResponse( "VALIDATION", error instanceof ProjectAffinityInputError ? error.message : "Invalid project hints" ); } const queryOptions = { limit: Math.min(body.limit ?? 20, 50), minScore: body.minScore, collection: body.collection, lang: body.lang, intent: body.intent?.trim() || undefined, candidateLimit: body.candidateLimit !== undefined ? Math.min(body.candidateLimit, 100) : undefined, exclude, queryModes: normalizedQueryModes, noExpand: body.noExpand, noRerank: body.noRerank, graph: body.graph, noGraph: body.noGraph, tagsAll, tagsAny, since: body.since, until: body.until, categories, author, filter, projectAffinity, explain: body.explain, }; const trace = await startRestTrace(ctx, { query: normalizedQuery, filters: retrievalTraceFilters(queryOptions), pipeline: "hybrid", modelUris: [ ctx.embedPort?.modelUri, ctx.expandPort?.modelUri, ctx.rerankPort?.modelUri, ].filter((value): value is string => Boolean(value)), }); if (trace.error) return trace.error; const result = await searchHybrid( { store: ctx.store, config: ctx.config, vectorIndex: ctx.vectorIndex, embedPort: ctx.embedPort, expandPort: ctx.expandPort, rerankPort: ctx.rerankPort, }, normalizedQuery, { ...queryOptions, traceSession: trace.session ?? undefined, } ); if (!result.ok) { return finishRestTrace( req, trace.session, "failed", errorResponse("RUNTIME", result.error.message, 500) ); } return withRetrievalTraceHeader(jsonResponse(result.value), trace.session); } /** * POST /api/query/diagnose * Body: { query, target, limit?, minScore?, collection?, lang?, queryModes?, noExpand?, noRerank?, graph? } * Returns targeted retrieval diagnostics for one document. */ export async function handleQueryDiagnose( ctx: ServerContext, req: Request ): Promise { let body: QueryDiagnoseRequestBody; try { body = (await req.json()) as QueryDiagnoseRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body || typeof body !== "object" || Array.isArray(body)) { return errorResponse("VALIDATION", "JSON body must be an object"); } if (!body.query || typeof body.query !== "string") { return errorResponse("VALIDATION", "Missing or invalid query"); } if (!body.target || typeof body.target !== "string") { return errorResponse("VALIDATION", "Missing or invalid target"); } const rawQuery = body.query.trim(); const target = body.target.trim(); if (!rawQuery) { return errorResponse("VALIDATION", "Query cannot be empty"); } if (!target) { return errorResponse("VALIDATION", "target cannot be empty"); } if ( body.limit !== undefined && (typeof body.limit !== "number" || body.limit < 1) ) { return errorResponse("VALIDATION", "limit must be a positive integer"); } if ( body.minScore !== undefined && (typeof body.minScore !== "number" || body.minScore < 0 || body.minScore > 1) ) { return errorResponse( "VALIDATION", "minScore must be a number between 0 and 1" ); } if (body.since !== undefined && typeof body.since !== "string") { return errorResponse("VALIDATION", "since must be a string"); } if (body.until !== undefined && typeof body.until !== "string") { return errorResponse("VALIDATION", "until must be a string"); } if (body.intent !== undefined && typeof body.intent !== "string") { return errorResponse("VALIDATION", "intent must be a string"); } if (body.exclude !== undefined && typeof body.exclude !== "string") { return errorResponse( "VALIDATION", "exclude must be a comma-separated string" ); } if ( body.candidateLimit !== undefined && (typeof body.candidateLimit !== "number" || body.candidateLimit < 1) ) { return errorResponse( "VALIDATION", "candidateLimit must be a positive integer" ); } if (body.category !== undefined && typeof body.category !== "string") { return errorResponse( "VALIDATION", "category must be a comma-separated string" ); } if (body.author !== undefined && typeof body.author !== "string") { return errorResponse("VALIDATION", "author must be a string"); } const { queryModes, error: queryModesError } = parseQueryModesInput( body.queryModes ); if (queryModesError) { return queryModesError; } const { query, queryModes: normalizedQueryModes, error: structuredQueryError, } = normalizeStructuredQueryBody(rawQuery, queryModes); if (structuredQueryError) { return structuredQueryError; } const normalizedQuery = query ?? rawQuery; let tagsAll: string[] | undefined; let tagsAny: string[] | undefined; if (body.tagsAll) { try { tagsAll = parseAndValidateTagFilter(body.tagsAll); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAll" ); } } if (body.tagsAny) { try { tagsAny = parseAndValidateTagFilter(body.tagsAny); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAny" ); } } const categories = body.category ? parseCommaSeparatedValues(body.category) : undefined; const exclude = body.exclude ? parseCommaSeparatedValues(body.exclude) : undefined; const author = body.author?.trim() || undefined; const parsedFilter = parseRestMetadataFilter(body.filter); if (!parsedFilter.ok) return parsedFilter.response; const filter = parsedFilter.filter; const contentTypeRules = normalizeContentTypes( ctx.config.contentTypes ?? [] ).rules; let projectAffinity: ProjectAffinityScoringInput | undefined; try { projectAffinity = await resolveRemoteProjectAffinity( ctx.config, body.projectHints ); } catch (error) { return errorResponse( "VALIDATION", error instanceof ProjectAffinityInputError ? error.message : "Invalid project hints" ); } const result = await diagnoseQueryTarget( { store: ctx.store, config: ctx.config, vectorIndex: ctx.vectorIndex, embedPort: ctx.embedPort, expandPort: ctx.expandPort, rerankPort: ctx.rerankPort, }, normalizedQuery, { target, limit: Math.min(body.limit ?? 20, 50), minScore: body.minScore, collection: body.collection, lang: body.lang, intent: body.intent?.trim() || undefined, candidateLimit: body.candidateLimit !== undefined ? Math.min(body.candidateLimit, 100) : undefined, exclude, queryModes: normalizedQueryModes, noExpand: body.noExpand, noRerank: body.noRerank, graph: body.graph, noGraph: body.noGraph, tagsAll, tagsAny, since: body.since, until: body.until, categories, author, filter, projectAffinity, contentTypeRules, contentTypeRulesFingerprint: fingerprintContentTypeMetadataRules(contentTypeRules), } ); if (!result.ok) { return errorResponse("RUNTIME", result.error.message, 500); } return jsonResponse(result.value); } /** * POST /api/ask * Body: { query, limit?, collection?, lang?, maxAnswerTokens? } * Returns AI-generated answer with citations and sources. */ export async function handleAsk( ctx: ServerContext, req: Request ): Promise { let body: AskRequestBody; try { body = (await req.json()) as AskRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (body === null || typeof body !== "object" || Array.isArray(body)) { return errorResponse("VALIDATION", "Request body must be an object"); } const unknownKey = Object.keys(body).find( (key) => !ASK_REQUEST_KEYS.has(key as keyof AskRequestBody) ); if (unknownKey) { return errorResponse( "VALIDATION", `Unknown Ask request field: ${unknownKey}` ); } if (!body.query || typeof body.query !== "string") { return errorResponse("VALIDATION", "Missing or invalid query"); } for (const field of [ "verify", "noExpand", "noRerank", "graph", "noGraph", "explain", ] as const) { if (body[field] !== undefined && typeof body[field] !== "boolean") { return errorResponse("VALIDATION", `${field} must be a boolean`); } } for (const [field, value, maximum] of [ ["limit", body.limit, 20], ["candidateLimit", body.candidateLimit, 100], ["maxAnswerTokens", body.maxAnswerTokens, Number.MAX_SAFE_INTEGER], ] as const) { if ( value !== undefined && (!Number.isSafeInteger(value) || value < 1 || value > maximum) ) { return errorResponse( "VALIDATION", `${field} must be a positive integer${maximum < Number.MAX_SAFE_INTEGER ? ` no greater than ${maximum}` : ""}` ); } } const rawQuery = body.query.trim(); if (!rawQuery) { return errorResponse("VALIDATION", "Query cannot be empty"); } if ( body.minScore !== undefined && (typeof body.minScore !== "number" || !Number.isFinite(body.minScore) || body.minScore < 0 || body.minScore > 1) ) { return errorResponse("VALIDATION", "minScore must be between 0 and 1"); } if ( body.contextBudgetTokens !== undefined && (!Number.isSafeInteger(body.contextBudgetTokens) || body.contextBudgetTokens < 1) ) { return errorResponse( "VALIDATION", "contextBudgetTokens must be a positive integer" ); } if ( body.contextBudgetBytes !== undefined && (!Number.isSafeInteger(body.contextBudgetBytes) || body.contextBudgetBytes < 1) ) { return errorResponse( "VALIDATION", "contextBudgetBytes must be a positive integer" ); } // Parse tag filters let tagsAll: string[] | undefined; let tagsAny: string[] | undefined; if (body.since !== undefined && typeof body.since !== "string") { return errorResponse("VALIDATION", "since must be a string"); } if (body.until !== undefined && typeof body.until !== "string") { return errorResponse("VALIDATION", "until must be a string"); } if (body.intent !== undefined && typeof body.intent !== "string") { return errorResponse("VALIDATION", "intent must be a string"); } if (body.exclude !== undefined && typeof body.exclude !== "string") { return errorResponse( "VALIDATION", "exclude must be a comma-separated string" ); } if ( body.candidateLimit !== undefined && (typeof body.candidateLimit !== "number" || body.candidateLimit < 1) ) { return errorResponse( "VALIDATION", "candidateLimit must be a positive integer" ); } if (body.category !== undefined && typeof body.category !== "string") { return errorResponse( "VALIDATION", "category must be a comma-separated string" ); } if (body.author !== undefined && typeof body.author !== "string") { return errorResponse("VALIDATION", "author must be a string"); } const { queryModes, error: queryModesError } = parseQueryModesInput( body.queryModes ); if (queryModesError) { return queryModesError; } const { query, queryModes: normalizedQueryModes, error: structuredQueryError, } = normalizeStructuredQueryBody(rawQuery, queryModes); if (structuredQueryError) { return structuredQueryError; } const normalizedQuery = query ?? rawQuery; if (body.tagsAll) { try { tagsAll = parseAndValidateTagFilter(body.tagsAll); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAll" ); } } if (body.tagsAny) { try { tagsAny = parseAndValidateTagFilter(body.tagsAny); } catch (e) { return errorResponse( "VALIDATION", e instanceof Error ? e.message : "Invalid tagsAny" ); } } const categories = body.category ? parseCommaSeparatedValues(body.category) : undefined; const exclude = body.exclude ? parseCommaSeparatedValues(body.exclude) : undefined; const author = body.author?.trim() || undefined; const parsedFilter = parseRestMetadataFilter(body.filter); if (!parsedFilter.ok) return parsedFilter.response; const filter = parsedFilter.filter; let projectAffinity: ProjectAffinityScoringInput | undefined; try { projectAffinity = await resolveRemoteProjectAffinity( ctx.config, body.projectHints ); } catch (error) { return errorResponse( "VALIDATION", error instanceof ProjectAffinityInputError ? error.message : "Invalid project hints" ); } const limit = Math.min(body.limit ?? 5, 20); const askOptions = { limit, collection: body.collection, lang: body.lang, intent: body.intent?.trim() || undefined, minScore: body.minScore, noExpand: body.noExpand, noRerank: body.noRerank, candidateLimit: body.candidateLimit !== undefined ? Math.min(body.candidateLimit, 100) : undefined, exclude, graph: body.graph, noGraph: body.noGraph, queryModes: normalizedQueryModes, tagsAll, tagsAny, since: body.since, until: body.until, categories, author, filter, verify: body.verify, contextBudgetTokens: body.contextBudgetTokens, contextBudgetBytes: body.contextBudgetBytes, maxAnswerTokens: body.maxAnswerTokens, projectAffinity, explain: body.explain, }; const trace = await startRestTrace(ctx, { query: normalizedQuery, filters: retrievalTraceFilters(askOptions), pipeline: "ask", modelUris: [ ctx.embedPort?.modelUri, ctx.expandPort?.modelUri, ctx.answerPort?.modelUri, ctx.rerankPort?.modelUri, ].filter((value): value is string => Boolean(value)), }); if (trace.error) return trace.error; if (!ctx.capabilities.answer) { const unavailable = await trace.session?.recordCapability( "answer_generation", "unavailable", "model_unavailable" ); if (unavailable && !unavailable.ok) { return finishRestTrace( req, trace.session, req.signal.aborted ? "cancelled" : "failed", errorResponse("RUNTIME", unavailable.error.message, 500) ); } return finishRestTrace( req, trace.session, "failed", errorResponse( "UNAVAILABLE", "Answer generation not available. No answer model loaded.", 503 ) ); } if (body.verify && ctx.answerPort) { try { const verified = await buildVerifiedAsk(normalizedQuery, askOptions, { store: ctx.store, config: ctx.config, indexName: ctx.indexName, vectorIndex: ctx.vectorIndex, embedPort: ctx.embedPort, rerankPort: ctx.rerankPort, genPort: ctx.answerPort, projectAffinity, traceSession: trace.session ?? undefined, }); return finishRestTrace( req, trace.session, answerTraceTerminalStatus(verified.citations), jsonResponse(verified) ); } catch (error) { return finishRestTrace( req, trace.session, "failed", errorResponse( "RUNTIME", error instanceof Error ? error.message : String(error), 500 ) ); } } // Run hybrid search first const searchResult = await searchHybrid( { store: ctx.store, config: ctx.config, vectorIndex: ctx.vectorIndex, embedPort: ctx.embedPort, expandPort: ctx.expandPort, rerankPort: ctx.rerankPort, }, normalizedQuery, { ...askOptions, traceSession: trace.session ?? undefined, } ); if (!searchResult.ok) { return finishRestTrace( req, trace.session, "failed", errorResponse("RUNTIME", searchResult.error.message, 500) ); } const results = searchResult.value.results; // Generate grounded answer (requires answer model) let answer: string | undefined; let citations: Citation[] | undefined; let answerContext: AskResult["meta"]["answerContext"] | undefined; let answerGenerated = false; if (ctx.answerPort) { const attempted = await trace.session?.recordCapability( "answer_generation", "attempted" ); if (attempted && !attempted.ok) { return finishRestTrace( req, trace.session, "failed", errorResponse("RUNTIME", attempted.error.message, 500) ); } const maxTokens = body.maxAnswerTokens ?? 512; let rawResult: Awaited>; try { rawResult = await generateGroundedAnswer( { genPort: ctx.answerPort, store: ctx.store }, normalizedQuery, results, maxTokens ); } catch (error) { await trace.session?.recordCapability( "answer_generation", "failed", "generation_failed" ); return finishRestTrace( req, trace.session, "failed", errorResponse( "RUNTIME", error instanceof Error ? error.message : String(error), 500 ) ); } if (rawResult) { try { const processed = await processAnswerResultWithTrace( rawResult, trace.session ?? undefined ); answer = processed.answer; citations = processed.citations; answerContext = processed.answerContext; answerGenerated = true; const used = await trace.session?.recordCapability( "answer_generation", "used" ); if (used && !used.ok) { throw new Error(`Trace recording failed: ${used.error.message}`); } } catch (error) { await trace.session?.recordCapability( "answer_generation", "failed", "generation_failed" ); return finishRestTrace( req, trace.session, "failed", errorResponse( "RUNTIME", error instanceof Error ? error.message : String(error), 500 ) ); } } else { const failed = await trace.session?.recordCapability( "answer_generation", "failed", "generation_failed" ); if (failed && !failed.ok) { return finishRestTrace( req, trace.session, "failed", errorResponse("RUNTIME", failed.error.message, 500) ); } } } const askResult: AskResult = { query: normalizedQuery, mode: searchResult.value.meta.vectorsUsed ? "hybrid" : "bm25_only", queryLanguage: searchResult.value.meta.queryLanguage ?? "und", answer, citations, results, meta: { expanded: searchResult.value.meta.expanded ?? false, reranked: searchResult.value.meta.reranked ?? false, vectorsUsed: searchResult.value.meta.vectorsUsed ?? false, intent: searchResult.value.meta.intent, candidateLimit: searchResult.value.meta.candidateLimit, exclude: searchResult.value.meta.exclude, queryModes: searchResult.value.meta.queryModes, answerGenerated, totalResults: results.length, answerContext, ...(body.explain && searchResult.value.meta.explain ? { explain: searchResult.value.meta.explain } : {}), }, }; const complete = answerGenerated && (citations?.length ?? 0) > 0; return finishRestTrace( req, trace.session, complete ? "completed" : "partial", jsonResponse(askResult) ); } // ───────────────────────────────────────────────────────────────────────────── // Status with capabilities // ───────────────────────────────────────────────────────────────────────────── /** * GET /api/capabilities * Returns server capabilities (what features are available) plus whether the * caller is a same-host client (see request-locality). */ export function handleCapabilities( ctx: ServerContext, req: Request, server: RequestPeerServer | undefined ): Response { return jsonResponse({ bm25: ctx.capabilities.bm25, vector: ctx.capabilities.vector, hybrid: ctx.capabilities.hybrid, answer: ctx.capabilities.answer, localClient: isLocalClientRequest(req, server), }); } // ───────────────────────────────────────────────────────────────────────────── // Presets // ───────────────────────────────────────────────────────────────────────────── export interface PresetInfo extends ModelPreset { active: boolean; } /** * GET /api/presets * Returns available model presets and which is active. */ export function handlePresets(ctx: ServerContext): Response { const modelConfig = getModelConfig(ctx.config); const presets = listPresets(ctx.config); const activeId = modelConfig.activePreset; const presetsWithStatus: PresetInfo[] = presets.map((p) => ({ ...p, active: p.id === activeId, })); return jsonResponse({ presets: presetsWithStatus, activePreset: activeId, capabilities: ctx.capabilities, }); } export interface SetPresetRequestBody { presetId: string; } /** * POST /api/presets * Switch to a different preset and reload LLM context. */ export async function handleSetPreset( ctxHolder: ContextHolder, req: Request, deps?: { applyConfigChangeFn?: typeof applyConfigChange; reloadServerContextFn?: typeof reloadServerContext; } ): Promise { let body: SetPresetRequestBody; try { body = (await req.json()) as SetPresetRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.presetId || typeof body.presetId !== "string") { return errorResponse("VALIDATION", "Missing or invalid presetId"); } // Validate preset exists const preset = getPreset(ctxHolder.config, body.presetId); if (!preset) { return errorResponse("NOT_FOUND", `Unknown preset: ${body.presetId}`, 404); } console.log(`Switching to preset: ${preset.name}`); const previousEmbedModel = resolveModelUri(ctxHolder.config, "embed"); const syncResult = await (deps?.applyConfigChangeFn ?? applyConfigChange)( ctxHolder, ctxHolder.current.store, async (config) => { const currentModelConfig = getModelConfig(config); return { ok: true, config: { ...config, models: { activePreset: body.presetId, presets: config.models?.presets ?? [], loadTimeout: config.models?.loadTimeout ?? currentModelConfig.loadTimeout, inferenceTimeout: config.models?.inferenceTimeout ?? currentModelConfig.inferenceTimeout, expandContextSize: config.models?.expandContextSize ?? currentModelConfig.expandContextSize, warmModelTtl: config.models?.warmModelTtl ?? currentModelConfig.warmModelTtl, }, }, }; } ); if (!syncResult.ok) { return errorResponse("RUNTIME", syncResult.error, 500); } try { ctxHolder.current = await ( deps?.reloadServerContextFn ?? reloadServerContext )(ctxHolder.current, syncResult.config); ctxHolder.config = syncResult.config; } catch (e) { return errorResponse( "RUNTIME", `Failed to reload context: ${e instanceof Error ? e.message : String(e)}`, 500 ); } return jsonResponse({ success: true, activePreset: body.presetId, embedModelChanged: previousEmbedModel !== preset.embed, note: previousEmbedModel !== preset.embed ? "Embedding model changed. Existing collections may need gno embed so vector results catch up." : undefined, capabilities: ctxHolder.current.capabilities, }); } // ───────────────────────────────────────────────────────────────────────────── // Model Download // ───────────────────────────────────────────────────────────────────────────── /** * GET /api/models/status * Returns current download status for polling. */ export function handleModelStatus(): Response { return jsonResponse({ active: downloadState.active, currentType: downloadState.currentType, progress: downloadState.progress, completed: downloadState.completed, failed: downloadState.failed, startedAt: downloadState.startedAt, }); } /** * POST /api/models/pull * Start downloading models for current preset. * Returns immediately; poll /api/models/status for progress. */ export function handleModelPull( ctxHolder: ContextHolder, deps?: { modelsPullFn?: typeof modelsPull; reloadServerContextFn?: typeof reloadServerContext; } ): Response { // Don't start if already downloading if (downloadState.active) { return errorResponse("CONFLICT", "Download already in progress", 409); } // Reset and start resetDownloadState(); downloadState.active = true; downloadState.startedAt = Date.now(); const operation = async (signal: AbortSignal): Promise => { try { const result = await (deps?.modelsPullFn ?? modelsPull)({ config: ctxHolder.config, all: true, signal, onProgress: (type, progress) => { if (signal.aborted) return; downloadState.currentType = type; downloadState.progress = progress; }, }); if (signal.aborted) return; // Track results for (const r of result.results) { if (r.ok) { if (!r.skipped) { downloadState.completed.push(r.type); } } else { downloadState.failed.push({ type: r.type, error: r.error ?? "Unknown error", }); } } // Reload context to pick up new models console.log("Models downloaded, reloading context..."); try { ctxHolder.current = await ( deps?.reloadServerContextFn ?? reloadServerContext )(ctxHolder.current, ctxHolder.config); if (signal.aborted) return; console.log("Context reloaded"); if (ctxHolder.scheduler) { await ctxHolder.scheduler.triggerNow(); } } catch (e) { console.error("Failed to reload context:", e); } } catch (e) { console.error("Model download failed:", e); downloadState.failed.push({ type: downloadState.currentType ?? "embed", error: e instanceof Error ? e.message : String(e), }); } finally { downloadState.active = false; downloadState.currentType = null; downloadState.progress = null; } }; const started = ctxHolder.startBackgroundWork?.(operation) ?? false; if (!started) { resetDownloadState(); return errorResponse( "UNAVAILABLE", "Resident runtime is shutting down", 503 ); } return jsonResponse({ started: true, message: "Download started. Poll /api/models/status for progress.", }); } // ───────────────────────────────────────────────────────────────────────────── // Jobs // ───────────────────────────────────────────────────────────────────────────── /** * GET /api/jobs/:id * Poll job status for async operations. */ export function handleJob(jobId: string, jobManager?: JobManager): Response { const status = getJobStatus(jobId, jobManager); if (!status) { return errorResponse("NOT_FOUND", "Job not found or expired", 404); } return jsonResponse(status); } /** * GET /api/jobs/active * Returns the current active job, or null when idle. */ export function handleActiveJob(jobManager?: JobManager): Response { return jsonResponse({ activeJob: getActiveJob(jobManager), }); } // ───────────────────────────────────────────────────────────────────────────── // Embed Scheduler // ───────────────────────────────────────────────────────────────────────────── /** * POST /api/embed * Trigger immediate embedding (bypasses debounce). * Used by Cmd+S to force embed after save. */ export async function handleEmbed( scheduler: EmbedScheduler | null ): Promise { if (!scheduler) { return jsonResponse({ embedded: 0, errors: 0, note: "No embedding port available", }); } const state = scheduler.getState(); if (state.running) { return jsonResponse({ running: true, pendingCount: state.pendingDocCount, note: "Embedding already in progress", }); } const result = await scheduler.triggerNow(); if (!result) { return jsonResponse({ embedded: 0, errors: 0, note: "No embedding port available", }); } return jsonResponse({ embedded: result.embedded, errors: result.errors, }); } /** * GET /api/embed/status * Get current embed scheduler state (for debugging). */ export function handleEmbedStatus(scheduler: EmbedScheduler | null): Response { if (!scheduler) { return jsonResponse({ available: false, pendingDocCount: 0, running: false, }); } const state = scheduler.getState(); return jsonResponse({ available: true, ...state, }); } export async function handleConnectors( config: Config, overrides?: { cwd?: string; homeDir?: string }, deps: ConnectorRouteDeps = DEFAULT_CONNECTOR_ROUTE_DEPS ): Promise { return jsonResponse({ connectors: await deps.getStatuses(overrides), collections: config.collections .map(({ name }) => name) .sort((left, right) => left.localeCompare(right)), }); } /** * Explicitly run one collection-scoped connector retrieval proof. * Passive connector/status routes never call this handler. */ export async function handleVerifyConnector( config: Config, store: StorePort, req: Request, overrides?: { cwd?: string; homeDir?: string }, deps: ConnectorRouteDeps = DEFAULT_CONNECTOR_ROUTE_DEPS ): Promise { let body: unknown; try { body = await req.json(); } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body || typeof body !== "object" || Array.isArray(body)) { return errorResponse("VALIDATION", "Request body must be an object"); } const fields = Object.keys(body); const allowedFields = new Set(["connectorId", "collection"]); if (fields.some((field) => !allowedFields.has(field))) { return errorResponse("VALIDATION", "Request body has unknown fields"); } const { connectorId, collection } = body as Record; if ( typeof connectorId !== "string" || connectorId.length === 0 || connectorId.length > 128 ) { return errorResponse("VALIDATION", "Missing or invalid connectorId"); } if ( typeof collection !== "string" || collection.length === 0 || collection.length > 64 ) { return errorResponse("VALIDATION", "Missing or invalid collection"); } if (!config.collections.some(({ name }) => name === collection)) { return errorResponse("VALIDATION", "Unknown collection"); } try { const statuses = await deps.getStatuses(overrides); const connector = statuses.find(({ id }) => id === connectorId); if (!connector) { return errorResponse("VALIDATION", "Unknown connectorId"); } const result = await deps.verify( connectorId, store, collection, { force: true }, overrides ); if (!result.ok) { return errorResponse( "CONNECTOR_VERIFICATION_FAILED", "Connector verification could not be completed", 500 ); } const code = result.value.stages.connector.code; return jsonResponse({ verification: { collection: result.value.collection, lexicalReady: result.value.ready, connectorReady: result.value.stages.connector.status === "passed", generatedAt: result.value.generatedAt, stages: { connector: result.value.stages.connector }, }, remediation: code ? getConnectorVerificationRemediation( code as ConnectorVerificationCode, connector.appName ) : null, }); } catch { return errorResponse( "CONNECTOR_VERIFICATION_FAILED", "Connector verification could not be completed", 500 ); } } export async function handleInstallConnector( req: Request, overrides?: { cwd?: string; homeDir?: string; indexName?: string; configPath?: string; } ): Promise { let body: InstallConnectorRequestBody; try { body = (await req.json()) as InstallConnectorRequestBody; } catch { return errorResponse("VALIDATION", "Invalid JSON body"); } if (!body.connectorId || typeof body.connectorId !== "string") { return errorResponse("VALIDATION", "Missing or invalid connectorId"); } if (body.reinstall !== undefined && typeof body.reinstall !== "boolean") { return errorResponse("VALIDATION", "reinstall must be a boolean"); } try { return jsonResponse({ connector: await installConnector( body.connectorId, { reinstall: body.reinstall }, overrides ), }); } catch (error) { return errorResponse( "RUNTIME", error instanceof Error ? error.message : "Failed to install connector", 500 ); } } // ───────────────────────────────────────────────────────────────────────────── // Router // ───────────────────────────────────────────────────────────────────────────── /** * Route an API request to the appropriate handler. * Returns null if the path is not an API route. * Note: Currently unused since we use routes object in Bun.serve(). */ // oxlint-disable-next-line typescript-eslint/require-await -- handlers are async, kept for future use export async function routeApi( store: SqliteAdapter, config: Config, req: Request, url: URL ): Promise { const path = url.pathname; // CSRF protection: validate Origin for non-GET requests if (req.method !== "GET" && req.method !== "HEAD") { const origin = req.headers.get("origin"); const secFetchSite = req.headers.get("sec-fetch-site"); // Reject cross-origin requests (allow same-origin or no origin for curl) if (origin) { const originUrl = new URL(origin); if ( originUrl.hostname !== "127.0.0.1" && originUrl.hostname !== "localhost" ) { return errorResponse( "FORBIDDEN", "Cross-origin requests not allowed", 403 ); } } else if ( secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none" ) { return errorResponse( "FORBIDDEN", "Cross-origin requests not allowed", 403 ); } } if (path === "/api/health") { return handleHealth(); } if (path === "/api/status") { return handleStatus({ store, config, indexName: "default", vectorIndex: null, embedPort: null, expandPort: null, answerPort: null, rerankPort: null, capabilities: { bm25: true, vector: false, hybrid: false, answer: false, }, }); } if (path === "/api/collections") { return handleCollections(config); } if (path === "/api/connectors" && req.method === "GET") { return handleConnectors(config); } if (path === "/api/connectors/verify" && req.method === "POST") { return handleVerifyConnector(config, store, req); } if (path === "/api/publish/export" && req.method === "POST") { return handlePublishExport(config, store, req); } if (path === "/api/capture" && req.method === "POST") { const ctxHolder: ContextHolder = { current: { config, store, indexName: "default", vectorIndex: null, embedPort: null, expandPort: null, answerPort: null, rerankPort: null, capabilities: { bm25: true, vector: false, hybrid: false, answer: false, }, }, config, scheduler: null, eventBus: null, watchService: null, }; return handleCreateCapture(ctxHolder, store, req); } if (path === "/api/docs") { return handleDocs(store, url); } if (path === "/api/docs/autocomplete") { return handleDocsAutocomplete(store, url); } if (path === "/api/doc") { return handleDoc(store, config, url, req); } if (path === "/api/doc-asset") { return handleDocAsset(store, config, url, req); } if (path === "/api/search" && req.method === "POST") { return handleSearch( { store, config, indexName: "default", vectorIndex: null, embedPort: null, expandPort: null, answerPort: null, rerankPort: null, capabilities: { bm25: true, vector: false, hybrid: false, answer: false, }, }, req ); } if (path === "/api/changes" && req.method === "GET") { return handleChanges(store, url); } if (path === "/api/diff" && req.method === "GET") { return handleDiff(store, url); } if (path === "/api/impact" && req.method === "GET") { return handleImpact(store, url); } // Unknown API route if (path.startsWith("/api/")) { return errorResponse("NOT_FOUND", `Unknown API endpoint: ${path}`, 404); } return null; }