lib/mcp-connection-store.js
Rama main · réplica de lectura del repositorio original.
// Owner-scoped remote records and project-scoped computer preferences with encrypted credentials; never sent to the UI.
var fs = require('fs');
var path = require('path');
var crypto = require('crypto');
var equal = require('util').isDeepStrictEqual;
function createConnectionStore(root, project, options) {
var directory = path.join(root, 'mcp-connections');
var shared = !!(options && options.shared);
function scope(owner) {
if (typeof owner !== 'string' || !owner || owner.length > 160) throw new Error('MCP account owner is required.');
return shared ? JSON.stringify([owner]) : JSON.stringify([project, owner]);
}
function filename(owner) { return path.join(directory, crypto.createHash('sha256').update(scope(owner)).digest('hex') + '.json'); }
function legacyFilename(owner) { return path.join(directory, crypto.createHash('sha256').update(JSON.stringify([project, owner])).digest('hex') + '.json'); }
function key() {
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
var file = path.join(directory, '.key'), bytes;
try { bytes = fs.readFileSync(file); }
catch (error) {
if (error.code !== 'ENOENT') throw error;
var temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
try {
fs.writeFileSync(temp, crypto.randomBytes(32), { flag: 'wx', mode: 0o600 });
try { fs.linkSync(temp, file); } catch (publishError) { if (publishError.code !== 'EEXIST') throw publishError; }
} finally { fs.unlinkSync(temp); }
bytes = fs.readFileSync(file);
}
if (bytes.length !== 32) throw new Error('MCP encryption key is invalid.');
return bytes;
}
function decode(file, aad) {
var data;
try { data = JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { if (error.code === 'ENOENT') return { records: [], revision: null, migratedProjects: [], retired: [] }; throw new Error('Could not read MCP connections.'); }
var parts = data.encrypted.split('.').map(function (part) { return Buffer.from(part, 'base64'); });
var cipher = crypto.createDecipheriv('aes-256-gcm', key(), parts[0]);
cipher.setAAD(Buffer.from(aad)); cipher.setAuthTag(parts[1]);
var value = JSON.parse(Buffer.concat([cipher.update(parts[2]), cipher.final()]).toString('utf8'));
if (Array.isArray(value)) return { records: value, revision: null, migratedProjects: [], retired: [] };
return { records: Array.isArray(value.records) ? value.records : [], revision: value.revision || null, migratedProjects: Array.isArray(value.migratedProjects) ? value.migratedProjects : [], retired: value.retired || [] };
}
function encode(owner, state, expectedRevision) {
var current = decode(filename(owner), scope(owner));
if (expectedRevision !== undefined && current.revision !== expectedRevision) throw new Error('MCP connections changed. Please retry.');
var next = { records: state.records, revision: crypto.randomBytes(16).toString('hex'), migratedProjects: state.migratedProjects || [], retired: state.retired || [] };
var iv = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
cipher.setAAD(Buffer.from(scope(owner)));
var bytes = Buffer.concat([cipher.update(JSON.stringify(next), 'utf8'), cipher.final()]);
var value = { encrypted: [iv, cipher.getAuthTag(), bytes].map(function (part) { return part.toString('base64'); }).join('.') };
var file = filename(owner), temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
try { fs.writeFileSync(temp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(temp, file); }
finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
return next.revision;
}
function visible(records) { if (!shared) return records; return records.filter(function (record) { return record.source !== 'computer' || record.project === project; }); }
function connectionConflict(left, right) { return left.id && left.id === right.id || (left.url && right.url && left.url === right.url); }
function migrate(owner, state) {
if (!shared || state.migratedProjects.indexOf(project) >= 0) return state;
var legacy = decode(legacyFilename(owner), JSON.stringify([project, owner])).records;
var merged = state.records.slice();
legacy.forEach(function (record) {
var copy = Object.assign({}, record);
if (copy.source === 'computer') copy.project = project;
else if ((state.retired || []).some(function (removed) { return connectionConflict(removed, copy); })) return;
var existing = merged.filter(function (current) { return current.source === copy.source && (copy.source === 'computer' ? current.project === project && current.name === copy.name : connectionConflict(current, copy)); });
if (existing.length && existing.some(function (current) { return !equal(current, copy); })) throw new Error('MCP connection migration conflict. Resolve the legacy project connection before retrying.');
if (!existing.length) merged.push(copy);
});
state.records = merged;
state.migratedProjects = state.migratedProjects.concat([project]);
return state;
}
function readUnlocked(owner) {
var state = decode(filename(owner), scope(owner));
if (shared) {
var unique = [];
state.records.forEach(function (record) {
var existing = unique.find(function (current) {
return record.source === 'computer' ? current.source === 'computer' && record.project === current.project && record.name === current.name : current.source !== 'computer' && connectionConflict(record, current);
});
if (existing && !equal(existing, record)) throw new Error('Conflicting duplicate MCP records require resolution.');
if (!existing) unique.push(record);
});
if (unique.length !== state.records.length) { state.records = unique; state.revision = encode(owner, state, state.revision); }
}
if (shared && state.migratedProjects.indexOf(project) < 0) { state = migrate(owner, state); state.revision = encode(owner, state, state.revision); }
return state;
}
// Fail closed on contention. The lock covers migration and the full CAS read/write,
// including callers in a second process using the same storage root.
function locked(owner, operation) {
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
var file = filename(owner) + '.lock', fd;
try { fd = fs.openSync(file, 'wx', 0o600); }
catch (error) { if (error.code === 'EEXIST') throw new Error('MCP connections are busy. Please retry.'); throw error; }
try { fs.writeFileSync(fd, String(process.pid)); return operation(); }
finally { fs.closeSync(fd); fs.unlinkSync(file); }
}
function snapshot(owner) {
return locked(owner, function () {
var state = readUnlocked(owner);
return { records: visible(state.records), revision: state.revision };
});
}
function load(owner) { return snapshot(owner).records; }
function revision(owner) { return snapshot(owner).revision; }
function validate(records) {
if (!Array.isArray(records)) throw new Error('MCP connection snapshot must be an array.');
records.forEach(function (record, index) {
if (!record || typeof record !== 'object') throw new Error('Invalid MCP connection record.');
if (records.slice(0, index).some(function (previous) {
return record.id && previous.id === record.id ||
record.source === 'computer' && previous.source === 'computer' && record.name === previous.name ||
record.source !== 'computer' && previous.source !== 'computer' && record.url && record.url === previous.url;
})) throw new Error('Duplicate MCP connection in snapshot.');
if (shared && record.source === 'computer' && record.project && record.project !== project) throw new Error('Foreign project computer preference.');
});
}
// Remote rows are the complete owner snapshot. Computer rows replace only this
// project's preferences; omitted remote rows are deleted, never merged back.
function save(owner, records, expectedRevision) {
validate(records);
return locked(owner, function () {
var state = readUnlocked(owner);
if (expectedRevision !== undefined && state.revision !== expectedRevision) throw new Error('MCP connections changed. Please retry.');
if (!shared) return encode(owner, { records: records, migratedProjects: [] }, state.revision);
var incomingRemote = records.filter(function (record) { return record.source !== 'computer'; });
var currentComputer = records.filter(function (record) { return record.source === 'computer'; }).map(function (record) { return Object.assign({}, record, { project: project }); });
var otherComputer = state.records.filter(function (record) { return record.source === 'computer' && record.project !== project; });
var removed = state.records.filter(function (record) {
return record.source !== 'computer' && !incomingRemote.some(function (incoming) { return connectionConflict(record, incoming); });
}).map(function (record) { return { id: record.id, url: record.url }; });
state.retired = (state.retired || []).concat(removed).filter(function (record, index, all) {
return all.findIndex(function (other) { return other.id === record.id && other.url === record.url; }) === index;
});
state.records = incomingRemote.concat(otherComputer, currentComputer);
return encode(owner, state, state.revision);
});
}
return { load: load, save: save, revision: revision, snapshot: snapshot, namespace: fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root) };
}
module.exports = { createConnectionStore: createConnectionStore };