// Read projection and authorization for Mate Knowledge. // // Reads the continuously synchronized Clay Knowledge records, never the legacy // files. Chunk records and tombstones are excluded, chunked content is // reassembled and hash-verified, and a record that cannot be read back exactly // is refused rather than returned partially. // // Authorization mirrors project-logs-service.js: a caller never states who it // is. An ordinary Mate is bound to its own scope and can express no other, and // only the authoritative builtin Clay host agent receives same-user cross-Mate // reads. No binding ever spans users. var path = require("path"); var builtinMates = require("./builtin-mates"); var knowledgeImport = require("./knowledge-import"); var knowledgeSearch = require("./knowledge-search"); var mateSync = require("./mate-knowledge-sync"); var textToolsModule = require("./mate-knowledge-text-tools"); var mateBoundModule = require("./mate-knowledge-mate-bound"); var MAX_PAGE = 50; var DEFAULT_PAGE = 20; var MAX_QUERY_CHARS = 200; var MAX_PREVIEW_CHARS = 240; var MAX_SNIPPET_CHARS = 320; // Reads are bounded and continuable rather than refused. A record larger than // one response is returned as verified slices the caller can page through. var DEFAULT_READ_CHARS = 8000; var MAX_READ_CHARS = 40000; var REF_PATTERN = /^know:[A-Za-z0-9_-]{24}$/; // Name outranks body slightly; a Mate's knowledge file names are meaningful, // while journal lines all share one file name and rank on content. var NAME_WEIGHT = 2; var BODY_WEIGHT = 1; function parseLimit(value) { var limit = Number(value); if (!Number.isFinite(limit)) limit = DEFAULT_PAGE; return Math.max(1, Math.min(MAX_PAGE, Math.floor(limit))); } function encodeCursor(offset) { return offset > 0 ? Buffer.from(String(offset), "utf8").toString("base64url") : null; } function decodeCursor(value) { if (!value) return 0; try { var parsed = Number(Buffer.from(String(value), "base64url").toString("utf8")); return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; } catch (e) { return 0; } } function page(items, args) { var offset = decodeCursor(args && args.cursor); var limit = parseLimit(args && args.limit); return { items: items.slice(offset, offset + limit), nextCursor: offset + limit < items.length ? encodeCursor(offset + limit) : null, total: items.length, }; } function parseOffset(value) { var offset = Number(value); if (!Number.isFinite(offset) || offset < 0) return 0; return Math.floor(offset); } function parseReadSize(value) { var size = Number(value); if (!Number.isFinite(size)) size = DEFAULT_READ_CHARS; return Math.max(1, Math.min(MAX_READ_CHARS, Math.floor(size))); } // Never end a slice inside a UTF-16 surrogate pair, so every slice is on its // own a valid string and concatenating slices reproduces the source exactly. function safeSliceEnd(text, end) { if (end >= text.length) return text.length; var code = text.charCodeAt(end - 1); if (code >= 0xd800 && code <= 0xdbff) return end - 1; return end; } function clean(value, max) { if (typeof value !== "string") return ""; var text = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim(); return text.length > max ? text.substring(0, max) : text; } function attachMateKnowledgeService(ctx) { var context = ctx || {}; var getProjects = context.getProjects; var isMultiUser = context.isMultiUser; var resolveMate = context.resolveMate; var listMates = context.listMates || function () { return []; }; var baseDir = context.baseDir; var importerCache = new Map(); var textTools = textToolsModule.createMateKnowledgeTextTools({ baseDir: baseDir }); function importerForScope(scopeId) { if (context.openImporter) return context.openImporter(scopeId); if (!importerCache.has(scopeId)) { importerCache.set(scopeId, knowledgeImport.createKnowledgeImporter({ scopeId: scopeId, baseDir: baseDir })); } return importerCache.get(scopeId); } // --- Read projection -------------------------------------------------- // Bring this exact Mate's records up to date with its legacy files before // reading them, so a direct filesystem edit or an offline deletion is visible // on the next tool call instead of only after a daemon restart. Only ever // called for a scope the principal is already authorized for. Reconciliation // is idempotent, so a no-op call appends nothing. // A reconciliation failure means the record for that source may no longer // match the legacy file. Presenting the previous record would be stale // content dressed as current, so every failure is attributed to a source and // that source is withheld. A failure that cannot be attributed withholds the // whole scope, because there is no way to know which record is stale. function refresh(mate) { if (context.syncBeforeRead === false || !mate.mateDir) { return { failed: 0, files: {}, scopeFailed: false }; } var summary; try { summary = mateSync.reconcileMate({ mateDir: mate.mateDir, mateName: mate.mateName, baseDir: baseDir, importer: importerForScope(mate.scopeId), actor: { type: "system" }, }); } catch (e) { return { failed: 1, files: {}, scopeFailed: true }; } var failed = (summary && summary.failed) || 0; if (failed === 0) return { failed: 0, files: {}, scopeFailed: false }; var files = {}; var attributed = 0; var errors = (summary && summary.errors) || []; for (var i = 0; i < errors.length; i++) { var name = errors[i] && errors[i].file; // Only a plain basename is trusted as an identity; anything else is // treated as unattributable rather than matched loosely. if (typeof name !== "string" || !name || path.basename(name) !== name) continue; if (!files[name]) attributed++; files[name] = true; } return { failed: failed, files: files, scopeFailed: attributed < failed }; } // entries() already excludes chunk records and tombstones. Chunked sources // are reassembled and hash-verified here; one that does not verify is // reported as degraded and left out of ranking rather than half-returned. function materialize(mate, refreshed) { var state = refreshed || refresh(mate); var degraded = state.failed; // An unattributable failure withholds every record in this scope. if (state.scopeFailed) return { items: [], degraded: degraded, scopeFailed: true }; var importer = importerForScope(mate.scopeId); var live = importer.entries(); var out = []; for (var i = 0; i < live.length; i++) { var entry = live[i]; var sourceName = entry.source && entry.source.fileName; // Withhold the previous record for a source that just failed to reconcile. if (sourceName && state.files[sourceName]) continue; var read = importer.readContent(entry); if (!read.complete) { degraded++; continue; } out.push({ ref: entry.ref, kind: entry.kind, name: entry.name, content: read.content, updatedAt: entry.updatedAt, revisions: entry.revisions, relPath: entry.source ? entry.source.relPath : null, sourceDate: entry.source ? entry.source.sourceDate || null : null, mateId: mate.mateId, mateName: mate.mateName || null, }); } return { items: out, degraded: degraded, scopeFailed: false }; } // Public shapes never carry the full body; read_knowledge does that. function summary(item, includeMate) { var out = { ref: item.ref, kind: item.kind, name: item.name, updatedAt: item.updatedAt, revisions: item.revisions, relPath: item.relPath, sourceDate: item.sourceDate, preview: clean(item.content, MAX_PREVIEW_CHARS), }; if (includeMate) { out.mateId = item.mateId; out.mateName = item.mateName; } return out; } function projectItem(item) { return { id: item.ref, fields: [ { text: item.name || "", weight: NAME_WEIGHT }, { text: item.content || "", weight: BODY_WEIGHT }, ], meta: item, }; } function collect(mates) { var items = []; var degraded = 0; var degradedScopes = 0; for (var i = 0; i < mates.length; i++) { var result = materialize(mates[i]); degraded += result.degraded; if (result.scopeFailed) degradedScopes++; for (var j = 0; j < result.items.length; j++) items.push(result.items[j]); } return { items: items, degraded: degraded, degradedScopes: degradedScopes }; } // How much of the candidate set could actually be indexed. Reported so an // incomplete search is stated rather than implied by an empty result. function coverageOf(candidates) { var incomplete = 0; for (var i = 0; i < candidates.length; i++) { if (!knowledgeSearch.coverage(projectItem(candidates[i]).fields).complete) incomplete++; } return incomplete; } function listFor(mates, args, includeMate) { var options = args || {}; var collected = collect(mates); var items = collected.items; if (options.kind) { var kind = clean(String(options.kind), 60); items = items.filter(function (item) { return item.kind === kind; }); } items.sort(function (a, b) { return b.updatedAt - a.updatedAt || knowledgeSearch.compareIds(a.ref, b.ref); }); var result = page(items, options); return { entries: result.items.map(function (item) { return summary(item, includeMate); }), nextCursor: result.nextCursor, total: result.total, degraded: collected.degraded, degradedScopes: collected.degradedScopes, }; } function searchFor(mates, args, includeMate) { var options = args || {}; var query = clean(options.query || "", MAX_QUERY_CHARS); if (!query) throw new Error("A search query is required."); var collected = collect(mates); var candidates = collected.items; if (options.kind) { var kind = clean(String(options.kind), 60); candidates = candidates.filter(function (item) { return item.kind === kind; }); } var ranked = knowledgeSearch.rank(candidates, query, projectItem, candidates.length); var hits = []; for (var i = 0; i < ranked.length; i++) { var item = ranked[i].meta; var hit = summary(item, includeMate); hit.score = ranked[i].score; hit.snippet = clean(knowledgeSearch.snippet(item.content, query, MAX_SNIPPET_CHARS), MAX_SNIPPET_CHARS); hits.push(hit); } hits.sort(function (a, b) { return b.score - a.score || b.updatedAt - a.updatedAt || knowledgeSearch.compareIds(a.ref, b.ref); }); var result = page(hits, options); return { results: result.items, nextCursor: result.nextCursor, total: result.total, degraded: collected.degraded, degradedScopes: collected.degradedScopes, // Records whose searchable text exceeds what the index can cover. A term // appearing only past that bound cannot be ranked, so say so. incompleteCoverage: coverageOf(candidates), coveredCharsPerRecord: knowledgeSearch.MAX_INDEXED_CHARS, }; } // A ref is resolved only inside the scopes this principal is authorized for, // so an opaque ref from elsewhere simply does not exist here. // // The whole source is always reassembled and hash-verified before any slicing, // so a bounded read is a window onto verified content, never a shortcut past // verification. A record larger than one response is continued through // nextOffset rather than refused. function readFor(mates, args, includeMate) { var options = args || {}; var ref = options.ref; if (typeof ref !== "string" || !REF_PATTERN.test(ref)) throw new Error("A valid knowledge reference is required."); var offset = parseOffset(options.offset); var maxChars = parseReadSize(options.maxChars); for (var i = 0; i < mates.length; i++) { var state = refresh(mates[i]); var importer = importerForScope(mates[i].scopeId); var live = importer.entries(); for (var j = 0; j < live.length; j++) { if (live[j].ref !== ref) continue; // A record whose source just failed to reconcile is refused rather than // served from the previous, possibly stale, content. if (state.scopeFailed) { throw new Error("This Mate's Knowledge could not be brought up to date, so reads are withheld."); } var failedName = live[j].source && live[j].source.fileName; if (failedName && state.files[failedName]) { throw new Error("The source of this knowledge record could not be brought up to date: " + failedName); } var read = importer.readContent(live[j]); if (!read.complete) { throw new Error("This knowledge record could not be read back completely: " + (read.reason || "unknown")); } var total = read.content.length; if (offset > total) throw new Error("Offset " + offset + " is beyond the end of this record (" + total + " characters)."); var end = safeSliceEnd(read.content, Math.min(total, offset + maxChars)); var slice = read.content.substring(offset, end); var out = summary({ ref: live[j].ref, kind: live[j].kind, name: live[j].name, content: read.content, updatedAt: live[j].updatedAt, revisions: live[j].revisions, relPath: live[j].source ? live[j].source.relPath : null, sourceDate: live[j].source ? live[j].source.sourceDate || null : null, mateId: mates[i].mateId, mateName: mates[i].mateName || null, }, includeMate); delete out.preview; out.content = slice; out.offset = offset; out.totalChars = total; out.nextOffset = end < total ? end : null; // True only when this single response carried the whole record. out.complete = offset === 0 && end >= total; return out; } } throw new Error("Knowledge record not found."); } // --- Binding ---------------------------------------------------------- function scopeForMate(mateDir, mateId, mateName) { var scope = mateSync.scopeForMateDir(mateDir); if (!scope || scope.mateId !== mateId) return null; return { scopeId: scope.scopeId, mateId: mateId, mateName: mateName || null, mateDir: scope.mateDir }; } // Exact live Mate project and session identity, confirmed against the server // Mate registry. Anything that fails to line up yields no capability. function resolve(source) { if (!source || source.isMate !== true || !source.mateId) return null; var project = getProjects().get(source.projectSlug); if (!project) return null; var status = project.getStatus(); if (!status || status.isMate !== true) return null; if (status.mateId !== source.mateId || status.projectOwnerId !== source.projectOwnerId) return null; if (source.session) { var manager = project.getSessionManager ? project.getSessionManager() : project.sm; if (!manager || !manager.sessions || manager.sessions.get(source.session.localId) !== source.session) return null; if (source.projectOwnerId && source.session.ownerId && source.session.ownerId !== source.projectOwnerId) return null; if (isMultiUser() && (!source.projectOwnerId || source.session.ownerId !== source.projectOwnerId)) return null; } if (typeof resolveMate !== "function") return null; var mate = resolveMate(source.projectOwnerId || null, source.mateId); if (!mate || mate.lifecycleDeletion || mate.id !== source.mateId) return null; if (isMultiUser()) { if (!source.projectOwnerId) return null; if (mate.createdBy !== source.projectOwnerId) return null; } if (!status.path) return null; var own = scopeForMate(status.path, source.mateId, mate.name || null); if (!own) return null; var def = mate.builtinKey ? builtinMates.getBuiltinByKey(mate.builtinKey) : null; var isClay = !!(mate.builtinKey === "clay" && def && def.hostAgent === true); return { project: project, own: own, isClay: isClay, ownerId: source.projectOwnerId || null, databaseService: typeof project.getKnowledgeDbService === "function" ? project.getKnowledgeDbService() : null, actor: { type: "user", userId: source.session && source.session.ownerId || source.projectOwnerId || null, displayName: null } }; } // A tool handler captured earlier stays valid only while its exact source // still resolves. Identity is re-derived on every call, so a session that has // since been dropped, a Mate that has been deleted, or a registry entry that // has changed hands loses the capability immediately rather than continuing // to work through a handler that was created while it was still live. function bind(source) { var initial = resolve(source); var generation = Number(source && source.session && source.session._sdkQueryGeneration || 0); if (!initial) return null; function revalidate() { var current = resolve(source); if (initial.isClay) require("./mate-knowledge-canonical-access").assertCanonicalKnowledgeQuery(source, getProjects().get(source.projectSlug), isMultiUser(), generation, context.findUser); if (!current) throw new Error("This Knowledge binding is no longer valid for the current session."); if (current.project !== initial.project || current.isClay !== initial.isClay || current.own.scopeId !== initial.own.scopeId || current.databaseService !== initial.databaseService) { throw new Error("This Knowledge binding is no longer valid for the current session."); } return current; } return initial.isClay ? createClayBound(revalidate, initial) : createMateBound(revalidate, initial); } // An ordinary Mate's own scope, and nothing else. There is no argument on // any tool that could widen it. function createMateBound(revalidate, initial) { return mateBoundModule.createMateBound({ revalidate: revalidate, initial: initial, textTools: textTools, listFor: listFor, searchFor: searchFor, readFor: readFor, }); } // Every Mate belonging to the same user, resolved fresh on each call so a // newly created or deleted Mate is reflected without a rebind. Never spans // users: the Mate list is derived from the bound owner id alone. function createClayBound(revalidate, initial) { function authorizedMates(mateId) { var current = revalidate(); var ownerId = current.ownerId; var own = current.own; var out = []; var listed = listMates(ownerId) || []; for (var i = 0; i < listed.length; i++) { var entry = listed[i]; if (!entry || !entry.id || !entry.dir) continue; var registered = resolveMate(ownerId, entry.id); if (!registered || registered.lifecycleDeletion || isMultiUser() && registered.createdBy !== ownerId) continue; var scope = scopeForMate(entry.dir, entry.id, entry.name || null); if (!scope) continue; out.push(scope); } if (out.length === 0) out.push(own); if (!mateId) return out; var wanted = String(mateId); var filtered = out.filter(function (scope) { return scope.mateId === wanted; }); if (filtered.length === 0) throw new Error("Mate not found for the current user."); return filtered; } return { isClay: true, mateId: initial.own.mateId, scopeId: initial.own.scopeId, listMateKnowledge: function (args) { return listFor(authorizedMates(args && args.mateId), args || {}, true); }, searchMateKnowledge: function (args) { return searchFor(authorizedMates(args && args.mateId), args || {}, true); }, readMateKnowledge: function (args) { return readFor(authorizedMates(null), args || {}, true); }, }; } return { bind: bind }; } module.exports = { MAX_PAGE: MAX_PAGE, MAX_READ_CHARS: MAX_READ_CHARS, DEFAULT_READ_CHARS: DEFAULT_READ_CHARS, REF_PATTERN: REF_PATTERN, NAME_WEIGHT: NAME_WEIGHT, BODY_WEIGHT: BODY_WEIGHT, attachMateKnowledgeService: attachMateKnowledgeService, };