@lessie/mcp-server 0.0.8 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/remote.js CHANGED
@@ -37,13 +37,24 @@ let cachedTools = [];
37
37
  let reconnecting = null;
38
38
  let authCompletion = null;
39
39
  let onAuthComplete = null;
40
+ let onAuthError = null;
41
+ let lastAuthError = null;
40
42
  /** 注册授权完成回调(用于发送 logging 通知) */
41
43
  export function setOnAuthComplete(cb) {
42
44
  onAuthComplete = cb;
43
45
  }
46
+ /** 注册授权失败回调(用于主动通知 Agent) */
47
+ export function setOnAuthError(cb) {
48
+ onAuthError = cb;
49
+ }
44
50
  export function isRemoteConnected() {
45
51
  return client !== null;
46
52
  }
53
+ /** 若授权流程正在进行中,等待其完成(成功或失败) */
54
+ export async function waitForAuthCompletion() {
55
+ if (authCompletion)
56
+ await authCompletion;
57
+ }
47
58
  /** 启动时静默连接:有缓存令牌才尝试,无令牌或连接失败则跳过 */
48
59
  export async function connectToRemote() {
49
60
  if (!REMOTE_MCP_URL)
@@ -61,37 +72,62 @@ export async function connectToRemote() {
61
72
  }
62
73
  }
63
74
  /**
64
- * 发起 OAuth 授权流程。
65
- * - 若已连接,返回 { connected: true }
66
- * - 若有缓存令牌未连接,尝试连接
67
- * - 若需要授权,直接调用 SDK auth() 获取授权 URL,不经过 transport
75
+ * 发起 OAuth 授权流程,返回结构化结果。
76
+ *
77
+ * 状态说明:
78
+ * connected — 已连接远程服务器
79
+ * auth_url — 生成了授权链接,等待用户浏览器完成登录
80
+ * waiting — 授权流程进行中,正在等待浏览器回调
81
+ * error — 授权流程出错(附带错误码和引导信息)
68
82
  */
69
83
  export async function initiateAuth() {
70
- if (client)
71
- return { connected: true };
72
- if (!REMOTE_MCP_URL)
73
- throw new Error("LESSIE_REMOTE_MCP_URL is not configured");
74
- // 复用已有的待完成授权流程
75
- if (authProvider.pendingAuthUrl) {
76
- ensureAuthCompletion();
77
- return { authUrl: authProvider.pendingAuthUrl };
84
+ if (client) {
85
+ const tools = await listRemoteTools();
86
+ return { status: "connected", toolCount: tools.length };
87
+ }
88
+ if (!REMOTE_MCP_URL) {
89
+ return { status: "error", errorCode: "not_configured", message: "LESSIE_REMOTE_MCP_URL is not configured" };
90
+ }
91
+ // 授权流程进行中:告诉 Agent 当前等待状态
92
+ if (authProvider.pendingAuthUrl && authCompletion) {
93
+ const elapsed = authProvider.waitingSince
94
+ ? Date.now() - authProvider.waitingSince
95
+ : 0;
96
+ return {
97
+ status: "waiting",
98
+ authUrl: authProvider.pendingAuthUrl,
99
+ elapsedMs: elapsed,
100
+ };
78
101
  }
102
+ // 上次异步错误(超时、授权拒绝等),记录后清除
103
+ const prevError = lastAuthError;
104
+ lastAuthError = null;
79
105
  const serverUrl = REMOTE_MCP_URL;
80
- // 直接调用 SDK auth():发现元数据 → 注册客户端 → 生成 PKCE → redirectToAuthorization
81
- const result = await auth(authProvider, { serverUrl, fetchFn });
82
- if (result === "AUTHORIZED") {
83
- const url = new URL(serverUrl);
84
- client = await tryConnect(url);
85
- const { tools } = await client.listTools();
86
- cachedTools = tools;
87
- return { connected: true };
106
+ try {
107
+ await authProvider.prepareCallbackServer();
108
+ const result = await auth(authProvider, { serverUrl, fetchFn });
109
+ if (result === "AUTHORIZED") {
110
+ const url = new URL(serverUrl);
111
+ client = await tryConnect(url);
112
+ const { tools } = await client.listTools();
113
+ cachedTools = tools;
114
+ return { status: "connected", toolCount: tools.length };
115
+ }
116
+ const authUrl = authProvider.pendingAuthUrl;
117
+ if (!authUrl) {
118
+ return { status: "error", errorCode: "no_auth_url", message: "OAuth flow initiated but no authorization URL was generated" };
119
+ }
120
+ ensureAuthCompletion();
121
+ return {
122
+ status: "auth_url",
123
+ authUrl,
124
+ ...(prevError ? { previousError: prevError.message } : {}),
125
+ };
126
+ }
127
+ catch (err) {
128
+ const error = err instanceof Error ? err : new Error(String(err));
129
+ return { status: "error", errorCode: categorizeAuthError(error), message: error.message };
88
130
  }
89
- // 'REDIRECT' — 用户需要访问授权 URL
90
- const authUrl = authProvider.pendingAuthUrl;
91
- if (!authUrl)
92
- throw new Error("OAuth flow initiated but no authorization URL was generated");
93
- ensureAuthCompletion();
94
- return { authUrl };
95
131
  }
96
132
  /** 启动后台任务:等待用户浏览器回调 → 交换令牌 → 连接远程服务器 */
97
133
  function ensureAuthCompletion() {
@@ -111,6 +147,10 @@ function ensureAuthCompletion() {
111
147
  }
112
148
  catch (e) {
113
149
  authProvider.pendingAuthUrl = null;
150
+ const error = e instanceof Error ? e : new Error(String(e));
151
+ const code = categorizeAuthError(error);
152
+ lastAuthError = { code, message: error.message };
153
+ onAuthError?.({ code, message: error.message });
114
154
  console.error("[lessie] Authorization completion failed:", e);
115
155
  }
116
156
  finally {
@@ -118,6 +158,19 @@ function ensureAuthCompletion() {
118
158
  }
119
159
  })();
120
160
  }
161
+ function categorizeAuthError(err) {
162
+ const msg = err.message.toLowerCase();
163
+ const code = err.code;
164
+ if (code === "EADDRINUSE" || msg.includes("already in use") || msg.includes("no available port"))
165
+ return "port_in_use";
166
+ if (msg.includes("timed out") || msg.includes("timeout"))
167
+ return "timeout";
168
+ if (msg.includes("authorization denied") || msg.includes("access_denied"))
169
+ return "auth_denied";
170
+ if (msg.includes("callback server"))
171
+ return "server_error";
172
+ return "unknown";
173
+ }
121
174
  async function tryConnect(url) {
122
175
  const c = new Client({ name: "lessie-mcp-proxy", version: pkg.version });
123
176
  await c.connect(new StreamableHTTPClientTransport(url, { authProvider, fetch: fetchFn }));
@@ -154,14 +207,25 @@ export async function callRemoteTool(name, args) {
154
207
  try {
155
208
  return (await client.callTool({ name, arguments: args }));
156
209
  }
157
- catch {
210
+ catch (err) {
211
+ if (!isAuthError(err)) {
212
+ return {
213
+ content: [
214
+ {
215
+ type: "text",
216
+ text: `工具 ${name} 调用失败: ${err instanceof Error ? err.message : String(err)}`,
217
+ },
218
+ ],
219
+ isError: true,
220
+ };
221
+ }
158
222
  await reconnect();
159
223
  if (!client) {
160
224
  return {
161
225
  content: [
162
226
  {
163
227
  type: "text",
164
- text: `工具 ${name} 调用失败,请使用 authorize 工具重新登录。`,
228
+ text: `工具 ${name} 调用失败(认证过期),请使用 authorize 工具重新登录。`,
165
229
  },
166
230
  ],
167
231
  isError: true,
@@ -170,6 +234,17 @@ export async function callRemoteTool(name, args) {
170
234
  return (await client.callTool({ name, arguments: args }));
171
235
  }
172
236
  }
237
+ function isAuthError(err) {
238
+ const obj = err;
239
+ const status = obj?.status ?? obj?.statusCode;
240
+ if (status === 401 || status === 403)
241
+ return true;
242
+ if (err instanceof Error) {
243
+ const msg = err.message.toLowerCase();
244
+ return msg.includes("unauthorized") || msg.includes("forbidden");
245
+ }
246
+ return false;
247
+ }
173
248
  /**
174
249
  * 重连,关闭旧连接;并发调用共享同一个 Promise 避免竞争。
175
250
  * 静默尝试连接,若令牌已失效则 client 保持 null。
package/dist/schema.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * JSON Schema (MCP Tool inputSchema) → Zod shape 转换。
3
+ *
4
+ * MCP 工具的 inputSchema 是 JSON Schema 的受限子集(type: "object"),
5
+ * 这里只处理 properties 中可能出现的常见类型。
6
+ * 不支持的类型回退为 z.unknown()。
7
+ */
8
+ import { z } from "zod";
9
+ function convertProperty(prop) {
10
+ const types = Array.isArray(prop.type) ? prop.type : prop.type ? [prop.type] : [];
11
+ const hasNull = types.includes("null");
12
+ const nonNullTypes = types.filter((t) => t !== "null");
13
+ const primaryType = nonNullTypes[0];
14
+ let schema;
15
+ if (prop.enum && Array.isArray(prop.enum)) {
16
+ const values = prop.enum.filter((v) => v !== null);
17
+ schema = values.length > 0 ? z.enum(values) : z.unknown();
18
+ }
19
+ else {
20
+ switch (primaryType) {
21
+ case "string":
22
+ schema = z.string();
23
+ break;
24
+ case "number":
25
+ case "integer":
26
+ schema = z.number();
27
+ break;
28
+ case "boolean":
29
+ schema = z.boolean();
30
+ break;
31
+ case "array":
32
+ schema = prop.items ? z.array(convertProperty(prop.items)) : z.array(z.unknown());
33
+ break;
34
+ case "object":
35
+ if (prop.properties) {
36
+ schema = z.object(convertShape(prop.properties, prop.required));
37
+ }
38
+ else {
39
+ schema = z.record(z.unknown());
40
+ }
41
+ break;
42
+ default:
43
+ schema = z.unknown();
44
+ }
45
+ }
46
+ if (hasNull)
47
+ schema = schema.nullable();
48
+ if (prop.description)
49
+ schema = schema.describe(prop.description);
50
+ return schema;
51
+ }
52
+ function convertShape(properties, required) {
53
+ const requiredSet = new Set(required ?? []);
54
+ const shape = {};
55
+ for (const [key, prop] of Object.entries(properties)) {
56
+ let field = convertProperty(prop);
57
+ if (!requiredSet.has(key))
58
+ field = field.optional();
59
+ shape[key] = field;
60
+ }
61
+ return shape;
62
+ }
63
+ /**
64
+ * 将 MCP Tool 的 inputSchema(JSON Schema object)转换为 Zod raw shape。
65
+ * 返回的 shape 可以直接传给 McpServer.registerTool() 的 inputSchema 参数。
66
+ */
67
+ export function jsonSchemaToZodShape(inputSchema) {
68
+ if (!inputSchema.properties || Object.keys(inputSchema.properties).length === 0)
69
+ return undefined;
70
+ return convertShape(inputSchema.properties, inputSchema.required);
71
+ }
package/dist/tools.js CHANGED
@@ -2,63 +2,144 @@
2
2
  * 本地工具注册。
3
3
  *
4
4
  * 定义不依赖远程 MCP Server 的工具(如鉴权辅助工具)。
5
- * 新增工具时在 LOCAL_TOOLS 中声明元数据,在 localHandlers 中注册处理函数。
5
+ * 新增工具时在 registerTools() 中通过 server.registerTool() 注册。
6
6
  */
7
- import { authProvider } from "./auth.js";
8
- import { initiateAuth, listRemoteTools } from "./remote.js";
9
- export const LOCAL_TOOLS = [
10
- {
11
- name: "authorize",
12
- description: "连接到远程 Lessie 服务。首次使用或授权过期时返回授权链接,用户需在浏览器中打开完成登录。已连接时返回当前状态。",
13
- inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
14
- },
15
- {
16
- name: "get_access_token",
17
- description: "获取当前有效的 OAuth access token,可用于直接调用 Lessie API。返回 token 字符串和剩余有效时间。",
18
- inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
19
- },
20
- ];
21
- export const localHandlers = new Map();
22
- localHandlers.set("authorize", async () => {
23
- const result = await initiateAuth();
24
- if ("connected" in result) {
25
- const tools = await listRemoteTools();
26
- return {
27
- content: [
28
- {
29
- type: "text",
30
- text: `已连接到远程 MCP 服务器,共发现 ${tools.length} 个远程工具,无需重新授权。`,
31
- },
32
- ],
33
- };
7
+ import { z } from "zod";
8
+ import { initiateAuth, listRemoteTools, callRemoteTool, isRemoteConnected, waitForAuthCompletion } from "./remote.js";
9
+ import { DEFAULT_CALLBACK_PORT, PORT_SCAN_RANGE } from "./auth.js";
10
+ function portInUseGuidance() {
11
+ const endPort = DEFAULT_CALLBACK_PORT + PORT_SCAN_RANGE - 1;
12
+ return [
13
+ `授权回调服务器启动失败:端口 ${DEFAULT_CALLBACK_PORT}–${endPort} 均被占用。`,
14
+ "",
15
+ "可能原因:上次授权流程未正常关闭,或其他程序占用了这些端口。",
16
+ `请执行 \`lsof -i :${DEFAULT_CALLBACK_PORT}-${endPort}\` 查看占用进程并终止,然后重新调用此工具重试。`,
17
+ ].join("\n");
18
+ }
19
+ const ERROR_GUIDANCE = {
20
+ port_in_use: portInUseGuidance,
21
+ timeout: [
22
+ "授权超时:2 分钟内未收到浏览器回调。",
23
+ "",
24
+ "可能原因:",
25
+ " - 用户未在浏览器中完成授权流程",
26
+ " - 浏览器页面已关闭或未正确加载",
27
+ " - 网络连接问题导致回调未到达",
28
+ "",
29
+ "请重新调用此工具生成新的授权链接,并提醒用户在浏览器中完成授权。",
30
+ ].join("\n"),
31
+ auth_denied: [
32
+ "用户拒绝了授权请求。",
33
+ "",
34
+ "如果用户需要使用远程功能,请重新调用此工具生成新的授权链接,",
35
+ "并请用户在浏览器中点击「允许」完成授权。",
36
+ ].join("\n"),
37
+ server_error: [
38
+ "授权回调服务器运行异常。",
39
+ "",
40
+ "请重新调用此工具重试。如果问题持续出现,可能需要检查系统网络配置或防火墙设置。",
41
+ ].join("\n"),
42
+ not_configured: [
43
+ "远程 MCP 服务器地址未配置。",
44
+ "",
45
+ "请检查环境变量 LESSIE_REMOTE_MCP_URL 是否正确设置。",
46
+ ].join("\n"),
47
+ };
48
+ function formatAuthResult(result) {
49
+ switch (result.status) {
50
+ case "connected":
51
+ return {
52
+ content: [{
53
+ type: "text",
54
+ text: `已连接到远程 MCP 服务器,共发现 ${result.toolCount} 个远程工具,无需重新授权。\n\n可通过 use_lessie 工具调用远程功能:不传 tool 参数可列出所有工具,传入 tool 和 arguments 可调用指定工具。`,
55
+ }],
56
+ };
57
+ case "auth_url": {
58
+ const lines = [];
59
+ if (result.previousError) {
60
+ lines.push(`⚠ 上次授权尝试失败:${result.previousError}`);
61
+ lines.push("已自动发起新的授权流程。");
62
+ lines.push("");
63
+ }
64
+ lines.push("需要授权登录。请在浏览器中打开以下链接完成授权:", "", result.authUrl, "", "授权完成后,使用 use_lessie 工具调用远程功能(不传 tool 参数可查看所有可用工具)。", `如果 2 分钟内未完成授权,流程将超时,届时请重新调用此工具重试。`);
65
+ return { content: [{ type: "text", text: lines.join("\n") }] };
66
+ }
67
+ case "waiting": {
68
+ const elapsedSec = Math.floor(result.elapsedMs / 1000);
69
+ const remainingSec = Math.max(0, 120 - elapsedSec);
70
+ return {
71
+ content: [{
72
+ type: "text",
73
+ text: [
74
+ `授权流程进行中,正在等待用户在浏览器中完成登录。`,
75
+ `已等待 ${elapsedSec} 秒,剩余超时时间约 ${remainingSec} 秒。`,
76
+ "",
77
+ "请提醒用户在浏览器中完成授权。如果用户已关闭页面,可以重新访问以下链接:",
78
+ "",
79
+ result.authUrl,
80
+ ].join("\n"),
81
+ }],
82
+ };
83
+ }
84
+ case "error": {
85
+ const entry = ERROR_GUIDANCE[result.errorCode];
86
+ const guidance = typeof entry === "function" ? entry()
87
+ : entry ?? `授权失败:${result.message}\n\n请重新调用此工具重试。`;
88
+ return {
89
+ content: [{ type: "text", text: guidance }],
90
+ isError: true,
91
+ };
92
+ }
34
93
  }
35
- return {
36
- content: [
37
- {
38
- type: "text",
39
- text: [
40
- "需要授权登录。请在浏览器中打开以下链接完成授权:",
41
- "",
42
- result.authUrl,
43
- "",
44
- "授权完成后,远程工具将自动可用。",
45
- ].join("\n"),
46
- },
47
- ],
48
- };
49
- });
50
- localHandlers.set("get_access_token", async () => {
51
- const tokens = await authProvider.tokens();
52
- return {
53
- content: [
54
- {
55
- type: "text",
56
- text: JSON.stringify({
57
- access_token: tokens?.access_token,
58
- token_type: tokens?.token_type,
59
- remainingSeconds: authProvider.remainingSeconds(),
60
- }, null, 2),
61
- },
62
- ],
63
- };
64
- });
94
+ }
95
+ export function registerTools(server) {
96
+ server.registerTool("authorize", {
97
+ description: "连接到远程 Lessie 服务。首次使用或授权过期时返回授权链接,用户需在浏览器中打开完成登录。已连接时返回当前状态。授权异常时返回诊断信息和修复建议。",
98
+ }, async () => {
99
+ const result = await initiateAuth();
100
+ return formatAuthResult(result);
101
+ });
102
+ server.registerTool("use_lessie", {
103
+ description: "调用远程 Lessie 工具的统一入口。不传 tool 参数时列出所有可用的远程工具及其参数说明;传入 tool 和 arguments 时调用指定的远程工具。需先通过 authorize 完成授权。",
104
+ inputSchema: {
105
+ tool: z.string().optional().describe("要调用的远程工具名称,留空则列出所有可用工具"),
106
+ arguments: z.record(z.unknown()).optional().describe("传递给远程工具的参数"),
107
+ },
108
+ }, async (args) => {
109
+ const toolName = args.tool;
110
+ await waitForAuthCompletion();
111
+ if (!isRemoteConnected()) {
112
+ return {
113
+ content: [{
114
+ type: "text",
115
+ text: "远程 MCP 服务器未连接。请先调用 authorize 工具完成授权。",
116
+ }],
117
+ isError: true,
118
+ };
119
+ }
120
+ if (!toolName) {
121
+ const tools = await listRemoteTools();
122
+ if (tools.length === 0) {
123
+ return {
124
+ content: [{ type: "text", text: "当前没有可用的远程工具。" }],
125
+ };
126
+ }
127
+ const listing = tools.map((t) => {
128
+ const params = t.inputSchema?.properties
129
+ ? Object.entries(t.inputSchema.properties)
130
+ .map(([k, v]) => ` - ${k} (${v.type ?? "any"}): ${v.description ?? ""}`)
131
+ .join("\n")
132
+ : " (无参数)";
133
+ const required = t.inputSchema?.required?.join(", ") ?? "";
134
+ return `## ${t.name}\n${t.description ?? ""}\n 参数:\n${params}${required ? `\n 必填: ${required}` : ""}`;
135
+ });
136
+ return {
137
+ content: [{
138
+ type: "text",
139
+ text: `共 ${tools.length} 个远程工具可用:\n\n${listing.join("\n\n")}`,
140
+ }],
141
+ };
142
+ }
143
+ return callRemoteTool(toolName, args.arguments ?? {});
144
+ });
145
+ }
package/package.json CHANGED
@@ -1,23 +1,27 @@
1
1
  {
2
2
  "name": "@lessie/mcp-server",
3
- "version": "0.0.8",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
- "lessie-mcp-server": "dist/index.js"
7
+ "lessie-mcp-server": "dist/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc",
11
11
  "dev": "tsc --watch",
12
12
  "start": "node dist/index.js",
13
- "inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
13
+ "inspector": "npx @modelcontextprotocol/inspector node dist/index.js",
14
+ "mcpb:pack": "bash mcpb/pack.sh"
14
15
  },
15
16
  "dependencies": {
16
17
  "@modelcontextprotocol/sdk": "^1.10.0",
17
18
  "zod": "^3.23.0"
18
19
  },
19
20
  "devDependencies": {
21
+ "@anthropic-ai/mcpb": "^2.1.2",
22
+ "@modelcontextprotocol/inspector": "^0.21.1",
20
23
  "@types/node": "^20.0.0",
24
+ "esbuild": "^0.27.4",
21
25
  "typescript": "^5.4.0"
22
26
  },
23
27
  "publishConfig": {
@@ -1,36 +0,0 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import jwt from "jsonwebtoken";
3
- const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
4
- const JWT_EXPIRES_IN = 3600; // 1 hour
5
- // ── API Key 生成 ─────────────────────────────────────────────────────────────
6
- export function generateApiKey() {
7
- const rawKey = "sk-live-v1-" + randomBytes(24).toString("base64url");
8
- const keyHash = hashApiKey(rawKey);
9
- const keyHint = rawKey.slice(0, 20) + "...";
10
- return { rawKey, keyHash, keyHint };
11
- }
12
- export function hashApiKey(rawKey) {
13
- return createHash("sha256").update(rawKey).digest("hex");
14
- }
15
- // ── JWT 签发 ─────────────────────────────────────────────────────────────────
16
- export function signJwt(payload) {
17
- const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
18
- return { token, expiresIn: JWT_EXPIRES_IN };
19
- }
20
- // ── JWT 验证中间件(登录态保护) ──────────────────────────────────────────────
21
- export function requireAuth(req, res, next) {
22
- const header = req.headers.authorization;
23
- if (!header?.startsWith("Bearer ")) {
24
- res.status(401).json({ error: "Missing or invalid Authorization header" });
25
- return;
26
- }
27
- const token = header.slice(7);
28
- try {
29
- const decoded = jwt.verify(token, JWT_SECRET);
30
- req.user = decoded;
31
- next();
32
- }
33
- catch {
34
- res.status(401).json({ error: "Invalid or expired token" });
35
- }
36
- }
package/dist/server/db.js DELETED
@@ -1,5 +0,0 @@
1
- import pg from "pg";
2
- const pool = new pg.Pool({
3
- connectionString: process.env.DATABASE_URL,
4
- });
5
- export default pool;
@@ -1,11 +0,0 @@
1
- import express from "express";
2
- import apiKeysRouter from "./routes/api-keys.js";
3
- import authTokenRouter from "./routes/auth-token.js";
4
- const app = express();
5
- const PORT = parseInt(process.env.PORT || "3000", 10);
6
- app.use(express.json());
7
- app.use("/api-keys", apiKeysRouter);
8
- app.use("/auth/token", authTokenRouter);
9
- app.listen(PORT, () => {
10
- console.log(`Server listening on port ${PORT}`);
11
- });
@@ -1,78 +0,0 @@
1
- import { Router } from "express";
2
- import pool from "../db.js";
3
- import { generateApiKey, requireAuth } from "../auth.js";
4
- const router = Router();
5
- router.use(requireAuth);
6
- const MAX_KEYS_PER_USER = 10;
7
- // POST /api-keys — 创建 API Key
8
- router.post("/", async (req, res) => {
9
- try {
10
- const userId = req.user.sub;
11
- const { name, scopes } = req.body;
12
- if (!name || typeof name !== "string" || name.trim().length === 0) {
13
- res.status(400).json({ error: "name is required" });
14
- return;
15
- }
16
- const validScopes = scopes ?? ["read"];
17
- const countResult = await pool.query("SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND revoked_at IS NULL", [userId]);
18
- if (parseInt(countResult.rows[0].count, 10) >= MAX_KEYS_PER_USER) {
19
- res.status(400).json({ error: `Maximum ${MAX_KEYS_PER_USER} active API keys allowed` });
20
- return;
21
- }
22
- const { rawKey, keyHash, keyHint } = generateApiKey();
23
- await pool.query(`INSERT INTO api_keys (user_id, name, key_hash, key_hint, scopes)
24
- VALUES ($1, $2, $3, $4, $5)`, [userId, name.trim(), keyHash, keyHint, validScopes]);
25
- res.status(201).json({
26
- key: rawKey,
27
- hint: keyHint,
28
- name: name.trim(),
29
- scopes: validScopes,
30
- });
31
- }
32
- catch (err) {
33
- console.error("POST /api-keys error:", err);
34
- res.status(500).json({ error: "Internal server error" });
35
- }
36
- });
37
- // GET /api-keys — 列出 API Keys
38
- router.get("/", async (req, res) => {
39
- try {
40
- const userId = req.user.sub;
41
- const result = await pool.query(`SELECT id, name, key_hint, scopes, last_used_at, created_at
42
- FROM api_keys
43
- WHERE user_id = $1 AND revoked_at IS NULL
44
- ORDER BY created_at DESC`, [userId]);
45
- res.json(result.rows.map((row) => ({
46
- id: row.id,
47
- name: row.name,
48
- keyHint: row.key_hint,
49
- scopes: row.scopes,
50
- lastUsedAt: row.last_used_at,
51
- createdAt: row.created_at,
52
- })));
53
- }
54
- catch (err) {
55
- console.error("GET /api-keys error:", err);
56
- res.status(500).json({ error: "Internal server error" });
57
- }
58
- });
59
- // DELETE /api-keys/:id — 吊销 API Key(软删除)
60
- router.delete("/:id", async (req, res) => {
61
- try {
62
- const userId = req.user.sub;
63
- const { id } = req.params;
64
- const result = await pool.query(`UPDATE api_keys SET revoked_at = now()
65
- WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
66
- RETURNING id`, [id, userId]);
67
- if (result.rowCount === 0) {
68
- res.status(404).json({ error: "API key not found" });
69
- return;
70
- }
71
- res.status(204).send();
72
- }
73
- catch (err) {
74
- console.error("DELETE /api-keys/:id error:", err);
75
- res.status(500).json({ error: "Internal server error" });
76
- }
77
- });
78
- export default router;