feat: Token 支持多项目关联且全部为 MCP 令牌;配置操作全量审计,支持按项目/键查询
build-and-deploy / build-and-deploy (push) Successful in 40s
build-and-deploy / build-and-deploy (push) Successful in 40s
This commit is contained in:
+69
-39
@@ -117,7 +117,7 @@
|
||||
<table class="data-table" id="token-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th><th>关联项目</th><th>权限</th><th>MCP 访问</th><th>Token</th>
|
||||
<th>名称</th><th>关联项目</th><th>权限</th><th>Token</th>
|
||||
<th>状态</th><th>过期时间</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -211,8 +211,13 @@
|
||||
<input type="text" id="input-token-name" placeholder="例如:CI 自动发布">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>关联项目</label>
|
||||
<select id="input-token-project"><option value="all">全部项目</option></select>
|
||||
<label>关联项目 <span class="hint">(可勾选全部或多个已存在项目)</span></label>
|
||||
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:6px">
|
||||
<input type="checkbox" id="input-token-all" style="width:16px;height:16px">
|
||||
<span>全部项目</span>
|
||||
</label>
|
||||
<div id="token-project-list" style="display:flex;flex-direction:column;gap:6px;max-height:160px;overflow:auto">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half">
|
||||
@@ -229,13 +234,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="input-token-mcp" style="width:16px;height:16px">
|
||||
<span>作为 MCP 访问令牌(用于 /mcp 端点鉴权)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">生成后 Token 明文仅展示一次,请及时复制并妥善保管。</p>
|
||||
<p class="hint">所有 Token 均可作为 MCP 访问令牌用于 /mcp 端点鉴权。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-token')">取消</button>
|
||||
@@ -480,19 +479,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
var tokenProjects = [];
|
||||
|
||||
async function loadProjectsForSelect() {
|
||||
try {
|
||||
var projects = await API.getProjects();
|
||||
var sel = document.getElementById("input-token-project");
|
||||
sel.innerHTML = '<option value="all">全部项目</option>' +
|
||||
projects.map(function (p) { return '<option value="' + p.id + '">' + esc(p.name) + "</option>"; }).join("");
|
||||
tokenProjects = await API.getProjects();
|
||||
var list = document.getElementById("token-project-list");
|
||||
list.innerHTML = tokenProjects.map(function (p) {
|
||||
return '<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
|
||||
'<input type="checkbox" class="token-proj-check" value="' + p.id + '" style="width:14px;height:14px"> ' +
|
||||
'<span>' + esc(p.name) + "</span></label>";
|
||||
}).join("");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function getSelectedProjectIds() {
|
||||
if (document.getElementById("input-token-all").checked) return ["all"];
|
||||
var ids = [];
|
||||
document.querySelectorAll(".token-proj-check:checked").forEach(function (c) { ids.push(c.value); });
|
||||
return ids.length ? ids : ["all"];
|
||||
}
|
||||
|
||||
function setSelectedProjectIds(ids) {
|
||||
var allCheck = document.getElementById("input-token-all");
|
||||
if (!ids || !ids.length || ids.indexOf("all") !== -1) {
|
||||
allCheck.checked = true;
|
||||
document.querySelectorAll(".token-proj-check").forEach(function (c) { c.checked = false; c.disabled = true; });
|
||||
} else {
|
||||
allCheck.checked = false;
|
||||
document.querySelectorAll(".token-proj-check").forEach(function (c) {
|
||||
c.checked = ids.indexOf(c.value) !== -1;
|
||||
c.disabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("input-token-all").addEventListener("change", function () {
|
||||
var checked = this.checked;
|
||||
document.querySelectorAll(".token-proj-check").forEach(function (c) {
|
||||
c.checked = checked ? false : c.checked;
|
||||
c.disabled = checked;
|
||||
});
|
||||
});
|
||||
|
||||
function projectNames(projectIds) {
|
||||
if (!projectIds || !projectIds.length || projectIds.indexOf("all") !== -1) return "全部项目";
|
||||
return projectIds.map(function (id) {
|
||||
var p = tokenProjects.find(function (x) { return x.id === id; });
|
||||
return p ? p.name : id;
|
||||
}).join("、");
|
||||
}
|
||||
|
||||
function renderTokens(tokens) {
|
||||
var tbody = document.getElementById("token-tbody");
|
||||
if (!tokens.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = tokens.map(function (t) {
|
||||
@@ -503,11 +544,8 @@
|
||||
var masked = t.value.substring(0, 6) + "••••••••••••";
|
||||
return "<tr>" +
|
||||
"<td>" + esc(t.name) + "</td>" +
|
||||
"<td>" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "</td>" +
|
||||
"<td>" + esc(projectNames(t.projectIds)) + "</td>" +
|
||||
'<td><span class="dim-chip">' + (t.permission === "readwrite" ? "读写" : "只读") + "</span></td>" +
|
||||
'<td>' + (t.mcpEnabled
|
||||
? '<span class="dim-chip" style="color:#0a7a3c;border-color:#0a7a3c">MCP</span>'
|
||||
: '<span style="color:#aaa">—</span>') + "</td>" +
|
||||
'<td><div class="token-reveal">' +
|
||||
'<span class="token-value token-mask" data-id="' + t.id + '">' + masked + "</span>" +
|
||||
'<button class="token-btn" onclick="toggleTokenVis(this)">显示</button>' +
|
||||
@@ -517,7 +555,7 @@
|
||||
"<td>" + fmtDate(t.expiresAt) + "</td>" +
|
||||
"<td>" + fmtDate(t.createdAt) + "</td>" +
|
||||
'<td style="white-space:nowrap">' +
|
||||
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectId: t.projectId, permission: t.permission, mcpEnabled: !!t.mcpEnabled }).replace(/'/g, "'") + ")'>编辑</button> " +
|
||||
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectIds: t.projectIds || ["all"], permission: t.permission }).replace(/'/g, "'") + ")'>编辑</button> " +
|
||||
(t.enabled
|
||||
? '<button class="row-btn danger" onclick="revokeToken(\'' + t.id + "')\">吊销</button>"
|
||||
: "") +
|
||||
@@ -565,12 +603,10 @@
|
||||
document.getElementById("modal-token-title").textContent = "生成 Token";
|
||||
document.getElementById("edit-token-id").value = "";
|
||||
document.getElementById("input-token-name").value = "";
|
||||
document.getElementById("input-token-project").value = "all";
|
||||
document.getElementById("input-token-permission").value = "readonly";
|
||||
document.getElementById("input-token-expiry").value = "90";
|
||||
document.getElementById("input-token-mcp").checked = false;
|
||||
document.getElementById("btn-save-token").textContent = "生成";
|
||||
loadProjectsForSelect();
|
||||
loadProjectsForSelect().then(function () { setSelectedProjectIds(["all"]); });
|
||||
openModal("modal-token");
|
||||
});
|
||||
|
||||
@@ -578,11 +614,10 @@
|
||||
document.getElementById("modal-token-title").textContent = "编辑 Token";
|
||||
document.getElementById("edit-token-id").value = t.id;
|
||||
document.getElementById("input-token-name").value = t.name;
|
||||
document.getElementById("input-token-project").value = t.projectId;
|
||||
document.getElementById("input-token-permission").value = t.permission;
|
||||
document.getElementById("input-token-mcp").checked = !!(t.mcpEnabled);
|
||||
document.getElementById("btn-save-token").textContent = "保存";
|
||||
loadProjectsForSelect();
|
||||
var ids = t.projectIds;
|
||||
loadProjectsForSelect().then(function () { setSelectedProjectIds(ids); });
|
||||
openModal("modal-token");
|
||||
};
|
||||
|
||||
@@ -590,25 +625,20 @@
|
||||
var id = document.getElementById("edit-token-id").value;
|
||||
var name = document.getElementById("input-token-name").value.trim();
|
||||
if (!name) { toast("请输入 Token 名称", "error"); return; }
|
||||
var payload = {
|
||||
name: name,
|
||||
projectIds: getSelectedProjectIds(),
|
||||
permission: document.getElementById("input-token-permission").value,
|
||||
};
|
||||
try {
|
||||
if (id) {
|
||||
await API.updateToken(id, {
|
||||
name: name,
|
||||
projectId: document.getElementById("input-token-project").value,
|
||||
permission: document.getElementById("input-token-permission").value,
|
||||
mcpEnabled: document.getElementById("input-token-mcp").checked,
|
||||
});
|
||||
await API.updateToken(id, payload);
|
||||
closeModal("modal-token");
|
||||
toast("Token 已更新", "success");
|
||||
loadTokens();
|
||||
} else {
|
||||
var data = await API.createToken({
|
||||
name: name,
|
||||
projectId: document.getElementById("input-token-project").value,
|
||||
permission: document.getElementById("input-token-permission").value,
|
||||
mcpEnabled: document.getElementById("input-token-mcp").checked,
|
||||
expiresInDays: parseInt(document.getElementById("input-token-expiry").value) || 0,
|
||||
});
|
||||
payload.expiresInDays = parseInt(document.getElementById("input-token-expiry").value) || 0;
|
||||
var data = await API.createToken(payload);
|
||||
closeModal("modal-token");
|
||||
document.getElementById("reveal-token-value").textContent = data.value;
|
||||
openModal("modal-token-reveal");
|
||||
|
||||
+28
-3
@@ -45,8 +45,9 @@
|
||||
|
||||
<div class="tool-group" title="数据操作">
|
||||
<span class="group-label">数据</span>
|
||||
<button id="btn-export-config" class="toolbar-btn" title="导出配置">📥 导出</button>
|
||||
<button id="btn-import-config" class="toolbar-btn" title="导入配置">📤 导入</button>
|
||||
<button id="btn-export-config" class="toolbar-btn" title="导出配置">📥 导出</button>
|
||||
<button id="btn-import-config" class="toolbar-btn" title="导入配置">📤 导入</button>
|
||||
<button id="btn-audit" class="toolbar-btn" title="查看当前项目操作记录">📜 操作记录</button>
|
||||
<input type="file" id="import-file-input" accept=".json" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +304,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:确认删除 -->
|
||||
<div class="modal-overlay" id="modal-audit" style="display:none">
|
||||
<div class="modal" style="max-width:920px">
|
||||
<div class="modal-header">
|
||||
<h3>操作记录 <span id="audit-title" style="font-weight:normal;font-size:12px;color:#999"></span></h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-audit')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="overflow:auto;max-height:480px">
|
||||
<table class="config-table" id="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th><th>方式</th><th>操作者</th><th>IP</th><th>操作</th><th>键</th><th>值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="audit-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-audit')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:确认删除 -->
|
||||
<div class="modal-overlay" id="modal-confirm" style="display:none">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -93,6 +93,11 @@ const API = {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
getAudit(projectId, key) {
|
||||
const qs = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
return this.request(`/api/projects/${projectId}/configs/audit${qs}`);
|
||||
},
|
||||
|
||||
// ========== 模式 ==========
|
||||
|
||||
getMode() {
|
||||
|
||||
+102
-26
@@ -274,10 +274,11 @@ function renderConfigTable(configs, path) {
|
||||
<td><span class="type-badge">${escapeHtml(cfg.type)}</span></td>
|
||||
<td class="config-status-cell">${statusHtml.join("")}</td>
|
||||
<td>${escapeHtml(cfg.description)}</td>
|
||||
<td>
|
||||
<button class="row-btn view" data-action="view" data-id="${cfg.id}">查看</button>
|
||||
<button class="row-btn" data-action="edit" data-id="${cfg.id}">编辑</button>
|
||||
<button class="row-btn danger" data-action="delete" data-id="${cfg.id}">删除</button>
|
||||
<td>
|
||||
<button class="row-btn view" data-action="view" data-id="${cfg.id}">查看</button>
|
||||
<button class="row-btn" data-action="edit" data-id="${cfg.id}">编辑</button>
|
||||
<button class="row-btn" data-action="delete" data-id="${cfg.id}">删除</button>
|
||||
<button class="row-btn" data-action="audit" data-id="${cfg.id}">记录</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
@@ -670,9 +671,79 @@ async function copyAddress() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件监听
|
||||
* @returns {void}
|
||||
/**
|
||||
* 打开操作记录弹窗
|
||||
* @param {string|null} key - 为空时查当前项目全部记录;否则查指定配置键的记录
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function openAudit(key) {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
document.getElementById("audit-title").textContent = key
|
||||
? `(${state.currentProject.name} · ${key})`
|
||||
: `(${state.currentProject.name})`;
|
||||
openModal("modal-audit");
|
||||
try {
|
||||
const logs = await API.getAudit(state.currentProject.id, key || "");
|
||||
renderAudit(logs);
|
||||
} catch (err) {
|
||||
toast("加载操作记录失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染操作记录表格
|
||||
* @param {Array} logs - 操作记录列表
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderAudit(logs) {
|
||||
const tbody = document.getElementById("audit-tbody");
|
||||
if (!logs || !logs.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:24px">暂无操作记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
const ACTION_NAMES = { read: "读取", create: "新增", update: "更新", delete: "删除" };
|
||||
const SOURCE_NAMES = { web: "网页", mcp: "MCP" };
|
||||
tbody.innerHTML = logs
|
||||
.map((l) => {
|
||||
const act = ACTION_NAMES[l.action] || l.action;
|
||||
const src = SOURCE_NAMES[l.source] || l.source;
|
||||
const val = l.value !== undefined && l.value !== null && l.value !== "" ? String(l.value) : "—";
|
||||
return (
|
||||
"<tr>" +
|
||||
`<td>${escapeHtml(fmtTime(l.createdAt))}</td>` +
|
||||
`<td>${escapeHtml(src)}</td>` +
|
||||
`<td>${escapeHtml(l.actor || "")}</td>` +
|
||||
`<td>${l.ip ? escapeHtml(l.ip) : "—"}</td>` +
|
||||
`<td>${escapeHtml(act)}</td>` +
|
||||
`<td>${escapeHtml(l.key || "")}</td>` +
|
||||
`<td class="config-value-cell">${escapeHtml(val.length > 80 ? val.slice(0, 80) + "…" : val)}</td>` +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间:YYYY-MM-DD HH:mm:ss
|
||||
* @param {string} iso - ISO 时间
|
||||
* @returns {string}
|
||||
*/
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => (n < 10 ? "0" + n : n);
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件监听
|
||||
* @returns {void}
|
||||
*/
|
||||
function bindEvents() {
|
||||
// 项目选择
|
||||
@@ -784,17 +855,20 @@ function bindEvents() {
|
||||
if (e.key === "Enter") navigateToPath();
|
||||
});
|
||||
|
||||
// 导出/导入
|
||||
document.getElementById("btn-export-config").addEventListener("click", exportConfig);
|
||||
document.getElementById("btn-import-config").addEventListener("click", () => {
|
||||
document.getElementById("import-file-input").click();
|
||||
});
|
||||
document.getElementById("import-file-input").addEventListener("change", (e) => {
|
||||
if (e.target.files[0]) {
|
||||
importConfig(e.target.files[0]);
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
// 导出/导入
|
||||
document.getElementById("btn-export-config").addEventListener("click", exportConfig);
|
||||
document.getElementById("btn-import-config").addEventListener("click", () => {
|
||||
document.getElementById("import-file-input").click();
|
||||
});
|
||||
document.getElementById("import-file-input").addEventListener("change", (e) => {
|
||||
if (e.target.files[0]) {
|
||||
importConfig(e.target.files[0]);
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 操作记录
|
||||
document.getElementById("btn-audit").addEventListener("click", () => openAudit(null));
|
||||
|
||||
// 表格操作(事件委托)
|
||||
document.getElementById("config-tbody").addEventListener("click", (e) => {
|
||||
@@ -805,14 +879,16 @@ function bindEvents() {
|
||||
const config = state.configs.find((c) => c.id === id);
|
||||
if (!config) return;
|
||||
|
||||
if (action === "view") {
|
||||
openConfigModal(config, true);
|
||||
} else if (action === "edit") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许编辑", "error");
|
||||
return;
|
||||
}
|
||||
openConfigModal(config, false);
|
||||
if (action === "view") {
|
||||
openConfigModal(config, true);
|
||||
} else if (action === "edit") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许编辑", "error");
|
||||
return;
|
||||
}
|
||||
openConfigModal(config, false);
|
||||
} else if (action === "audit") {
|
||||
openAudit(config.key);
|
||||
} else if (action === "delete") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许删除", "error");
|
||||
|
||||
+25
-28
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* MCP HTTP 传输层
|
||||
* 以 Streamable HTTP 协议在 /mcp 端点暴露 MCP 服务。
|
||||
* 鉴权:基于后台 Token 管理中标记为「MCP 访问」的令牌,要求请求头 Authorization: Bearer <token>。
|
||||
* 鉴权:使用后台 Token 管理中任一生效令牌,要求请求头 Authorization: Bearer <token>。
|
||||
*/
|
||||
const crypto = require("crypto");
|
||||
const express = require("express");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const {
|
||||
@@ -11,50 +10,39 @@ const {
|
||||
} = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
||||
const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js");
|
||||
const { createMcpServer } = require("./tools");
|
||||
const { getMcpTokenValues } = require("../services/tokenService");
|
||||
const { resolveActiveToken } = require("../services/tokenService");
|
||||
const { getClientIp } = require("../services/auditService");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// sessionId -> transport,用于会话复用
|
||||
const transports = new Map();
|
||||
|
||||
// sessionId -> ctx(含会话 Token 与请求来源信息,工具审计使用)
|
||||
const sessionContexts = new Map();
|
||||
|
||||
/**
|
||||
* 校验请求携带的 Bearer token 是否命中任意一个生效的 MCP 访问令牌
|
||||
* 校验请求携带的 Bearer token 是否为生效 Token
|
||||
* 所有后台 Token 均视为 MCP 访问令牌
|
||||
* @param {string} [header] - Authorization 请求头
|
||||
* @returns {Promise<boolean>} 是否鉴权通过
|
||||
* @returns {Promise<Object|null>} 校验通过返回 Token 对象,否则 null
|
||||
*/
|
||||
async function isAuthorized(header) {
|
||||
async function resolveAuthorizedToken(header) {
|
||||
const provided = header && header.startsWith("Bearer ") ? header.slice(7).trim() : "";
|
||||
const expectedList = await getMcpTokenValues();
|
||||
// 未配置 MCP 令牌时视为未启用鉴权
|
||||
if (expectedList.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!provided) {
|
||||
return false;
|
||||
}
|
||||
// 与任一生效令牌做常量时间比较,避免时序侧信道
|
||||
for (const expected of expectedList) {
|
||||
if (provided.length === expected.length) {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
if (crypto.timingSafeEqual(a, b)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return resolveActiveToken(provided);
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权中间件:token 校验失败返回 401
|
||||
* 鉴权中间件:token 校验失败返回 401,通过时在 req 上挂载 mcpToken
|
||||
*/
|
||||
router.use(async (req, res, next) => {
|
||||
if (!(await isAuthorized(req.headers.authorization))) {
|
||||
const token = await resolveAuthorizedToken(req.headers.authorization);
|
||||
if (!token) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized: invalid or missing token" }, id: null });
|
||||
}
|
||||
req.mcpToken = token;
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -70,19 +58,28 @@ async function handleRequest(req, res) {
|
||||
let transport;
|
||||
if (sessionId && transports.get(sessionId)) {
|
||||
transport = transports.get(sessionId);
|
||||
// 刷新会话上下文:Token 与 IP 以当前请求为准
|
||||
const ctx = sessionContexts.get(sessionId);
|
||||
if (ctx) {
|
||||
ctx.token = req.mcpToken;
|
||||
ctx.ip = getClientIp(req);
|
||||
}
|
||||
} else if (!sessionId && isInitializeRequest(jsonBody)) {
|
||||
const ctx = { token: req.mcpToken, ip: getClientIp(req), source: "mcp" };
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sid) => {
|
||||
transports.set(sid, transport);
|
||||
sessionContexts.set(sid, ctx);
|
||||
},
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
transports.delete(transport.sessionId);
|
||||
sessionContexts.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
const server = createMcpServer();
|
||||
const server = createMcpServer(ctx);
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
|
||||
+60
-1
@@ -12,6 +12,8 @@ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
||||
const projectService = require("../services/projectService");
|
||||
const configService = require("../services/configService");
|
||||
const { loadSystem, loadMcpConfig } = require("../persistence/duckdbAdapter");
|
||||
const { coversProject } = require("../services/tokenService");
|
||||
const { log: auditLog } = require("../services/auditService");
|
||||
|
||||
// 待确认删除的登记表:token -> { type, projectId, configId, expiresAt }
|
||||
const pendingDeletions = new Map();
|
||||
@@ -94,6 +96,32 @@ function errText(err) {
|
||||
return err && err.message ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前会话 Token 是否有权限访问指定项目
|
||||
* @param {Object} ctx - 会话上下文(含 token)
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @throws {Error} 无权访问时抛出
|
||||
*/
|
||||
function assertProjectAccess(ctx, projectId) {
|
||||
if (!coversProject(ctx && ctx.token, projectId)) {
|
||||
throw new Error("无权访问该项目");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次 MCP 配置操作
|
||||
* @param {Object} ctx - 会话上下文
|
||||
* @param {Object} entry - 记录内容 { projectId, key, action, value, detail }
|
||||
*/
|
||||
function mcpAudit(ctx, entry) {
|
||||
auditLog({
|
||||
source: "mcp",
|
||||
actor: (ctx && ctx.token && ctx.token.name) || "anonymous",
|
||||
ip: (ctx && ctx.ip) || "",
|
||||
...entry,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建带删除确认提示的文本结果
|
||||
* @param {string} summary - 结果概要
|
||||
@@ -121,9 +149,11 @@ function confirmResult(summary, confirmTool, token, extra) {
|
||||
|
||||
/**
|
||||
* 创建并返回 MCP 服务器实例(仅定义工具,不绑定传输层)
|
||||
* @param {Object} [ctx] - 会话上下文 { token, ip, source }
|
||||
* @returns {McpServer} 已注册全部工具的服务实例
|
||||
*/
|
||||
function createMcpServer() {
|
||||
function createMcpServer(ctx) {
|
||||
ctx = ctx || {};
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "registry-config-center",
|
||||
@@ -335,7 +365,11 @@ function createMcpServer() {
|
||||
},
|
||||
},
|
||||
async ({ projectId }) => {
|
||||
assertProjectAccess(ctx, projectId);
|
||||
const configs = await configService.getConfigsByProject(projectId);
|
||||
for (const c of configs) {
|
||||
mcpAudit(ctx, { projectId, key: c.key, action: "read", value: c.value });
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(configs, null, 2) }],
|
||||
structuredContent: { configs },
|
||||
@@ -353,10 +387,12 @@ function createMcpServer() {
|
||||
},
|
||||
},
|
||||
async ({ projectId, configId }) => {
|
||||
assertProjectAccess(ctx, projectId);
|
||||
const config = await configService.getConfigById(projectId, configId);
|
||||
if (!config) {
|
||||
throw new Error(`配置项不存在:${configId}`);
|
||||
}
|
||||
mcpAudit(ctx, { projectId, key: config.key, action: "read", value: config.value });
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
|
||||
structuredContent: { config },
|
||||
@@ -388,8 +424,10 @@ function createMcpServer() {
|
||||
async (args) => {
|
||||
await assertWritable();
|
||||
const { projectId, ...data } = args;
|
||||
assertProjectAccess(ctx, projectId);
|
||||
const mode = await currentMode();
|
||||
const config = await configService.createConfig(projectId, data, mode);
|
||||
mcpAudit(ctx, { projectId, key: config.key, action: "create", value: config.value });
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
|
||||
structuredContent: { config },
|
||||
@@ -422,6 +460,8 @@ function createMcpServer() {
|
||||
async (args) => {
|
||||
await assertWritable();
|
||||
const { projectId, configId, ...data } = args;
|
||||
assertProjectAccess(ctx, projectId);
|
||||
const oldConfig = await configService.getConfigById(projectId, configId);
|
||||
const picks = {};
|
||||
for (const field of [
|
||||
"key", "value", "type", "description", "defaultValue", "valueRange",
|
||||
@@ -433,6 +473,16 @@ function createMcpServer() {
|
||||
}
|
||||
const mode = await currentMode();
|
||||
const config = await configService.updateConfig(projectId, configId, picks, mode);
|
||||
mcpAudit(ctx, {
|
||||
projectId,
|
||||
key: config.key,
|
||||
action: "update",
|
||||
value: config.value,
|
||||
detail: {
|
||||
from: oldConfig ? { key: oldConfig.key, value: oldConfig.value } : null,
|
||||
to: { key: config.key, value: config.value },
|
||||
},
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
|
||||
structuredContent: { config },
|
||||
@@ -452,6 +502,7 @@ function createMcpServer() {
|
||||
},
|
||||
async ({ projectId, configId }) => {
|
||||
await assertWritable();
|
||||
assertProjectAccess(ctx, projectId);
|
||||
const config = await configService.getConfigById(projectId, configId);
|
||||
if (!config) {
|
||||
throw new Error(`配置项不存在:${configId}`);
|
||||
@@ -493,7 +544,9 @@ function createMcpServer() {
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
assertProjectAccess(ctx, entry.projectId);
|
||||
const mode = await currentMode();
|
||||
const doomed = await configService.getConfigById(entry.projectId, entry.configId);
|
||||
const ok = await configService.deleteConfig(entry.projectId, entry.configId, mode);
|
||||
if (!ok) {
|
||||
return {
|
||||
@@ -502,6 +555,12 @@ function createMcpServer() {
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
mcpAudit(ctx, {
|
||||
projectId: entry.projectId,
|
||||
key: (doomed && doomed.key) || "",
|
||||
action: "delete",
|
||||
value: (doomed && doomed.value) || "",
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `配置项已删除:${entry.configId}` }],
|
||||
structuredContent: { status: "deleted", projectId: entry.projectId, configId: entry.configId },
|
||||
|
||||
@@ -63,6 +63,18 @@ async function createTables() {
|
||||
key VARCHAR PRIMARY KEY,
|
||||
data JSON NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
project_id VARCHAR NOT NULL,
|
||||
config_key VARCHAR NOT NULL DEFAULT '',
|
||||
action VARCHAR NOT NULL,
|
||||
source VARCHAR NOT NULL,
|
||||
actor VARCHAR NOT NULL DEFAULT '',
|
||||
ip VARCHAR NOT NULL DEFAULT '',
|
||||
value_text VARCHAR NOT NULL DEFAULT '',
|
||||
detail JSON,
|
||||
created_at VARCHAR NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -287,6 +299,58 @@ async function saveApiTokens(tokens) {
|
||||
await insertRows("api_tokens", tokens);
|
||||
}
|
||||
|
||||
// ==================== audit logs ====================
|
||||
|
||||
async function appendAuditLog(entry) {
|
||||
await init();
|
||||
await connection.run(
|
||||
`INSERT INTO audit_logs (id, project_id, config_key, action, source, actor, ip, value_text, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.id,
|
||||
entry.projectId || "",
|
||||
entry.key || "",
|
||||
entry.action,
|
||||
entry.source,
|
||||
entry.actor || "",
|
||||
entry.ip || "",
|
||||
entry.value || "",
|
||||
entry.detail ? JSON.stringify(entry.detail) : null,
|
||||
entry.createdAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询操作记录
|
||||
* @param {Object} filter - { projectId, key }
|
||||
* @returns {Promise<Array>} 操作记录,按时间倒序
|
||||
*/
|
||||
async function loadAuditLogs(filter = {}) {
|
||||
await init();
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.projectId) {
|
||||
where.push("project_id = ?");
|
||||
params.push(filter.projectId);
|
||||
}
|
||||
if (filter.key) {
|
||||
where.push("config_key = ?");
|
||||
params.push(filter.key);
|
||||
}
|
||||
const sql =
|
||||
`SELECT id, project_id AS projectId, config_key AS key, action, source, actor, ip,
|
||||
value_text AS value, detail, created_at AS createdAt
|
||||
FROM audit_logs` +
|
||||
(where.length ? ` WHERE ${where.join(" AND ")}` : "") +
|
||||
` ORDER BY created_at DESC, id DESC`;
|
||||
const reader = await connection.runAndReadAll(sql, params);
|
||||
return reader.getRowObjects().map((r) => ({
|
||||
...r,
|
||||
detail: r.detail ? parseData(r.detail) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
loadProjects,
|
||||
@@ -302,4 +366,6 @@ module.exports = {
|
||||
saveUsers,
|
||||
loadApiTokens,
|
||||
saveApiTokens,
|
||||
appendAuditLog,
|
||||
loadAuditLogs,
|
||||
};
|
||||
+140
-78
@@ -2,10 +2,18 @@
|
||||
* 配置路由
|
||||
* 处理配置项相关 API 请求
|
||||
*/
|
||||
const express = require("express");
|
||||
const router = express.Router({ mergeParams: true });
|
||||
const configService = require("../services/configService");
|
||||
const { loadSystem, saveSystem } = require("../persistence/duckdbAdapter");
|
||||
const express = require("express");
|
||||
const router = express.Router({ mergeParams: true });
|
||||
const configService = require("../services/configService");
|
||||
const { loadSystem, saveSystem, loadAuditLogs } = require("../persistence/duckdbAdapter");
|
||||
const { log: auditLog, getClientIp } = require("../services/auditService");
|
||||
|
||||
/**
|
||||
* 当前访问者信息(Web:记录用户名;未登录则 anonymous)
|
||||
*/
|
||||
function webAuditContext(req) {
|
||||
return { source: "web", actor: (req.user && req.user.username) || "anonymous", ip: getClientIp(req) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前模式
|
||||
@@ -18,80 +26,134 @@ async function getCurrentMode() {
|
||||
} catch {
|
||||
return "edit";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/projects/:projectId/configs
|
||||
* 获取项目下所有配置项
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const configs = await configService.getConfigsByProject(req.params.projectId);
|
||||
res.json({ success: true, data: configs });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/projects/:projectId/configs/:configId
|
||||
* 获取单个配置项
|
||||
*/
|
||||
router.get("/:configId", async (req, res) => {
|
||||
try {
|
||||
const config = await configService.getConfigById(req.params.projectId, req.params.configId);
|
||||
if (!config) {
|
||||
return res.status(404).json({ success: false, message: "配置项不存在" });
|
||||
}
|
||||
res.json({ success: true, data: config });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/projects/:projectId/configs
|
||||
* 新增配置项
|
||||
*/
|
||||
router.post("/", async (req, res) => {
|
||||
try {
|
||||
const mode = await getCurrentMode();
|
||||
const config = await configService.createConfig(req.params.projectId, req.body, mode);
|
||||
res.status(201).json({ success: true, data: config });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/projects/:projectId/configs/:configId
|
||||
* 修改配置项
|
||||
*/
|
||||
router.put("/:configId", async (req, res) => {
|
||||
try {
|
||||
const mode = await getCurrentMode();
|
||||
const config = await configService.updateConfig(req.params.projectId, req.params.configId, req.body, mode);
|
||||
res.json({ success: true, data: config });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/projects/:projectId/configs/:configId
|
||||
* 删除配置项
|
||||
*/
|
||||
router.delete("/:configId", async (req, res) => {
|
||||
try {
|
||||
const mode = await getCurrentMode();
|
||||
const result = await configService.deleteConfig(req.params.projectId, req.params.configId, mode);
|
||||
if (!result) {
|
||||
return res.status(404).json({ success: false, message: "配置项不存在" });
|
||||
}
|
||||
res.json({ success: true, message: "配置项已删除" });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/projects/:projectId/configs/audit?key=xxx
|
||||
* 查询当前项目(或指定配置键)的操作记录
|
||||
*/
|
||||
router.get("/audit", async (req, res) => {
|
||||
try {
|
||||
const logs = await loadAuditLogs({
|
||||
projectId: req.params.projectId,
|
||||
key: req.query.key || "",
|
||||
});
|
||||
res.json({ success: true, data: logs });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/projects/:projectId/configs
|
||||
* 获取项目下所有配置项
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const configs = await configService.getConfigsByProject(req.params.projectId);
|
||||
const ctx = webAuditContext(req);
|
||||
for (const c of configs) {
|
||||
auditLog({ ...ctx, projectId: req.params.projectId, key: c.key, action: "read", value: c.value });
|
||||
}
|
||||
res.json({ success: true, data: configs });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/projects/:projectId/configs/:configId
|
||||
* 获取单个配置项
|
||||
*/
|
||||
router.get("/:configId", async (req, res) => {
|
||||
try {
|
||||
const config = await configService.getConfigById(req.params.projectId, req.params.configId);
|
||||
if (!config) {
|
||||
return res.status(404).json({ success: false, message: "配置项不存在" });
|
||||
}
|
||||
auditLog({
|
||||
...webAuditContext(req),
|
||||
projectId: req.params.projectId,
|
||||
key: config.key,
|
||||
action: "read",
|
||||
value: config.value,
|
||||
});
|
||||
res.json({ success: true, data: config });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/projects/:projectId/configs
|
||||
* 新增配置项
|
||||
*/
|
||||
router.post("/", async (req, res) => {
|
||||
try {
|
||||
const mode = await getCurrentMode();
|
||||
const config = await configService.createConfig(req.params.projectId, req.body, mode);
|
||||
auditLog({
|
||||
...webAuditContext(req),
|
||||
projectId: req.params.projectId,
|
||||
key: config.key,
|
||||
action: "create",
|
||||
value: config.value,
|
||||
});
|
||||
res.status(201).json({ success: true, data: config });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/projects/:projectId/configs/:configId
|
||||
* 修改配置项
|
||||
*/
|
||||
router.put("/:configId", async (req, res) => {
|
||||
try {
|
||||
const mode = await getCurrentMode();
|
||||
const oldConfig = await configService.getConfigById(req.params.projectId, req.params.configId);
|
||||
const config = await configService.updateConfig(req.params.projectId, req.params.configId, req.body, mode);
|
||||
auditLog({
|
||||
...webAuditContext(req),
|
||||
projectId: req.params.projectId,
|
||||
key: config.key,
|
||||
action: "update",
|
||||
value: config.value,
|
||||
detail: {
|
||||
from: oldConfig ? { key: oldConfig.key, value: oldConfig.value } : null,
|
||||
to: { key: config.key, value: config.value },
|
||||
},
|
||||
});
|
||||
res.json({ success: true, data: config });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/projects/:projectId/configs/:configId
|
||||
* 删除配置项
|
||||
*/
|
||||
router.delete("/:configId", async (req, res) => {
|
||||
try {
|
||||
const mode = await getCurrentMode();
|
||||
const oldConfig = await configService.getConfigById(req.params.projectId, req.params.configId);
|
||||
const result = await configService.deleteConfig(req.params.projectId, req.params.configId, mode);
|
||||
if (!result) {
|
||||
return res.status(404).json({ success: false, message: "配置项不存在" });
|
||||
}
|
||||
auditLog({
|
||||
...webAuditContext(req),
|
||||
projectId: req.params.projectId,
|
||||
key: oldConfig ? oldConfig.key : "",
|
||||
action: "delete",
|
||||
value: oldConfig ? oldConfig.value : "",
|
||||
});
|
||||
res.json({ success: true, message: "配置项已删除" });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 操作记录(审计)服务
|
||||
* 记录所有对配置的访问:读取/新增/更新/删除
|
||||
* 访问方式:mcp(记录 Token 名)/ web(记录用户名),含 IP(支持 nginx 转发)
|
||||
* 记录写入失败时静默降级,不阻塞主业务操作
|
||||
*/
|
||||
const { appendAuditLog } = require("../persistence/duckdbAdapter");
|
||||
|
||||
/**
|
||||
* 生成唯一记录 ID
|
||||
* @returns {string} 记录 ID
|
||||
*/
|
||||
function generateId() {
|
||||
return "aud-" + Date.now().toString(36) + "-" + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取客户端 IP(支持 nginx 等代理通过 X-Forwarded-For 转发)
|
||||
* @param {Object} req - Express 请求对象
|
||||
* @returns {string} 客户端 IP
|
||||
*/
|
||||
function getClientIp(req) {
|
||||
if (!req) return "";
|
||||
const forwarded = req.headers && req.headers["x-forwarded-for"];
|
||||
if (forwarded) {
|
||||
return String(forwarded).split(",")[0].trim();
|
||||
}
|
||||
return (req.socket && req.socket.remoteAddress) || req.ip || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次配置访问
|
||||
* @param {Object} entry - 记录内容
|
||||
* @param {string} entry.source - 访问方式:mcp / web
|
||||
* @param {string} entry.actor - 操作者(mcp 记录 Token 名,web 记录用户名)
|
||||
* @param {string} entry.ip - 客户端 IP
|
||||
* @param {string} entry.projectId - 项目 ID
|
||||
* @param {string} entry.key - 配置键
|
||||
* @param {string} entry.action - read / create / update / delete
|
||||
* @param {*} entry.value - 操作值
|
||||
* @param {Object} [entry.detail] - 附加信息(如变更前后对比)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function log(entry) {
|
||||
try {
|
||||
await appendAuditLog({
|
||||
id: generateId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
projectId: entry.projectId || "",
|
||||
key: entry.key || "",
|
||||
action: entry.action,
|
||||
source: entry.source,
|
||||
actor: entry.actor || "",
|
||||
ip: entry.ip || "",
|
||||
value: entry.value !== undefined && entry.value !== null ? String(entry.value) : "",
|
||||
detail: entry.detail || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[audit] 操作记录写入失败:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { log, getClientIp };
|
||||
+110
-31
@@ -1,17 +1,70 @@
|
||||
/**
|
||||
* API Token 服务
|
||||
* 负责 API 访问令牌的增删改查与吊销,持久化到 data/api-tokens.json
|
||||
* 支持 mcpEnabled 标记:标记后该 token 用于 MCP /mcp 端点鉴权(取代旧的 mcp.json / mcp_kv)
|
||||
* 负责 API 访问令牌的增删改查与吊销,持久化到 DuckDB。
|
||||
* 所有 Token 均可用于 MCP /mcp 端点鉴权;Token 名称唯一。
|
||||
* 关联项目支持多选:"all" 表示全部项目,否则为若干项目 ID 列表。
|
||||
*/
|
||||
const { loadApiTokens, saveApiTokens, loadMcpConfig, saveMcpConfig } = require("../persistence/duckdbAdapter");
|
||||
const { generateToken } = require("./userService");
|
||||
|
||||
/**
|
||||
* 规整历史 Token 记录:确保存在 projectIds(兼容旧 projectId 字段)
|
||||
* @param {Object} t - 原始 Token 记录
|
||||
* @returns {Object} 规整后的 Token(含 projectIds)
|
||||
*/
|
||||
function normalizeToken(t) {
|
||||
if (t.projectIds && Array.isArray(t.projectIds)) {
|
||||
return t;
|
||||
}
|
||||
const legacy = t.projectId === undefined || t.projectId === null ? "all" : t.projectId;
|
||||
return { ...t, projectIds: Array.isArray(legacy) ? legacy : [legacy] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整提交的项目范围:数组,含 "all" 时归一为 ["all"]
|
||||
* @param {*} projectIds - 提交的项目范围
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
function normalizeProjectIds(projectIds) {
|
||||
if (Array.isArray(projectIds)) {
|
||||
if (projectIds.includes("all")) return ["all"];
|
||||
return projectIds.filter(Boolean);
|
||||
}
|
||||
if (typeof projectIds === "string" && projectIds !== "") {
|
||||
return projectIds === "all" ? ["all"] : [projectIds];
|
||||
}
|
||||
return ["all"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 是否可访问指定项目
|
||||
* @param {Object} token - Token 对象
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function coversProject(token, projectId) {
|
||||
if (!token) return true;
|
||||
const ids = normalizeToken(token).projectIds;
|
||||
return !ids.length || ids.includes("all") || ids.includes(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 名称是否与其它 Token 重复
|
||||
* @param {Array} tokens - Token 列表
|
||||
* @param {string} name - 名称
|
||||
* @param {string} [excludeId] - 更新时排除自身
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function nameTaken(tokens, name, excludeId) {
|
||||
return tokens.some((t) => t.id !== excludeId && t.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Token(完整值,供列表展示)
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function getAllTokens() {
|
||||
return loadApiTokens();
|
||||
return (await loadApiTokens()).map(normalizeToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,12 +73,13 @@ async function getAllTokens() {
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findById(id) {
|
||||
return (await loadApiTokens()).find((t) => t.id === id) || null;
|
||||
const t = (await loadApiTokens()).find((t) => t.id === id);
|
||||
return t ? normalizeToken(t) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Token
|
||||
* @param {Object} data - { name, projectId, permission, mcpEnabled, expiresInDays }
|
||||
* @param {Object} data - { name, projectIds | projectId, permission, expiresInDays }
|
||||
* @returns {Promise<Object>} 创建的 Token(含明文 value)
|
||||
*/
|
||||
async function createToken(data) {
|
||||
@@ -33,23 +87,28 @@ async function createToken(data) {
|
||||
if (!data.name || !data.name.trim()) {
|
||||
throw new Error("Token 名称不能为空");
|
||||
}
|
||||
const name = data.name.trim();
|
||||
if (nameTaken(tokens, name)) {
|
||||
throw new Error("Token 名称已存在");
|
||||
}
|
||||
const tokenValue = `reg_${generateToken(24)}`;
|
||||
const now = new Date();
|
||||
let expiresAt = null;
|
||||
if (data.expiresInDays && data.expiresInDays > 0) {
|
||||
expiresAt = new Date(now.getTime() + data.expiresInDays * 86400000).toISOString();
|
||||
}
|
||||
const token = {
|
||||
const token = normalizeToken({
|
||||
id: generateToken(8),
|
||||
name: data.name.trim(),
|
||||
projectId: data.projectId || "all",
|
||||
name,
|
||||
projectIds: normalizeProjectIds(
|
||||
data.projectIds !== undefined ? data.projectIds : (data.projectId !== undefined ? data.projectId : "all")
|
||||
),
|
||||
permission: data.permission === "readwrite" ? "readwrite" : "readonly",
|
||||
mcpEnabled: !!data.mcpEnabled,
|
||||
value: tokenValue,
|
||||
enabled: true,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt,
|
||||
};
|
||||
});
|
||||
tokens.push(token);
|
||||
await saveApiTokens(tokens);
|
||||
return token;
|
||||
@@ -58,17 +117,24 @@ async function createToken(data) {
|
||||
/**
|
||||
* 更新 Token 元信息
|
||||
* @param {string} id
|
||||
* @param {Object} data - { name, projectId, permission, mcpEnabled, enabled }
|
||||
* @param {Object} data - { name, projectIds | projectId, permission, enabled }
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function updateToken(id, data) {
|
||||
const tokens = await loadApiTokens();
|
||||
const tokens = (await loadApiTokens()).map(normalizeToken);
|
||||
const idx = tokens.findIndex((t) => t.id === id);
|
||||
if (idx === -1) throw new Error("Token 不存在");
|
||||
if (data.name !== undefined) tokens[idx].name = data.name.trim();
|
||||
if (data.projectId !== undefined) tokens[idx].projectId = data.projectId;
|
||||
if (data.name !== undefined) {
|
||||
const name = data.name.trim();
|
||||
if (!name) throw new Error("Token 名称不能为空");
|
||||
if (nameTaken(tokens, name, id)) {
|
||||
throw new Error("Token 名称已存在");
|
||||
}
|
||||
tokens[idx].name = name;
|
||||
}
|
||||
if (data.projectIds !== undefined) tokens[idx].projectIds = normalizeProjectIds(data.projectIds);
|
||||
if (data.projectId !== undefined && data.projectIds === undefined) tokens[idx].projectIds = normalizeProjectIds(data.projectId);
|
||||
if (data.permission !== undefined) tokens[idx].permission = data.permission;
|
||||
if (data.mcpEnabled !== undefined) tokens[idx].mcpEnabled = !!data.mcpEnabled;
|
||||
if (data.enabled !== undefined) tokens[idx].enabled = !!data.enabled;
|
||||
await saveApiTokens(tokens);
|
||||
return tokens[idx];
|
||||
@@ -103,50 +169,61 @@ async function deleteToken(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 token value 校验是否有效(用于 API 鉴权)
|
||||
* 根据 token value 校验是否有效(用于通用 API 鉴权)
|
||||
* @param {string} value
|
||||
* @returns {Promise<Object|null>} token 对象或 null
|
||||
* @returns {Promise<Object|null>} Token 对象或 null
|
||||
*/
|
||||
async function validateToken(value) {
|
||||
return resolveActiveToken(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Bearer token 是否为生效 Token(启用且未过期)
|
||||
* @param {string} value - Token 明文
|
||||
* @returns {Promise<Object|null>} 生效的 Token 对象或 null
|
||||
*/
|
||||
async function resolveActiveToken(value) {
|
||||
if (!value) return null;
|
||||
const token = (await loadApiTokens()).find((t) => t.value === value);
|
||||
const token = (await loadApiTokens()).map(normalizeToken).find((t) => t.value === value);
|
||||
if (!token || !token.enabled) return null;
|
||||
if (token.expiresAt && new Date(token.expiresAt) < new Date()) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用了 MCP 访问的生效 Token 值列表(用于 /mcp 端点鉴权)
|
||||
* @returns {Promise<string[]>} 生效的 MCP token 明文值
|
||||
* 获取全部生效 Token 明文值列表(MCP / 其它鉴权共用,所有 Token 均视为 MCP 令牌)
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function getMcpTokenValues() {
|
||||
async function getActiveTokenValues() {
|
||||
const now = Date.now();
|
||||
return (await loadApiTokens())
|
||||
.filter((t) => t.mcpEnabled && t.enabled && (!t.expiresAt || new Date(t.expiresAt).getTime() > now))
|
||||
.map(normalizeToken)
|
||||
.filter((t) => t.enabled && (!t.expiresAt || new Date(t.expiresAt).getTime() > now))
|
||||
.map((t) => t.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性迁移历史 MCP 鉴权配置(旧 mcp.json / mcp_kv)到 Token 表
|
||||
* 兼容线上已有用旧 token 连接的 MCP 客户端:把旧 token 转为一条启用 mcpEnabled 的 Token,并清除旧配置
|
||||
* 兼容线上已有用旧 token 连接的 MCP 客户端:若旧 token 尚未存在于 Token 表,则转为一条 Token 记录并清除旧配置。
|
||||
* 所有 Token 均视为 MCP 令牌,无需特殊标记。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function migrateLegacyMcpToken() {
|
||||
const tokens = await loadApiTokens();
|
||||
if (tokens.some((t) => t.mcpEnabled)) {
|
||||
return;
|
||||
}
|
||||
const tokens = (await loadApiTokens()).map(normalizeToken);
|
||||
const mcp = await loadMcpConfig();
|
||||
if (!mcp || !mcp.token) {
|
||||
return;
|
||||
}
|
||||
if (tokens.some((t) => t.value === mcp.token)) {
|
||||
await saveMcpConfig({ enabled: false, token: "" });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
tokens.push({
|
||||
id: generateToken(8),
|
||||
name: "MCP(历史迁移)",
|
||||
projectId: "all",
|
||||
projectIds: ["all"],
|
||||
permission: "readwrite",
|
||||
mcpEnabled: true,
|
||||
value: mcp.token,
|
||||
enabled: true,
|
||||
createdAt: now.toISOString(),
|
||||
@@ -165,6 +242,8 @@ module.exports = {
|
||||
revokeToken,
|
||||
deleteToken,
|
||||
validateToken,
|
||||
getMcpTokenValues,
|
||||
resolveActiveToken,
|
||||
getActiveTokenValues,
|
||||
coversProject,
|
||||
migrateLegacyMcpToken,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 操作记录(审计)单元测试
|
||||
* 覆盖:appendAuditLog/loadAuditLogs 读写与过滤、auditService.log 静默容错、getClientIp 支持 nginx 转发
|
||||
* 运行:npm test
|
||||
*/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
// 使用临时数据目录,避免污染真实 data/
|
||||
const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-audit-test-"));
|
||||
process.env.REGISTRY_DATA_DIR = tmpDataDir;
|
||||
|
||||
const duckdbAdapter = require("../src/persistence/duckdbAdapter");
|
||||
const { log: auditLog, getClientIp } = require("../src/services/auditService");
|
||||
|
||||
test.before(async () => {
|
||||
await duckdbAdapter.init();
|
||||
});
|
||||
|
||||
test("记录一条配置操作并可按项目/键查询", async () => {
|
||||
await auditLog({
|
||||
source: "web",
|
||||
actor: "admin",
|
||||
ip: "203.0.113.9",
|
||||
projectId: "proj-a",
|
||||
key: "db/host",
|
||||
action: "update",
|
||||
value: "db.internal",
|
||||
detail: { from: { key: "db/host", value: "old" }, to: { key: "db/host", value: "db.internal" } },
|
||||
});
|
||||
|
||||
const byProject = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a" });
|
||||
assert.strictEqual(byProject.length, 1);
|
||||
const byKey = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "db/host" });
|
||||
assert.strictEqual(byKey.length, 1);
|
||||
// 其它项目/键查不到
|
||||
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-b" })).length, 0);
|
||||
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "other" })).length, 0);
|
||||
|
||||
const rec = byKey[0];
|
||||
assert.strictEqual(rec.source, "web");
|
||||
assert.strictEqual(rec.actor, "admin");
|
||||
assert.strictEqual(rec.ip, "203.0.113.9");
|
||||
assert.strictEqual(rec.action, "update");
|
||||
assert.strictEqual(rec.value, "db.internal");
|
||||
assert.strictEqual(rec.detail.from.value, "old");
|
||||
assert.ok(rec.createdAt);
|
||||
});
|
||||
|
||||
test("记录按时间倒序返回,MCP 来源也正确记录 Token 名", async () => {
|
||||
await auditLog({ source: "mcp", actor: "ci-token", ip: "10.0.0.1", projectId: "proj-a", key: "k/1", action: "read", value: "x" });
|
||||
await auditLog({ source: "mcp", actor: "ci-token", ip: "10.0.0.1", projectId: "proj-a", key: "k/1", action: "create", value: "x" });
|
||||
|
||||
const logs = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "k/1" });
|
||||
assert.strictEqual(logs.length, 2);
|
||||
assert.ok(logs[0].createdAt >= logs[1].createdAt, "最新的应在前");
|
||||
assert.ok(logs.every((l) => l.source === "mcp" && l.actor === "ci-token"));
|
||||
});
|
||||
|
||||
test("getClientIp 优先取 X-Forwarded-For,其次 remoteAddress", () => {
|
||||
assert.strictEqual(getClientIp({ headers: { "x-forwarded-for": "203.0.113.50, 10.0.0.5" } }), "203.0.113.50");
|
||||
assert.strictEqual(getClientIp({ headers: {}, socket: { remoteAddress: "127.0.0.1" } }), "127.0.0.1");
|
||||
assert.strictEqual(getClientIp(null), "");
|
||||
});
|
||||
+42
-4
@@ -9,7 +9,7 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createMcpServer } = require("../src/mcp/tools");
|
||||
const { init, loadSystem, saveSystem } = require("../src/persistence/duckdbAdapter");
|
||||
const { init, loadSystem, saveSystem, loadAuditLogs } = require("../src/persistence/duckdbAdapter");
|
||||
|
||||
// 使用临时数据目录,避免污染真实 data/
|
||||
const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-mcp-test-"));
|
||||
@@ -35,10 +35,12 @@ test.after(async () => {
|
||||
* 直接调用已注册工具的处理函数
|
||||
* @param {string} name - 工具名
|
||||
* @param {Object} args - 工具参数
|
||||
* @param {Object} [srv] - MCP 服务器实例(默认使用全局 server)
|
||||
* @returns {Promise<Object>} 工具结果
|
||||
*/
|
||||
async function callTool(name, args) {
|
||||
const tool = server._registeredTools[name];
|
||||
async function callTool(name, args, srv) {
|
||||
const target = srv || server;
|
||||
const tool = target._registeredTools[name];
|
||||
assert.ok(tool, `工具不存在: ${name}`);
|
||||
try {
|
||||
const result = await tool.handler(args, {});
|
||||
@@ -153,13 +155,49 @@ test("edit 模式下可以修改配置说明", async () => {
|
||||
assert.strictEqual(updatedObj.value, "localhost", "仅改说明,值不应变化");
|
||||
|
||||
// 清理测试项目
|
||||
const delProject = await callTool("delete_project", { projectId: project.id });
|
||||
const delProject = await callTool("delete_project", { projectId: project.id });
|
||||
const confirmProject = await callTool("confirm_delete_project", {
|
||||
confirmation_token: delProject.structuredContent.confirmation_token,
|
||||
});
|
||||
assert.strictEqual(confirmProject.structuredContent.status, "deleted");
|
||||
});
|
||||
|
||||
test("Token 项目范围限制 MCP 配置访问,越权操作被拒绝且不入记录", async () => {
|
||||
// 先创建真实项目,再构造仅允许该项目访问的 Token 会话
|
||||
const projText = (await callTool("create_project", { name: "MCP-Scope-Proj" })).text;
|
||||
const project = JSON.parse(projText.match(/\{[\s\S]*\}/)[0]);
|
||||
const scoped = createMcpServer({ token: { name: "scope-token", projectIds: [project.id] }, ip: "10.1.2.3" });
|
||||
|
||||
// 无权访问的项目:读取/写入被拒绝
|
||||
const deniedRead = await callTool("list_configs", { projectId: "other-proj" }, scoped);
|
||||
assert.ok(deniedRead.isError);
|
||||
assert.match(deniedRead.text, /无权访问/);
|
||||
|
||||
const deniedWrite = await callTool("create_config", { projectId: "other-proj", key: "x", description: "x" }, scoped);
|
||||
assert.ok(deniedWrite.isError);
|
||||
assert.match(deniedWrite.text, /无权访问/);
|
||||
|
||||
// 有权限:可读可写,并留下审计记录
|
||||
const createdReal = await callTool("create_config", { projectId: project.id, key: "sql/dsn", description: "d", value: "v1" }, scoped);
|
||||
assert.ok(!createdReal.isError);
|
||||
|
||||
const logsReal = await loadAuditLogs({ projectId: project.id, key: "sql/dsn" });
|
||||
const createLog = logsReal.find((l) => l.action === "create");
|
||||
assert.ok(createLog, "应在 audit_logs 中存在 create 记录");
|
||||
assert.strictEqual(createLog.source, "mcp");
|
||||
assert.strictEqual(createLog.actor, "scope-token");
|
||||
assert.strictEqual(createLog.ip, "10.1.2.3");
|
||||
assert.strictEqual(createLog.value, "v1");
|
||||
|
||||
// 越权操作不产生记录(other-proj 无任何记录)
|
||||
const deniedLogs = await loadAuditLogs({ projectId: "other-proj" });
|
||||
assert.strictEqual(deniedLogs.length, 0);
|
||||
|
||||
// 清理测试项目
|
||||
const delProject = await callTool("delete_project", { projectId: project.id });
|
||||
await callTool("confirm_delete_project", { confirmation_token: delProject.structuredContent.confirmation_token });
|
||||
});
|
||||
|
||||
test("配置删除同样为两步式确认", async () => {
|
||||
const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Cfg" });
|
||||
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
|
||||
|
||||
+56
-39
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Token 服务单元测试
|
||||
* 覆盖:mcpEnabled 标记、MCP token 列表过滤、历史 MCP 配置迁移
|
||||
* 覆盖:多项目关联、名称唯一、全部 Token 均为 MCP 令牌、历史 MCP 配置迁移
|
||||
* 运行:npm test
|
||||
*/
|
||||
const test = require("node:test");
|
||||
@@ -20,61 +20,78 @@ test.before(async () => {
|
||||
await init();
|
||||
});
|
||||
|
||||
test("createToken 保存 mcpEnabled 标记", async () => {
|
||||
const mcpToken = await tokenService.createToken({
|
||||
name: "mcp-key",
|
||||
projectId: "all",
|
||||
permission: "readwrite",
|
||||
mcpEnabled: true,
|
||||
expiresInDays: 30,
|
||||
test("创建 Token:支持多个关联项目与全部项目", async () => {
|
||||
const multi = await tokenService.createToken({
|
||||
name: "multi-proj",
|
||||
projectIds: ["proj-a", "proj-b"],
|
||||
expiresInDays: 0,
|
||||
});
|
||||
assert.strictEqual(mcpToken.mcpEnabled, true);
|
||||
const plainToken = await tokenService.createToken({
|
||||
name: "plain",
|
||||
mcpEnabled: false,
|
||||
});
|
||||
assert.strictEqual(plainToken.mcpEnabled, false);
|
||||
assert.deepStrictEqual(multi.projectIds, ["proj-a", "proj-b"]);
|
||||
assert.strictEqual(tokenService.coversProject(multi, "proj-a"), true);
|
||||
assert.strictEqual(tokenService.coversProject(multi, "proj-b"), true);
|
||||
assert.strictEqual(tokenService.coversProject(multi, "proj-c"), false);
|
||||
|
||||
const all = await tokenService.createToken({ name: "all-proj", projectIds: ["all"] });
|
||||
assert.deepStrictEqual(all.projectIds, ["all"]);
|
||||
assert.strictEqual(tokenService.coversProject(all, "任意项目"), true);
|
||||
|
||||
const legacyProjectId = await tokenService.createToken({ name: "legacy-field", projectId: "proj-x" });
|
||||
assert.deepStrictEqual(legacyProjectId.projectIds, ["proj-x"]);
|
||||
});
|
||||
|
||||
test("getMcpTokenValues 仅返回生效的 MCP token 明文", async () => {
|
||||
const values = await tokenService.getMcpTokenValues();
|
||||
assert.ok(values.length >= 1);
|
||||
// 明文值以 reg_ 开头
|
||||
assert.ok(values.every((v) => typeof v === "string" && v.startsWith("reg_")));
|
||||
test("Token 名称唯一:重复名称创建与更新均报错", async () => {
|
||||
await tokenService.createToken({ name: "dup-name", projectIds: ["all"] });
|
||||
await assert.rejects(
|
||||
() => tokenService.createToken({ name: "dup-name", projectIds: ["all"] }),
|
||||
/名称已存在/
|
||||
);
|
||||
|
||||
// 更新为已存在的名称也报错
|
||||
const other = await tokenService.createToken({ name: "dup-other", projectIds: ["all"] });
|
||||
await assert.rejects(
|
||||
() => tokenService.updateToken(other.id, { name: "dup-name" }),
|
||||
/名称已存在/
|
||||
);
|
||||
|
||||
// 更新自身名称不改动、且可改成唯一名
|
||||
await tokenService.updateToken(other.id, { name: "dup-other" });
|
||||
await tokenService.updateToken(other.id, { name: "dup-renamed" });
|
||||
assert.strictEqual((await tokenService.findById(other.id)).name, "dup-renamed");
|
||||
});
|
||||
|
||||
test("updateToken 可切换 mcpEnabled", async () => {
|
||||
const plain = await tokenService.createToken({ name: "toggle", mcpEnabled: false });
|
||||
const before = await tokenService.getMcpTokenValues();
|
||||
assert.ok(!before.includes(plain.value));
|
||||
await tokenService.updateToken(plain.id, { mcpEnabled: true });
|
||||
const after = await tokenService.getMcpTokenValues();
|
||||
assert.ok(after.includes(plain.value));
|
||||
test("所有 Token 均为 MCP 令牌:全部生效 Token 明文可获取,通过 resolveActiveToken 鉴权", async () => {
|
||||
const t = await tokenService.createToken({ name: "mcp-all", projectIds: ["all"] });
|
||||
const values = await tokenService.getActiveTokenValues();
|
||||
assert.ok(values.includes(t.value), "新 Token 应直接生效于 MCP");
|
||||
|
||||
const resolved = await tokenService.resolveActiveToken(t.value);
|
||||
assert.strictEqual(resolved.name, "mcp-all");
|
||||
assert.strictEqual(await tokenService.resolveActiveToken("bad-token"), null);
|
||||
});
|
||||
|
||||
test("吊销或过期后 MCP token 不再生效", async () => {
|
||||
const t = await tokenService.createToken({ name: "will-revoke", mcpEnabled: true, expiresInDays: 30 });
|
||||
const ok = await tokenService.getMcpTokenValues();
|
||||
assert.ok(ok.includes(t.value));
|
||||
test("吊销或过期后 Token 不再生效", async () => {
|
||||
const t = await tokenService.createToken({ name: "will-revoke", projectIds: ["all"], expiresInDays: 30 });
|
||||
assert.ok((await tokenService.getActiveTokenValues()).includes(t.value));
|
||||
await tokenService.revokeToken(t.id);
|
||||
const revoked = await tokenService.getMcpTokenValues();
|
||||
assert.ok(!revoked.includes(t.value));
|
||||
assert.ok(!(await tokenService.getActiveTokenValues()).includes(t.value));
|
||||
assert.strictEqual(await tokenService.resolveActiveToken(t.value), null);
|
||||
});
|
||||
|
||||
test("migrateLegacyMcpToken 把历史 mcp_kv 配置迁移为 Token 记录", async () => {
|
||||
// 清理当前 token 表,模拟空 MCP 标记状态
|
||||
const { loadApiTokens, saveApiTokens, saveMcpConfig, loadMcpConfig } = require("../src/persistence/duckdbAdapter");
|
||||
await saveApiTokens([]);
|
||||
const existing = await loadApiTokens();
|
||||
await saveMcpConfig({ enabled: true, token: "legacy-mcp-token-abc" });
|
||||
await tokenService.migrateLegacyMcpToken();
|
||||
|
||||
const tokens = await loadApiTokens();
|
||||
const migrated = tokens.find((t) => t.mcpEnabled);
|
||||
assert.ok(migrated, "应生成一条 mcpEnabled 的 Token");
|
||||
assert.strictEqual(migrated.value, "legacy-mcp-token-abc");
|
||||
const values = await tokenService.getMcpTokenValues();
|
||||
assert.ok(values.includes("legacy-mcp-token-abc"));
|
||||
const migrated = tokens.find((t) => t.value === "legacy-mcp-token-abc");
|
||||
assert.ok(migrated, "应生成一条包含旧 token 的记录");
|
||||
assert.strictEqual(migrated.projectIds[0], "all");
|
||||
assert.ok((await tokenService.getActiveTokenValues()).includes("legacy-mcp-token-abc"));
|
||||
// 旧配置已清空
|
||||
const mcp = await loadMcpConfig();
|
||||
assert.strictEqual(mcp.token, "");
|
||||
|
||||
// 清掉迁移产生的记录,避免影响后续测试
|
||||
await saveApiTokens(existing);
|
||||
});
|
||||
Reference in New Issue
Block a user