From 4674905d4b26aa55420ba4521e114767ecfab0bc Mon Sep 17 00:00:00 2001 From: fenglei Date: Thu, 23 Jul 2026 15:21:23 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(plugin-meego):=20=E7=9B=B4=E8=B0=83=20?= =?UTF-8?q?Meego=20open=5Fapi,=E8=AF=84=E8=AE=BA=E4=BB=A5=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E7=BB=91=E5=AE=9A=E8=BA=AB=E4=BB=BD=E8=90=BD?= =?UTF-8?q?=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方托管 MCP 的操作身份固化在挂载 URL 的 userKey 里,所有评论都显示为同一 个人(纪世赫)。新增 tool-provider plugin 直调飞书项目 open_api: - pat.ts:plugin_token 换发与按 plugin_id 键控的进程内缓存(模式同 plugin-feishu/tat.ts);凭证 plugin_id/plugin_secret 存平台 SecretStore, 经 X-TB-Upstream-Auth 每次传入,plugin 不自持。 - meegoApi.ts:add_comment / list_comments / query_user 直调 open_api, X-PLUGIN-TOKEN + X-USER-KEY 双头;token 失效归一 MeegoAuthError 供强制 重换发重试一次。 - index.ts:操作人按 ctx.keyId 从挂载 providerConfig.userKeys 映射解析 (admin 维护,调用方不可自改、不做工具入参防冒充);未绑定一律 permission_denied,绝不回落默认身份。whoami 工具验证"评论会显示为谁"。 Co-Authored-By: Claude Fable 5 --- package.json | 2 +- packages/plugin-meego/package.json | 24 + packages/plugin-meego/src/index.ts | 433 ++++++++++++++++++ packages/plugin-meego/src/meegoApi.ts | 156 +++++++ packages/plugin-meego/src/pat.ts | 88 ++++ packages/plugin-meego/test/env.d.ts | 4 + .../test/plugin.integration.test.ts | 374 +++++++++++++++ packages/plugin-meego/tsconfig.json | 7 + packages/plugin-meego/vitest.config.ts | 18 + packages/plugin-meego/wrangler.jsonc | 14 + pnpm-lock.yaml | 159 +------ 11 files changed, 1141 insertions(+), 138 deletions(-) create mode 100644 packages/plugin-meego/package.json create mode 100644 packages/plugin-meego/src/index.ts create mode 100644 packages/plugin-meego/src/meegoApi.ts create mode 100644 packages/plugin-meego/src/pat.ts create mode 100644 packages/plugin-meego/test/env.d.ts create mode 100644 packages/plugin-meego/test/plugin.integration.test.ts create mode 100644 packages/plugin-meego/tsconfig.json create mode 100644 packages/plugin-meego/vitest.config.ts create mode 100644 packages/plugin-meego/wrangler.jsonc diff --git a/package.json b/package.json index 525ea93..01679f7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "pnpm --filter @tool-bridge/core --filter @tool-bridge/cli --filter @tool-bridge/sdk test", - "test:integration": "pnpm --filter @tool-bridge/gateway --filter @tool-bridge/server --filter @tool-bridge/plugin-feishu test", + "test:integration": "pnpm --filter @tool-bridge/gateway --filter @tool-bridge/server --filter @tool-bridge/plugin-feishu --filter @tool-bridge/plugin-meego test", "test": "pnpm -r --if-present test", "verify": "pnpm typecheck && pnpm lint && pnpm test:unit && pnpm test:integration", "gen-dev-vars": "node scripts/gen-dev-vars.mjs", diff --git a/packages/plugin-meego/package.json b/packages/plugin-meego/package.json new file mode 100644 index 0000000..97969a5 --- /dev/null +++ b/packages/plugin-meego/package.json @@ -0,0 +1,24 @@ +{ + "name": "@tool-bridge/plugin-meego", + "version": "0.1.0", + "private": true, + "description": "tool-bridge tool-provider plugin: Feishu Project (Meego) open_api with per-caller X-USER-KEY identity", + "type": "module", + "main": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@tool-bridge/core": "workspace:*" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.18.0", + "@cloudflare/workers-types": "^5.20260706.1", + "vitest": "^4.1.10", + "wrangler": "^4.107.0", + "zod": "^3.25.76" + } +} diff --git a/packages/plugin-meego/src/index.ts b/packages/plugin-meego/src/index.ts new file mode 100644 index 0000000..234b3b2 --- /dev/null +++ b/packages/plugin-meego/src/index.ts @@ -0,0 +1,433 @@ +/** + * 飞书项目(Meego)tool-provider plugin(CF Worker,自部署后注册进 tool-bridge)。 + * + * 解决的问题:官方托管 MCP 的操作身份固化在挂载 URL 的 userKey 里(所有评论都显示为 + * 同一个人)。本 plugin 直调 Meego open_api,每次请求带 `X-USER-KEY` 指定**操作人**—— + * 评论/写操作以真实调用者身份落地。 + * + * **操作人解析**(本 plugin 的核心):挂载节点 config.providerConfig.userKeys 是 + * `{ : }` 映射(admin 经 registry 维护,调用方不可自改),网关把它作为 + * CallContext.mountConfig 经 X-TB-Context 传入;每次调用按 ctx.keyId 查表得操作人。 + * 未绑定的 key 一律拒绝(permission_denied)——绝不静默回落到某个默认身份,那正是 + * 要修的病。user_key **不做工具入参**:入参可由任何持 key 者伪造,mountConfig 不能。 + * + * **凭证边界**(同 plugin-feishu):plugin_id/plugin_secret 不由 plugin 自持——凭证存平台 + * SecretStore(挂载 config.authRef),每次调用经 `X-TB-Upstream-Auth`(base64url JSON + * `{"plugin_id":"...","plugin_secret":"..."}`)传入;PAT 缓存按 plugin_id 键控(pat.ts)。 + * + * 契约面(tool-provider/v1,与 gateway pluginClient/契约校验对齐): + * GET /healthz → { healthy: true } + * GET /~describe → { kind, interfaceVersion } + * GET /~help → Help DSL / HelpJson(Accept 协商) + * POST / → envelope {"tool":"List|Get|Call","arguments":{...}} + * envelope 鉴权:`Authorization: Bearer `;X-TB-Request-Id 幂等去重。 + * + * env(wrangler secret / vars): + * PLUGIN_TOKEN — 平台 pluginToken(secret;注册后配进 Worker secret) + * MEEGO_BASE_URL — open_api 域名 override(vars,可缺省;私有化部署用) + */ + +import { + base64urlDecode, + type CallContext, + decodeCallContext, + decodePluginCall, + HEADER_TB_CONTEXT, + HEADER_TB_UPSTREAM_AUTH, + type HelpModel, + isTBError, + negotiate, + renderHelpDsl, + renderHelpJson, + RequestDedupe, + TBError, +} from '@tool-bridge/core' +import { + addComment, + isMeegoAuthError, + listComments, + type MeegoApiConfig, + queryUser, +} from './meegoApi' +import { DEFAULT_BASE_URL, pluginAccessToken } from './pat' + +export interface Env { + MEEGO_BASE_URL?: string + PLUGIN_TOKEN?: string +} + +/** X-TB-Upstream-Auth 解码后的 Meego 插件凭证形状。 */ +interface MeegoCredential { + plugin_id: string + plugin_secret: string +} + +function upstreamCredential(req: Request): MeegoCredential { + const header = req.headers.get(HEADER_TB_UPSTREAM_AUTH) + if (header === null || header === '') { + throw new TBError( + 'unavailable', + `缺 ${HEADER_TB_UPSTREAM_AUTH}:挂载节点须配置 authRef(Meego 凭证 JSON {"plugin_id","plugin_secret"} 存平台凭证保管)`, + { retryable: false }, + ) + } + let parsed: unknown + try { + parsed = JSON.parse(new TextDecoder().decode(base64urlDecode(header))) + } catch { + throw new TBError('invalid_argument', `${HEADER_TB_UPSTREAM_AUTH} 非法(须 base64url JSON)`) + } + const cred = parsed as Partial + if (typeof cred.plugin_id !== 'string' || cred.plugin_id === '') { + throw new TBError('invalid_argument', `${HEADER_TB_UPSTREAM_AUTH} 缺 plugin_id`) + } + if (typeof cred.plugin_secret !== 'string' || cred.plugin_secret === '') { + throw new TBError('invalid_argument', `${HEADER_TB_UPSTREAM_AUTH} 缺 plugin_secret`) + } + return { plugin_id: cred.plugin_id, plugin_secret: cred.plugin_secret } +} + +/** + * 按调用方 keyId 从 mountConfig.userKeys 解析操作人 user_key。 + * 未配置/未绑定 → permission_denied,附上自助指引;绝不回落默认身份。 + */ +function resolveUserKey(ctx: CallContext): string { + const raw = ctx.mountConfig?.userKeys + const map + = typeof raw === 'object' && raw !== null ? (raw as Record) : undefined + const userKey = map?.[ctx.keyId] + if (typeof userKey !== 'string' || userKey === '') { + throw new TBError( + 'permission_denied', + `调用方 key '${ctx.keyId}' 未绑定 Meego 操作人身份:请管理员在挂载节点 providerConfig.userKeys 增加 {"${ctx.keyId}":""}(user_key 可经 query_user 用邮箱反查)`, + { retryable: false }, + ) + } + return userKey +} + +const KIND = 'tool-provider' +const INTERFACE_VERSION = 'tool-provider/v1' + +const dedupe = new RequestDedupe() + +// ---------- 工具表 ---------- + +interface ToolSpec { + description?: string + effect?: string + inputSchema?: unknown + name: string +} + +const WORK_ITEM_SCHEMA_PROPS = { + project_key: { type: 'string', description: '空间 projectKey(如 tipsy chat = 6a4226868b7eed94090347eb)' }, + work_item_type_key: { type: 'string', description: '工作项类型 key(经 meego 官方 MCP list_workitem_types 查;缺陷常见为 issue)' }, + work_item_id: { type: 'number', description: '工作项 id(数字)' }, +} as const + +const TOOLS: ToolSpec[] = [ + { + name: 'add_comment', + description: + '给工作项添加评论(markdown/富文本)。评论以**调用方绑定的 user_key 身份**落地——谁调用显示谁,不再固定为挂载者。', + effect: 'destructive', + inputSchema: { + type: 'object', + properties: { + ...WORK_ITEM_SCHEMA_PROPS, + content: { type: 'string', description: '评论内容' }, + }, + required: ['project_key', 'work_item_type_key', 'work_item_id', 'content'], + }, + }, + { + name: 'list_comments', + description: '列出工作项的评论(含 operator=评论人 user_key,可验证评论身份)。', + effect: 'read', + inputSchema: { + type: 'object', + properties: { + ...WORK_ITEM_SCHEMA_PROPS, + page_num: { type: 'number', description: '页码(1 起)' }, + page_size: { type: 'number', description: '每页条数(默认 50)' }, + }, + required: ['project_key', 'work_item_type_key', 'work_item_id'], + }, + }, + { + name: 'query_user', + description: + '查用户详情(user_key/name_cn/email)。绑定新同学时用邮箱反查 user_key。user_keys 与 emails 至少一项。', + effect: 'read', + inputSchema: { + type: 'object', + properties: { + user_keys: { type: 'array', items: { type: 'string' }, description: '按 user_key 查' }, + emails: { type: 'array', items: { type: 'string' }, description: '按邮箱查(反查 user_key)' }, + }, + }, + }, + { + name: 'whoami', + description: '返回当前调用方绑定的 Meego 操作人(user_key + 用户详情);验证"评论会显示为谁"。', + effect: 'read', + inputSchema: { type: 'object', properties: {} }, + }, +] + +// ---------- 参数校验 ---------- + +function str(args: Record, field: string): string { + const v = args[field] + if (typeof v !== 'string' || v === '') { + throw new TBError('invalid_argument', `field '${field}' must be a non-empty string`) + } + return v +} + +function num(args: Record, field: string): number { + const v = args[field] + // 允许数字字符串:MQL 查回来的 work_item_id 常以字符串形态出现在上下文里。 + const n = typeof v === 'string' && v !== '' ? Number(v) : v + if (typeof n !== 'number' || !Number.isFinite(n)) { + throw new TBError('invalid_argument', `field '${field}' must be a number`) + } + return n +} + +function optNum(args: Record, field: string): number | undefined { + return args[field] === undefined ? undefined : num(args, field) +} + +function strArray(args: Record, field: string): string[] | undefined { + const v = args[field] + if (v === undefined) return undefined + if (!Array.isArray(v) || v.some(x => typeof x !== 'string' || x === '')) { + throw new TBError('invalid_argument', `field '${field}' must be an array of non-empty strings`) + } + return v as string[] +} + +// ---------- 方法实现(鉴权失效 → 强制重换发 PAT 重试一次) ---------- + +async function apiConfig( + env: Env, + cred: MeegoCredential, + userKey: string, + forcePat = false, +): Promise { + const baseUrl = env.MEEGO_BASE_URL ?? DEFAULT_BASE_URL + const pat = await pluginAccessToken( + { pluginId: cred.plugin_id, pluginSecret: cred.plugin_secret, baseUrl }, + forcePat, + ) + return { baseUrl, pat, userKey } +} + +/** + * 执行 `fn`,鉴权失效时强制重换发 PAT 后重试一次。缓存的 PAT 在余量内也可能已被 + * 吊销(如重置 plugin_secret);重试必须绕过缓存(force)。 + */ +async function withPatRetry( + env: Env, + cred: MeegoCredential, + userKey: string, + fn: (cfg: MeegoApiConfig) => Promise, +): Promise { + try { + return await fn(await apiConfig(env, cred, userKey)) + } catch (err) { + if (!isMeegoAuthError(err)) throw err + return await fn(await apiConfig(env, cred, userKey, true)) + } +} + +async function callTool( + env: Env, + cred: MeegoCredential, + ctx: CallContext, + name: string, + args: Record, +): Promise { + switch (name) { + case 'add_comment': { + const userKey = resolveUserKey(ctx) + return withPatRetry(env, cred, userKey, cfg => + addComment(cfg, { + projectKey: str(args, 'project_key'), + workItemTypeKey: str(args, 'work_item_type_key'), + workItemId: num(args, 'work_item_id'), + content: str(args, 'content'), + })) + } + case 'list_comments': { + const userKey = resolveUserKey(ctx) + const pageNum = optNum(args, 'page_num') + const pageSize = optNum(args, 'page_size') + return withPatRetry(env, cred, userKey, cfg => + listComments(cfg, { + projectKey: str(args, 'project_key'), + workItemTypeKey: str(args, 'work_item_type_key'), + workItemId: num(args, 'work_item_id'), + ...(pageNum !== undefined ? { pageNum } : {}), + ...(pageSize !== undefined ? { pageSize } : {}), + })) + } + case 'query_user': { + const userKey = resolveUserKey(ctx) + const userKeys = strArray(args, 'user_keys') + const emails = strArray(args, 'emails') + return withPatRetry(env, cred, userKey, cfg => + queryUser(cfg, { + ...(userKeys !== undefined ? { userKeys } : {}), + ...(emails !== undefined ? { emails } : {}), + })) + } + case 'whoami': { + const userKey = resolveUserKey(ctx) + const detail = await withPatRetry(env, cred, userKey, cfg => + queryUser(cfg, { userKeys: [userKey] })) + return { key_id: ctx.keyId, user_key: userKey, ...(detail as object) } + } + default: + throw TBError.notFound(`未知工具:'${name}'`) + } +} + +async function invoke( + env: Env, + cred: MeegoCredential, + ctx: CallContext, + tool: string, + args: Record, +): Promise { + switch (tool) { + case 'List': + return TOOLS + case 'Get': { + const name = args.name + if (typeof name !== 'string' || name === '') { + throw new TBError('invalid_argument', 'field \'name\' must be a non-empty string') + } + const found = TOOLS.find(t => t.name === name) + if (found === undefined) throw TBError.notFound(`未知工具:'${name}'`) + return found + } + case 'Call': { + const name = args.name + if (typeof name !== 'string' || name === '') { + throw new TBError('invalid_argument', 'field \'name\' must be a non-empty string') + } + const callArgs + = typeof args.args === 'object' && args.args !== null + ? (args.args as Record) + : {} + const value = await callTool(env, cred, ctx, name, callArgs) + return { content: value } + } + default: + throw new TBError('invalid_argument', `unknown method '${tool}'(见 ~help)`) + } +} + +// ---------- 元端点 ---------- + +const HELP: HelpModel = { + node: { + path: 'plugin-meego', + kind: 'tool', + description: + 'Feishu Project (Meego) open_api tools; comments land as the CALLER\'s bound user_key, not a fixed mount identity', + }, + cmds: [ + { + name: 'List', + method: 'POST', + path: '/', + h: 'List Meego tools (add_comment/list_comments/query_user/whoami)', + returns: 'ToolSpec[]', + scope: 'read', + }, + { + name: 'Get', + method: 'POST', + path: '/', + h: 'Get one tool spec by name', + returns: 'ToolSpec', + scope: 'read', + }, + { + name: 'Call', + method: 'POST', + path: '/', + h: 'Call a Meego tool as the caller\'s bound identity (providerConfig.userKeys[keyId])', + returns: 'ToolResult', + scope: 'call', + }, + ], +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function errorResponse(err: unknown): Response { + const tb = isTBError(err) + ? err + : new TBError('internal', err instanceof Error ? err.message : String(err)) + return json(tb.toJSON(), tb.httpStatus) +} + +async function handleEnvelope(req: Request, env: Env): Promise { + // 鉴权:Bearer 非空;配置了 PLUGIN_TOKEN 时还须逐字相等(platform-token 语义)。 + const auth = req.headers.get('authorization') ?? '' + const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '' + if (token === '') throw TBError.unauthenticated('missing Bearer token') + if (env.PLUGIN_TOKEN !== undefined && env.PLUGIN_TOKEN !== '' && token !== env.PLUGIN_TOKEN) { + throw TBError.unauthenticated('bad plugin token') + } + + const call = decodePluginCall(await req.text()) + const cred = upstreamCredential(req) + const ctxHeader = req.headers.get(HEADER_TB_CONTEXT) + if (ctxHeader === null || ctxHeader === '') { + throw new TBError('invalid_argument', `缺 ${HEADER_TB_CONTEXT}(操作人身份解析依赖 CallContext)`) + } + const ctx = decodeCallContext(ctxHeader) + const requestId = req.headers.get('x-tb-request-id') + const exec = (): Promise => invoke(env, cred, ctx, call.tool, call.arguments) + const result + = requestId !== null && requestId !== '' ? await dedupe.run(requestId, exec) : await exec() + return json(result ?? null) +} + +export default { + async fetch(req: Request, env: Env): Promise { + const url = new URL(req.url) + try { + if (req.method === 'GET') { + if (url.pathname === '/healthz') return json({ healthy: true }) + if (url.pathname === '/~describe') { + return json({ kind: KIND, interfaceVersion: INTERFACE_VERSION }) + } + if (url.pathname === '/~help') { + if (negotiate(req.headers.get('accept') ?? undefined) === 'json') { + return json(renderHelpJson(HELP)) + } + return new Response(renderHelpDsl(HELP), { + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + throw TBError.notFound(`no such path '${url.pathname}'`) + } + if (req.method === 'POST' && url.pathname === '/') return await handleEnvelope(req, env) + throw TBError.notFound(`no such route ${req.method} '${url.pathname}'`) + } catch (err) { + return errorResponse(err) + } + }, +} diff --git a/packages/plugin-meego/src/meegoApi.ts b/packages/plugin-meego/src/meegoApi.ts new file mode 100644 index 0000000..ce5d1b5 --- /dev/null +++ b/packages/plugin-meego/src/meegoApi.ts @@ -0,0 +1,156 @@ +/** + * 飞书项目(Meego)open_api 客户端(纯 fetch,形状对齐官方 project-oapi-sdk-golang v2)。 + * + * 请求头:`X-PLUGIN-TOKEN`(插件身份,pat.ts 换发)+ `X-USER-KEY`(**操作人身份**, + * 写操作以此落地为"谁评论/谁创建"——本 plugin 存在的全部意义)。 + * + * 响应统一形状 `{err_code,err_msg,data,...}`(err_code 0 = 成功);token 失效的信号是 + * HTTP 401 或 err_msg 明示 token 无效/过期——都归一为 `MeegoAuthError` 供调用方强制重换发重试。 + * 其余业务错误(无权限/参数错/不存在)归一 TBError 原样上抛,plugin 不重试。 + */ + +import { TBError } from '@tool-bridge/core' + +/** 鉴权失效标记:调用方捕获后强制重换发 PAT 重试一次。 */ +export class MeegoAuthError extends Error { + constructor(detail: string) { + super(detail) + this.name = 'MeegoAuthError' + } +} + +export function isMeegoAuthError(err: unknown): err is MeegoAuthError { + return err instanceof MeegoAuthError +} + +export interface MeegoApiConfig { + baseUrl: string + /** plugin_token(插件身份)。 */ + pat: string + /** 操作人 user_key;写接口必带(读接口部分也要求)。 */ + userKey: string +} + +interface MeegoEnvelope { + data?: unknown + err_code?: number + err_msg?: string + error?: { code?: number, msg?: string } + pagination?: unknown +} + +/** + * token 失效判定(触发强制重换发重试):HTTP 401,或 err_msg 明示 token 无效/过期。 + * 官方未公开稳定的鉴权错误码表(Go SDK 也不按码重试),按消息判定比猜错误码保守—— + * 误判为业务错误只是少一次重试,反向误判会把业务错误骗进重试循环。 + */ +function isTokenInvalidMsg(msg: string): boolean { + return /token/i.test(msg) && /(invalid|expire|失效|过期|无效)/i.test(msg) +} + +/** + * 单次 open_api 请求:2xx 且 err_code 0 → 返回整个响应体(data+pagination 由调用方取); + * 401/鉴权段 err_code → MeegoAuthError;其余按 TBError 归一。 + */ +async function request( + cfg: MeegoApiConfig, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: unknown, +): Promise { + let resp: Response + try { + resp = await fetch(`${cfg.baseUrl}${path}`, { + method, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'X-PLUGIN-TOKEN': cfg.pat, + 'X-USER-KEY': cfg.userKey, + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + } catch (err) { + throw new TBError( + 'unavailable', + `Meego open_api 网络失败:${err instanceof Error ? err.message : String(err)}`, + { retryable: true }, + ) + } + if (resp.status === 401) throw new MeegoAuthError('HTTP 401') + const parsed = (await resp.json().catch(() => null)) as MeegoEnvelope | null + if (parsed === null) { + throw new TBError('unavailable', `Meego open_api 响应非 JSON(HTTP ${resp.status})`, { + retryable: resp.status >= 500, + }) + } + const code = parsed.err_code ?? parsed.error?.code ?? 0 + const msg = parsed.err_msg ?? parsed.error?.msg ?? '' + if (code !== 0) { + if (isTokenInvalidMsg(msg)) throw new MeegoAuthError(`err_code=${code} ${msg}`) + throw new TBError( + 'invalid_argument', + `Meego open_api 业务错误:err_code=${code} ${msg}(路径 ${path})`.trim(), + ) + } + if (!resp.ok) { + throw new TBError('unavailable', `Meego open_api HTTP ${resp.status}(路径 ${path})`, { + retryable: resp.status >= 500, + }) + } + return parsed +} + +// ---------- 评论 ---------- + +export interface AddCommentArgs { + content: string + projectKey: string + workItemId: number + workItemTypeKey: string +} + +/** POST /open_api/:project_key/work_item/:work_item_type_key/:work_item_id/comment/create → 评论 id。 */ +export async function addComment(cfg: MeegoApiConfig, args: AddCommentArgs): Promise { + const path = `/open_api/${encodeURIComponent(args.projectKey)}/work_item/${encodeURIComponent(args.workItemTypeKey)}/${args.workItemId}/comment/create` + const resp = await request(cfg, 'POST', path, { content: args.content }) + return { comment_id: resp.data } +} + +export interface ListCommentsArgs { + pageNum?: number + pageSize?: number + projectKey: string + workItemId: number + workItemTypeKey: string +} + +/** GET .../comments → 评论列表(含 pagination)。 */ +export async function listComments(cfg: MeegoApiConfig, args: ListCommentsArgs): Promise { + const qs = new URLSearchParams() + if (args.pageNum !== undefined) qs.set('page_num', String(args.pageNum)) + if (args.pageSize !== undefined) qs.set('page_size', String(args.pageSize)) + const qsText = qs.toString() + const query = qsText === '' ? '' : `?${qsText}` + const path = `/open_api/${encodeURIComponent(args.projectKey)}/work_item/${encodeURIComponent(args.workItemTypeKey)}/${args.workItemId}/comments${query}` + const resp = await request(cfg, 'GET', path) + return { comments: resp.data, pagination: resp.pagination } +} + +// ---------- 用户 ---------- + +export interface QueryUserArgs { + emails?: string[] + userKeys?: string[] +} + +/** POST /open_api/user/query → 用户详情(user_key/name_cn/email 等;邮箱→user_key 反查用)。 */ +export async function queryUser(cfg: MeegoApiConfig, args: QueryUserArgs): Promise { + const body: Record = {} + if (args.userKeys !== undefined && args.userKeys.length > 0) body.user_keys = args.userKeys + if (args.emails !== undefined && args.emails.length > 0) body.emails = args.emails + if (Object.keys(body).length === 0) { + throw new TBError('invalid_argument', 'query_user 需要 user_keys 或 emails 至少一项') + } + const resp = await request(cfg, 'POST', '/open_api/user/query', body) + return { users: resp.data } +} diff --git a/packages/plugin-meego/src/pat.ts b/packages/plugin-meego/src/pat.ts new file mode 100644 index 0000000..590ee44 --- /dev/null +++ b/packages/plugin-meego/src/pat.ts @@ -0,0 +1,88 @@ +/** + * 飞书项目(Meego)plugin_token(PAT)换发与进程内缓存。 + * + * - 凭证(plugin_id/plugin_secret)不由 plugin 自持:每次调用由平台经 X-TB-Upstream-Auth + * 传入(挂载 config.authRef 平台代解析)——同一 plugin 部署可服务不同凭证的挂载, + * 故缓存**按 plugin_id 键控**(模式同 plugin-feishu/tat.ts)。 + * - 换发:POST {domain}/open_api/authen/plugin_token,body {plugin_id,plugin_secret,type:0} + * (type 0=正式 plugin_token,1=虚拟 token 仅调试);响应 {error:{code,msg},data:{token,expire_time}}, + * expire_time 单位秒(约 7200)。 + * - 缓存在 isolate 内存(无 KV):isolate 回收即重换发;换发是幂等轻请求,不值得引入持久层。 + * - 刷新余量 5min:调用时刻剩余不足余量即懒换发。 + * - `force` 绕过缓存直接换发——上游 401 的纠错路径不得回读缓存(教训同网关 mcp + * 会话空列表防御:凡纠错路径都绕开缓存读)。 + */ + +import { TBError } from '@tool-bridge/core' + +/** 飞书项目 open_api 域名(私有化部署经 MEEGO_BASE_URL override)。 */ +export const DEFAULT_BASE_URL = 'https://project.feishu.cn' + +export const PLUGIN_TOKEN_PATH = '/open_api/authen/plugin_token' + +const REFRESH_MARGIN_MS = 5 * 60_000 + +interface CachedPat { + expiresAtMs: number + token: string +} + +const cache = new Map() + +/** 测试用:清空进程内 PAT 缓存。 */ +export function clearPatCache(): void { + cache.clear() +} + +export interface PatConfig { + /** 换发端点 base override(测试/私有化部署);缺省飞书项目公网域名。 */ + baseUrl?: string + pluginId: string + pluginSecret: string +} + +interface PatResponse { + data?: { expire_time?: number, token?: string } + error?: { code?: number, msg?: string } +} + +/** 取可用 PAT:该 plugin_id 的缓存余量充足直接返回,否则换发并回填。 */ +export async function pluginAccessToken(cfg: PatConfig, force = false): Promise { + const cached = cache.get(cfg.pluginId) + if (!force && cached !== undefined && cached.expiresAtMs - Date.now() > REFRESH_MARGIN_MS) { + return cached.token + } + let resp: Response + try { + resp = await fetch(`${cfg.baseUrl ?? DEFAULT_BASE_URL}${PLUGIN_TOKEN_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ + plugin_id: cfg.pluginId, + plugin_secret: cfg.pluginSecret, + type: 0, + }), + }) + } catch (err) { + throw new TBError( + 'unavailable', + `Meego plugin_token 换发网络失败:${err instanceof Error ? err.message : String(err)}`, + { retryable: true }, + ) + } + const body = (await resp.json().catch(() => null)) as PatResponse | null + const token = body?.data?.token + if (!resp.ok || body === null || body.error?.code !== 0 || typeof token !== 'string') { + throw new TBError( + 'unavailable', + `Meego plugin_token 换发失败:HTTP ${resp.status} code=${body?.error?.code ?? '?'} ${body?.error?.msg ?? ''}`.trim(), + { retryable: false }, + ) + } + const expireSec = typeof body.data?.expire_time === 'number' ? body.data.expire_time : 0 + cache.set(cfg.pluginId, { + token, + expiresAtMs: Date.now() + expireSec * 1000, + }) + return token +} diff --git a/packages/plugin-meego/test/env.d.ts b/packages/plugin-meego/test/env.d.ts new file mode 100644 index 0000000..5a1c30d --- /dev/null +++ b/packages/plugin-meego/test/env.d.ts @@ -0,0 +1,4 @@ +/// + +// vitest-pool-workers 0.18 把 `cloudflare:test` 模块声明(SELF、env 等)移到 /types 子路径。 +// 此引用让 test/ 下的 `import { SELF } from 'cloudflare:test'` 获得类型。 diff --git a/packages/plugin-meego/test/plugin.integration.test.ts b/packages/plugin-meego/test/plugin.integration.test.ts new file mode 100644 index 0000000..4d6e41f --- /dev/null +++ b/packages/plugin-meego/test/plugin.integration.test.ts @@ -0,0 +1,374 @@ +import { + base64urlEncode, + encodeCallContext, + HEADER_TB_CONTEXT, + HEADER_TB_UPSTREAM_AUTH, +} from '@tool-bridge/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SELF } from 'cloudflare:test' +import { clearPatCache } from '../src/pat' + +// plugin-meego 集成测试:契约面 / envelope 鉴权 / 凭证经 X-TB-Upstream-Auth 传入 / +// **操作人身份按 keyId 从 mountConfig.userKeys 解析**(核心行为)/ PAT 换发缓存 / +// 鉴权失效强制重换发。Meego open_api 全部 fetch mock,默认离线确定性。 + +const BASE = 'https://meego-api.mock' +const PLUGIN_TOKEN = 'tbp_test_token' +const PROJECT_KEY = '6a4226868b7eed94090347eb' + +/** 平台注入形态:base64url JSON {plugin_id,plugin_secret}。 */ +function upstreamAuth(pluginId = 'MII_test_plugin', pluginSecret = 'test_secret'): string { + return base64urlEncode( + new TextEncoder().encode(JSON.stringify({ plugin_id: pluginId, plugin_secret: pluginSecret })), + ) +} + +/** X-TB-Context:keyId + 挂载 providerConfig(userKeys 映射)。 */ +function tbContext(keyId: string, userKeys?: Record): string { + return encodeCallContext({ + keyId, + owner: 'user:test', + scopes: [{ pattern: '**', actions: ['call'] }], + traceId: 'trace-test', + mountPath: 'mcp/meego2', + ...(userKeys !== undefined ? { mountConfig: { userKeys } } : {}), + }) +} + +/** + * Meego open_api mock:plugin_token 换发(签发递增 token)+ comment/create(校验 + * X-PLUGIN-TOKEN ∈ 有效集,记录 X-USER-KEY)+ user/query。`revokeAllTokens()` 模拟 + * PAT 被吊销:老 token 一律 err_msg 'token expired'。 + */ +function meegoMock() { + const validTokens = new Set() + let tokenSeq = 0 + let authCalls = 0 + const commentCalls: Array<{ path: string, userKey: string | null }> = [] + + const ok = (data: unknown, extra: Record = {}) => + new Response(JSON.stringify({ err_code: 0, err_msg: '', data, ...extra }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.origin !== BASE) return new Response('unexpected upstream', { status: 500 }) + + if (url.pathname === '/open_api/authen/plugin_token') { + authCalls += 1 + const body = JSON.parse(String(init?.body)) as { + plugin_id?: string + plugin_secret?: string + type?: number + } + if (!body.plugin_id || body.plugin_secret !== 'test_secret' || body.type !== 0) { + return new Response( + JSON.stringify({ error: { code: 10003, msg: 'invalid plugin credential' } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + tokenSeq += 1 + const token = `pat-${body.plugin_id}-${tokenSeq}` + validTokens.add(token) + return new Response( + JSON.stringify({ error: { code: 0, msg: '' }, data: { token, expire_time: 7200 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + + const headers = new Headers(init?.headers) + const pat = headers.get('X-PLUGIN-TOKEN') + if (pat === null || !validTokens.has(pat)) { + return new Response( + JSON.stringify({ err_code: 10009, err_msg: 'plugin token expired or invalid' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + + if (url.pathname.endsWith('/comment/create')) { + commentCalls.push({ path: url.pathname, userKey: headers.get('X-USER-KEY') }) + return ok(9527) + } + if (url.pathname.endsWith('/comments')) { + return ok( + [{ id: 9527, operator: headers.get('X-USER-KEY'), content: 'hi' }], + { pagination: { page_num: 1, page_size: 50, total: 1 } }, + ) + } + if (url.pathname === '/open_api/user/query') { + const body = JSON.parse(String(init?.body)) as { emails?: string[], user_keys?: string[] } + const keys = body.user_keys ?? (body.emails ?? []).map(e => `key-of-${e}`) + return ok(keys.map(k => ({ user_key: k, name_cn: `用户${k}`, email: `${k}@x.com` }))) + } + return new Response('no such mock path', { status: 404 }) + }) + + return { + fetchMock, + authCalls: () => authCalls, + commentCalls, + revokeAllTokens: () => validTokens.clear(), + } +} + +const USER_KEYS = { 'key-alice': 'uk_alice_001', 'key-bob': 'uk_bob_002' } + +async function envelope( + tool: string, + args: Record, + init: RequestInit = {}, +): Promise { + return SELF.fetch('https://plugin.test/', { + method: 'POST', + ...init, + headers: { + 'content-type': 'application/json', + 'authorization': `Bearer ${PLUGIN_TOKEN}`, + 'x-tb-request-id': crypto.randomUUID(), + [HEADER_TB_UPSTREAM_AUTH]: upstreamAuth(), + [HEADER_TB_CONTEXT]: tbContext('key-alice', USER_KEYS), + ...(init.headers ?? {}), + }, + body: JSON.stringify({ tool, arguments: args }), + }) +} + +function addCommentArgs(content = 'looks good'): Record { + return { + name: 'add_comment', + args: { + project_key: PROJECT_KEY, + work_item_type_key: 'issue', + work_item_id: 4321, + content, + }, + } +} + +beforeEach(() => { + clearPatCache() +}) + +describe('契约面(生命周期 GET,不鉴权)', () => { + it('healthz / ~describe / ~help 形状符合 tool-provider/v1', async () => { + const health = await SELF.fetch('https://plugin.test/healthz') + expect(health.status).toBe(200) + expect(await health.json()).toEqual({ healthy: true }) + + const describeRes = await SELF.fetch('https://plugin.test/~describe') + expect(await describeRes.json()).toEqual({ + kind: 'tool-provider', + interfaceVersion: 'tool-provider/v1', + }) + + const helpJson = await SELF.fetch('https://plugin.test/~help', { + headers: { accept: 'application/json' }, + }) + const cmds = ((await helpJson.json()) as { cmds: Array<{ name: string }> }).cmds + expect(cmds.map(c => c.name).sort()).toEqual(['Call', 'Get', 'List']) + }) +}) + +describe('envelope 鉴权与上下文', () => { + it('无 / 错 Bearer → 401', async () => { + const none = await envelope('List', {}, { headers: { authorization: '' } }) + expect(none.status).toBe(401) + const bad = await envelope('List', {}, { headers: { authorization: 'Bearer wrong' } }) + expect(bad.status).toBe(401) + }) + + it('缺 X-TB-Context → 400(操作人解析依赖 CallContext)', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + const res = await envelope('Call', addCommentArgs(), { + headers: { [HEADER_TB_CONTEXT]: '' }, + }) + expect(res.status).toBe(400) + }) + + it('缺 X-TB-Upstream-Auth → 503(挂载缺 authRef 是配置错误)', async () => { + const res = await envelope('Call', addCommentArgs(), { + headers: { [HEADER_TB_UPSTREAM_AUTH]: '' }, + }) + expect(res.status).toBe(503) + }) +}) + +describe('List / Get', () => { + it('List 返回四工具;add_comment 标 destructive、list_comments 标 read', async () => { + const res = await envelope('List', {}) + expect(res.status).toBe(200) + const tools = (await res.json()) as Array<{ effect?: string, name: string }> + expect(tools.map(t => t.name).sort()).toEqual([ + 'add_comment', + 'list_comments', + 'query_user', + 'whoami', + ]) + expect(tools.find(t => t.name === 'add_comment')?.effect).toBe('destructive') + expect(tools.find(t => t.name === 'list_comments')?.effect).toBe('read') + }) + + it('Get 未知工具 → 404', async () => { + const res = await envelope('Get', { name: 'nope' }) + expect(res.status).toBe(404) + }) +}) + +describe('操作人身份(核心行为)', () => { + it('add_comment 以调用方绑定的 user_key 落地 X-USER-KEY', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', addCommentArgs()) + expect(res.status).toBe(200) + const body = (await res.json()) as { content: { comment_id: number } } + expect(body.content.comment_id).toBe(9527) + expect(upstream.commentCalls).toHaveLength(1) + expect(upstream.commentCalls[0]?.userKey).toBe('uk_alice_001') + expect(upstream.commentCalls[0]?.path).toBe( + `/open_api/${PROJECT_KEY}/work_item/issue/4321/comment/create`, + ) + }) + + it('不同 keyId → 不同 X-USER-KEY(同一挂载多身份)', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', addCommentArgs(), { + headers: { [HEADER_TB_CONTEXT]: tbContext('key-bob', USER_KEYS) }, + }) + expect(res.status).toBe(200) + expect(upstream.commentCalls[0]?.userKey).toBe('uk_bob_002') + }) + + it('keyId 未绑定 → 403 permission_denied,不回落默认身份、零上游请求', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', addCommentArgs(), { + headers: { [HEADER_TB_CONTEXT]: tbContext('key-stranger', USER_KEYS) }, + }) + expect(res.status).toBe(403) + const body = (await res.json()) as { code: string, message: string } + expect(body.code).toBe('permission_denied') + expect(body.message).toContain('key-stranger') + expect(upstream.commentCalls).toHaveLength(0) + expect(upstream.authCalls()).toBe(0) + }) + + it('mountConfig 整体缺失(挂载没配 userKeys)→ 403', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', addCommentArgs(), { + headers: { [HEADER_TB_CONTEXT]: tbContext('key-alice') }, + }) + expect(res.status).toBe(403) + }) + + it('whoami 返回绑定身份与用户详情', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', { name: 'whoami', args: {} }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + content: { key_id: string, user_key: string, users: Array<{ user_key: string }> } + } + expect(body.content.key_id).toBe('key-alice') + expect(body.content.user_key).toBe('uk_alice_001') + expect(body.content.users[0]?.user_key).toBe('uk_alice_001') + }) +}) + +describe('PAT 换发缓存与失效重试', () => { + it('同 plugin_id 连续调用只换发一次;PAT 吊销后强制重换发重试成功', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + await envelope('Call', addCommentArgs('one')) + await envelope('Call', addCommentArgs('two')) + expect(upstream.authCalls()).toBe(1) // 第二次走缓存 + + upstream.revokeAllTokens() + const res = await envelope('Call', addCommentArgs('three')) + expect(res.status).toBe(200) // err_msg 'token expired' → 强制重换发重试 + expect(upstream.authCalls()).toBe(2) + expect(upstream.commentCalls).toHaveLength(3) + }) + + it('凭证坏(换发失败)→ 503', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', addCommentArgs(), { + headers: { [HEADER_TB_UPSTREAM_AUTH]: upstreamAuth('MII_test_plugin', 'wrong_secret') }, + }) + expect(res.status).toBe(503) + }) +}) + +describe('query_user / list_comments', () => { + it('query_user 按邮箱反查 user_key(绑定新同学的入口)', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', { + name: 'query_user', + args: { emails: ['carol@corp.com'] }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { content: { users: Array<{ user_key: string }> } } + expect(body.content.users[0]?.user_key).toBe('key-of-carol@corp.com') + }) + + it('query_user 两参数全缺 → 400', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + const res = await envelope('Call', { name: 'query_user', args: {} }) + expect(res.status).toBe(400) + }) + + it('list_comments 透传分页并返回 operator', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', { + name: 'list_comments', + args: { + project_key: PROJECT_KEY, + work_item_type_key: 'issue', + work_item_id: 4321, + page_num: 1, + page_size: 10, + }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + content: { comments: Array<{ operator: string }>, pagination: { total: number } } + } + expect(body.content.comments[0]?.operator).toBe('uk_alice_001') + expect(body.content.pagination.total).toBe(1) + }) +}) + +describe('幂等去重', () => { + it('同 X-TB-Request-Id 重放不重复评论', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const requestId = crypto.randomUUID() + const first = await envelope('Call', addCommentArgs(), { + headers: { 'x-tb-request-id': requestId }, + }) + expect(first.status).toBe(200) + const replay = await envelope('Call', addCommentArgs(), { + headers: { 'x-tb-request-id': requestId }, + }) + expect(replay.status).toBe(200) + expect(upstream.commentCalls).toHaveLength(1) // 重放,零上游请求 + }) +}) diff --git a/packages/plugin-meego/tsconfig.json b/packages/plugin-meego/tsconfig.json new file mode 100644 index 0000000..2fc80e8 --- /dev/null +++ b/packages/plugin-meego/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/plugin-meego/vitest.config.ts b/packages/plugin-meego/vitest.config.ts new file mode 100644 index 0000000..d033bc0 --- /dev/null +++ b/packages/plugin-meego/vitest.config.ts @@ -0,0 +1,18 @@ +import { cloudflareTest } from '@cloudflare/vitest-pool-workers' +import { defineConfig } from 'vitest/config' + +// 集成测试跑在真实 workerd(与 gateway/plugin-feishu 同基建);Meego open_api 全部 +// fetch mock,默认离线确定性。测试凭证经 miniflare.bindings 注入。 +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: './wrangler.jsonc' }, + miniflare: { + bindings: { + PLUGIN_TOKEN: 'tbp_test_token', + MEEGO_BASE_URL: 'https://meego-api.mock', + }, + }, + }), + ], +}) diff --git a/packages/plugin-meego/wrangler.jsonc b/packages/plugin-meego/wrangler.jsonc new file mode 100644 index 0000000..618a417 --- /dev/null +++ b/packages/plugin-meego/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + // Meego tool-provider plugin Worker。secrets:PLUGIN_TOKEN(注册后 wrangler secret put); + // Meego 凭证(plugin_id/plugin_secret)不在此配——存平台 SecretStore,经 X-TB-Upstream-Auth 每次传入。 + "$schema": "node_modules/wrangler/config-schema.json", + "name": "tb-plugin-meego", + "main": "src/index.ts", + "compatibility_date": "2025-06-01", + "compatibility_flags": ["nodejs_compat"], + // DJJ 账户(与 gateway 同;wrangler OAuth 多账户须显式指定)。 + "account_id": "0cb9b897010bc426cd8bf33d8c052d11", + "observability": { + "enabled": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73417ce..ecbcd1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: devDependencies: - '@biomejs/biome': - specifier: ^2.5.2 - version: 2.5.2 '@eslint/js': specifier: ^9.39.5 version: 9.39.5 @@ -271,6 +268,28 @@ importers: specifier: ^3.25.76 version: 3.25.76 + packages/plugin-meego: + dependencies: + '@tool-bridge/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: ^0.18.0 + version: 0.18.0(@cloudflare/workers-types@5.20260706.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))) + '@cloudflare/workers-types': + specifier: ^5.20260706.1 + version: 5.20260706.1 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + wrangler: + specifier: ^4.107.0 + version: 4.107.0(@cloudflare/workers-types@5.20260706.1) + zod: + specifier: ^3.25.76 + version: 3.25.76 + packages/sdk: dependencies: '@cfworker/json-schema': @@ -435,63 +454,6 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.2': - resolution: {integrity: sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@2.5.2': - resolution: {integrity: sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@2.5.2': - resolution: {integrity: sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@2.5.2': - resolution: {integrity: sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-arm64@2.5.2': - resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-linux-x64-musl@2.5.2': - resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-x64@2.5.2': - resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-win32-arm64@2.5.2': - resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@2.5.2': - resolution: {integrity: sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -1002,105 +964,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1943,7 +1889,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.4': resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} @@ -1957,28 +1902,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.4': resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.4': resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.4': resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.4': resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} @@ -2040,79 +1981,66 @@ packages: resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.2': resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.2': resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.2': resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.2': resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.2': resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.2': resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.2': resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.2': resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.2': resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.2': resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.2': resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.2': resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.2': resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} @@ -2208,28 +2136,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.2': resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.2': resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.2': resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.2': resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} @@ -3777,28 +3701,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5335,41 +5255,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@biomejs/biome@2.5.2': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.2 - '@biomejs/cli-darwin-x64': 2.5.2 - '@biomejs/cli-linux-arm64': 2.5.2 - '@biomejs/cli-linux-arm64-musl': 2.5.2 - '@biomejs/cli-linux-x64': 2.5.2 - '@biomejs/cli-linux-x64-musl': 2.5.2 - '@biomejs/cli-win32-arm64': 2.5.2 - '@biomejs/cli-win32-x64': 2.5.2 - - '@biomejs/cli-darwin-arm64@2.5.2': - optional: true - - '@biomejs/cli-darwin-x64@2.5.2': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.5.2': - optional: true - - '@biomejs/cli-linux-arm64@2.5.2': - optional: true - - '@biomejs/cli-linux-x64-musl@2.5.2': - optional: true - - '@biomejs/cli-linux-x64@2.5.2': - optional: true - - '@biomejs/cli-win32-arm64@2.5.2': - optional: true - - '@biomejs/cli-win32-x64@2.5.2': - optional: true - '@cfworker/json-schema@4.1.1': {} '@cloudflare/kv-asset-handler@0.5.0': {} From 50f74af089aa62f4a41bfc311388546d8d78502d Mon Sep 17 00:00:00 2001 From: fenglei Date: Thu, 23 Jul 2026 20:38:55 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(plugin-meego):=20account=5Fid=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E7=94=9F=E4=BA=A7=20Lightspeed=20=E8=B4=A6?= =?UTF-8?q?=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分支初始写的 DJJ account_id 是过时值;生产网关(tb-gateway)与 plugin-feishu 实际都在 Lightspeed 账户(041d4868…),plugin-meego 须同账户部署才能被网关注册/注入凭证。已按此账户部署验证通过。 Co-Authored-By: Claude Fable 5 --- packages/plugin-meego/wrangler.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-meego/wrangler.jsonc b/packages/plugin-meego/wrangler.jsonc index 618a417..47e375a 100644 --- a/packages/plugin-meego/wrangler.jsonc +++ b/packages/plugin-meego/wrangler.jsonc @@ -6,8 +6,8 @@ "main": "src/index.ts", "compatibility_date": "2025-06-01", "compatibility_flags": ["nodejs_compat"], - // DJJ 账户(与 gateway 同;wrangler OAuth 多账户须显式指定)。 - "account_id": "0cb9b897010bc426cd8bf33d8c052d11", + // Lightspeed 账户(与 gateway/plugin-feishu 同;wrangler OAuth 多账户须显式指定)。 + "account_id": "041d4868e5611b45f9959f4f58c1e4c7", "observability": { "enabled": true } From 37a91ca52a1233f6b0e53c3cb7226a69ae73a65b Mon Sep 17 00:00:00 2001 From: fenglei Date: Fri, 24 Jul 2026 06:29:38 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(plugin-meego):=20=E5=8A=A0=20filter=5F?= =?UTF-8?q?work=5Fitems,=E4=B8=80=E4=B8=AA=E8=8A=82=E7=82=B9=E9=80=9A?= =?UTF-8?q?=E5=90=83=E6=9F=A5+=E8=AF=84=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meego2 此前只有评论相关工具,查工作项还得绕回旧 mcp/meego(固定纪世赫 身份)。新增 filter_work_items 封装 open_api /work_item/filter: - user_keys 内建"该用户全角色并集"(经办人/研发/负责人),替代旧 MQL 手写三路 OR;实测查王风雷名下缺陷 22 条,与 MQL 结果完全一致。 - 探测确认 open_api 无 MQL 端点(旧 meego 的 search_by_mql 是飞书 MCP 层封装),故底层走 filter、命名 filter_work_items 避免误导。 - 查询按字段筛,与调用方身份无关;X-USER-KEY 仅用于鉴权。 - 顺带修正 add_comment 描述:实现只发纯文本,原"markdown/富文本"措辞 与实现不符,改为"纯文本"。 至此 mcp/meego2 覆盖查(需求/缺陷)+评论,具备下掉旧 mcp/meego 的条件。 测试 17→20。 Co-Authored-By: Claude Fable 5 --- packages/plugin-meego/src/index.ts | 53 ++++++++++++- packages/plugin-meego/src/meegoApi.ts | 35 +++++++++ .../test/plugin.integration.test.ts | 76 ++++++++++++++++++- 3 files changed, 161 insertions(+), 3 deletions(-) diff --git a/packages/plugin-meego/src/index.ts b/packages/plugin-meego/src/index.ts index 234b3b2..9fc3109 100644 --- a/packages/plugin-meego/src/index.ts +++ b/packages/plugin-meego/src/index.ts @@ -44,6 +44,7 @@ import { } from '@tool-bridge/core' import { addComment, + filterWorkItems, isMeegoAuthError, listComments, type MeegoApiConfig, @@ -130,13 +131,13 @@ const TOOLS: ToolSpec[] = [ { name: 'add_comment', description: - '给工作项添加评论(markdown/富文本)。评论以**调用方绑定的 user_key 身份**落地——谁调用显示谁,不再固定为挂载者。', + '给工作项添加评论(纯文本)。评论以**调用方绑定的 user_key 身份**落地——谁调用显示谁,不再固定为挂载者。', effect: 'destructive', inputSchema: { type: 'object', properties: { ...WORK_ITEM_SCHEMA_PROPS, - content: { type: 'string', description: '评论内容' }, + content: { type: 'string', description: '评论内容(纯文本)' }, }, required: ['project_key', 'work_item_type_key', 'work_item_id', 'content'], }, @@ -174,6 +175,35 @@ const TOOLS: ToolSpec[] = [ effect: 'read', inputSchema: { type: 'object', properties: {} }, }, + { + name: 'filter_work_items', + description: + '按条件查工作项(需求/缺陷等)。user_keys 传某人 user_key = 查其名下工作项——open_api 内建全角色并集(经办人/研发/负责人),无需手写 MQL 三路 OR。返回含 work_item_status(状态)。', + effect: 'read', + inputSchema: { + type: 'object', + properties: { + project_key: { + type: 'string', + description: '空间 projectKey(如 tipsy chat = 6a4226868b7eed94090347eb)', + }, + work_item_type_keys: { + type: 'array', + items: { type: 'string' }, + description: '工作项类型 key(缺陷=issue,需求=story;可多选)', + }, + user_keys: { + type: 'array', + items: { type: 'string' }, + description: '按人筛(该用户名下全角色);查"我名下"传自己 user_key(见 whoami)', + }, + work_item_name: { type: 'string', description: '按名称模糊筛' }, + page_num: { type: 'number', description: '页码(1 起)' }, + page_size: { type: 'number', description: '每页条数(默认 50)' }, + }, + required: ['project_key'], + }, + }, ] // ---------- 参数校验 ---------- @@ -290,6 +320,25 @@ async function callTool( queryUser(cfg, { userKeys: [userKey] })) return { key_id: ctx.keyId, user_key: userKey, ...(detail as object) } } + case 'filter_work_items': { + const userKey = resolveUserKey(ctx) + const userKeys = strArray(args, 'user_keys') + const workItemTypeKeys = strArray(args, 'work_item_type_keys') + const pageNum = optNum(args, 'page_num') + const pageSize = optNum(args, 'page_size') + const workItemName = args.work_item_name + return withPatRetry(env, cred, userKey, cfg => + filterWorkItems(cfg, { + projectKey: str(args, 'project_key'), + ...(userKeys !== undefined ? { userKeys } : {}), + ...(workItemTypeKeys !== undefined ? { workItemTypeKeys } : {}), + ...(typeof workItemName === 'string' && workItemName !== '' + ? { workItemName } + : {}), + ...(pageNum !== undefined ? { pageNum } : {}), + ...(pageSize !== undefined ? { pageSize } : {}), + })) + } default: throw TBError.notFound(`未知工具:'${name}'`) } diff --git a/packages/plugin-meego/src/meegoApi.ts b/packages/plugin-meego/src/meegoApi.ts index ce5d1b5..a08095e 100644 --- a/packages/plugin-meego/src/meegoApi.ts +++ b/packages/plugin-meego/src/meegoApi.ts @@ -154,3 +154,38 @@ export async function queryUser(cfg: MeegoApiConfig, args: QueryUserArgs): Promi const resp = await request(cfg, 'POST', '/open_api/user/query', body) return { users: resp.data } } + +// ---------- 工作项查询 ---------- + +export interface FilterWorkItemsArgs { + pageNum?: number + pageSize?: number + projectKey: string + /** 按人筛:open_api filter 的 user_keys 内建"该用户全角色并集"(经办人/研发/负责人等),无需手写 OR。 */ + userKeys?: string[] + workItemName?: string + workItemTypeKeys?: string[] +} + +/** + * POST /open_api/:project_key/work_item/filter → 工作项列表(含 work_item_status)。 + * 替代旧 meego 的 search_by_mql "按人查名下工作项":user_keys 由 open_api 内建全角色并集, + * 比手写 `__经办人 OR __研发 OR 当前负责人` 更准(不同工作项类型角色不同,后端已算好)。 + * 查询按字段筛,与调用方身份无关;X-USER-KEY 仅用于鉴权。 + */ +export async function filterWorkItems( + cfg: MeegoApiConfig, + args: FilterWorkItemsArgs, +): Promise { + const body: Record = {} + if (args.workItemName !== undefined) body.work_item_name = args.workItemName + if (args.userKeys !== undefined && args.userKeys.length > 0) body.user_keys = args.userKeys + if (args.workItemTypeKeys !== undefined && args.workItemTypeKeys.length > 0) { + body.work_item_type_keys = args.workItemTypeKeys + } + if (args.pageNum !== undefined) body.page_num = args.pageNum + if (args.pageSize !== undefined) body.page_size = args.pageSize + const path = `/open_api/${encodeURIComponent(args.projectKey)}/work_item/filter` + const resp = await request(cfg, 'POST', path, body) + return { work_items: resp.data, pagination: resp.pagination } +} diff --git a/packages/plugin-meego/test/plugin.integration.test.ts b/packages/plugin-meego/test/plugin.integration.test.ts index 4d6e41f..8805cb4 100644 --- a/packages/plugin-meego/test/plugin.integration.test.ts +++ b/packages/plugin-meego/test/plugin.integration.test.ts @@ -45,6 +45,10 @@ function meegoMock() { let tokenSeq = 0 let authCalls = 0 const commentCalls: Array<{ path: string, userKey: string | null }> = [] + const filterCalls: Array<{ + body: { user_keys?: string[], work_item_type_keys?: string[] } + userKey: string | null + }> = [] const ok = (data: unknown, extra: Record = {}) => new Response(JSON.stringify({ err_code: 0, err_msg: '', data, ...extra }), { @@ -102,6 +106,21 @@ function meegoMock() { const keys = body.user_keys ?? (body.emails ?? []).map(e => `key-of-${e}`) return ok(keys.map(k => ({ user_key: k, name_cn: `用户${k}`, email: `${k}@x.com` }))) } + if (url.pathname.endsWith('/work_item/filter')) { + const body = JSON.parse(String(init?.body)) as { + user_keys?: string[] + work_item_type_keys?: string[] + } + filterCalls.push({ body, userKey: headers.get('X-USER-KEY') }) + // 回两条,一条 OPEN 一条 RESOLVED,供状态筛选断言 + return ok( + [ + { id: 111, name: '缺陷A', work_item_status: { state_key: 'OPEN' } }, + { id: 222, name: '缺陷B', work_item_status: { state_key: 'RESOLVED' } }, + ], + { pagination: { page_num: 1, page_size: 50, total: 2 } }, + ) + } return new Response('no such mock path', { status: 404 }) }) @@ -109,6 +128,7 @@ function meegoMock() { fetchMock, authCalls: () => authCalls, commentCalls, + filterCalls, revokeAllTokens: () => validTokens.clear(), } } @@ -197,18 +217,20 @@ describe('envelope 鉴权与上下文', () => { }) describe('List / Get', () => { - it('List 返回四工具;add_comment 标 destructive、list_comments 标 read', async () => { + it('List 返回五工具;add_comment 标 destructive、filter_work_items 标 read', async () => { const res = await envelope('List', {}) expect(res.status).toBe(200) const tools = (await res.json()) as Array<{ effect?: string, name: string }> expect(tools.map(t => t.name).sort()).toEqual([ 'add_comment', + 'filter_work_items', 'list_comments', 'query_user', 'whoami', ]) expect(tools.find(t => t.name === 'add_comment')?.effect).toBe('destructive') expect(tools.find(t => t.name === 'list_comments')?.effect).toBe('read') + expect(tools.find(t => t.name === 'filter_work_items')?.effect).toBe('read') }) it('Get 未知工具 → 404', async () => { @@ -355,6 +377,58 @@ describe('query_user / list_comments', () => { }) }) +describe('filter_work_items(按人查工作项)', () => { + it('透传 user_keys/type,返回工作项含状态', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + + const res = await envelope('Call', { + name: 'filter_work_items', + args: { + project_key: PROJECT_KEY, + work_item_type_keys: ['issue'], + user_keys: ['uk_target_person'], + page_size: 50, + }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + content: { + pagination: { total: number } + work_items: Array<{ id: number, work_item_status: { state_key: string } }> + } + } + // 透传给 open_api 的筛选条件正确 + expect(upstream.filterCalls).toHaveLength(1) + expect(upstream.filterCalls[0]?.body.user_keys).toEqual(['uk_target_person']) + expect(upstream.filterCalls[0]?.body.work_item_type_keys).toEqual(['issue']) + // 返回工作项带状态,供未处理筛选 + expect(body.content.work_items.map(w => w.work_item_status.state_key)).toEqual([ + 'OPEN', + 'RESOLVED', + ]) + expect(body.content.pagination.total).toBe(2) + }) + + it('缺 project_key → 400', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + const res = await envelope('Call', { name: 'filter_work_items', args: {} }) + expect(res.status).toBe(400) + }) + + it('未绑定 key → 403,不打上游', async () => { + const upstream = meegoMock() + vi.stubGlobal('fetch', upstream.fetchMock) + const res = await envelope('Call', { + name: 'filter_work_items', + args: { project_key: PROJECT_KEY }, + }, { headers: { [HEADER_TB_CONTEXT]: tbContext('key-stranger', USER_KEYS) } }) + expect(res.status).toBe(403) + expect(upstream.filterCalls).toHaveLength(0) + }) +}) + describe('幂等去重', () => { it('同 X-TB-Request-Id 重放不重复评论', async () => { const upstream = meegoMock() From a07d7b845ff337cd4c2bff880f702bdb9ff7f6a4 Mon Sep 17 00:00:00 2001 From: evilstar9527 Date: Fri, 24 Jul 2026 16:50:02 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(deploy):=20=E5=8A=A0=20tb-pod-diag=20?= =?UTF-8?q?=E2=80=94=20=E9=9B=86=E7=BE=A4=E5=86=85=E5=8F=AA=E8=AF=BB=20K8s?= =?UTF-8?q?=20=E8=AF=8A=E6=96=AD,=E5=8F=8D=E5=90=91=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=88=B0=20tool-bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只读排查 pod(logs/events/describe/status/list/yaml),无 shell/exec/kubectl; 授权面为 namespace-scoped Role(pods/pods/log/events 的 get/list),结构性只读。 经 @tool-bridge/sdk 反向连接挂到 device//tools/pod-diag,纯 HTTP 可调。 --- deploy/k8s/pod-diag.yaml | 97 ++ deploy/k8s/pod-diag/Dockerfile | 20 + deploy/k8s/pod-diag/README.md | 95 ++ deploy/k8s/pod-diag/package-lock.json | 1856 +++++++++++++++++++++++++ deploy/k8s/pod-diag/package.json | 20 + deploy/k8s/pod-diag/smoke.mjs | 95 ++ deploy/k8s/pod-diag/src/index.mjs | 342 +++++ 7 files changed, 2525 insertions(+) create mode 100644 deploy/k8s/pod-diag.yaml create mode 100644 deploy/k8s/pod-diag/Dockerfile create mode 100644 deploy/k8s/pod-diag/README.md create mode 100644 deploy/k8s/pod-diag/package-lock.json create mode 100644 deploy/k8s/pod-diag/package.json create mode 100644 deploy/k8s/pod-diag/smoke.mjs create mode 100644 deploy/k8s/pod-diag/src/index.mjs diff --git a/deploy/k8s/pod-diag.yaml b/deploy/k8s/pod-diag.yaml new file mode 100644 index 0000000..a9a2d52 --- /dev/null +++ b/deploy/k8s/pod-diag.yaml @@ -0,0 +1,97 @@ +# tb-pod-diag:集群内只读诊断服务,反向注册到 tool-bridge 树 tools/pod-diag。 +# +# 安全模型(结构性只读,不依赖任何可绕过的软约束作为最终边界): +# - 授权面 = Role(限 namespace,非 ClusterRole)只给 pods / pods/log / events 的 +# get/list。这些子资源在 K8s API 层【没有写动作】,只读是内核保证,攻破本 pod +# 也偷不到写/exec 能力。 +# - 无 shell、无 exec、无 kubectl 二进制;Agent 只能调用六个结构化只读工具。 +# - SA token 只在本 pod 内,不经 Agent、不经网关。 +# +# 前置: +# 1) 构建推送镜像(见 pod-diag/Dockerfile),替换下方 image; +# 2) 建 device SK Secret(scope 只需 device 注册权): +# kubectl -n tool-bridge create secret generic tb-device-sk \ +# --from-literal=sk='<你的 device SK>' +# 3) 把 ALLOWED_NAMESPACES 改成你真正要排查的 namespace(逗号分隔); +# 并为【每一个】这样的 namespace 各建一份下面的 Role + RoleBinding +# (本文件示例只覆盖 default,多 ns 需复制 Role/RoleBinding 段并改 namespace)。 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: tool-bridge +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: pod-diag + namespace: tool-bridge +--- +# 只读诊断 Role —— 需在【每个被查询的 namespace】各建一份(此处示例:default)。 +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: tb-pod-diag-reader + namespace: default # ← 被查询的目标 namespace +rules: + - apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["events"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: tb-pod-diag-reader + namespace: default # ← 与上面 Role 的 namespace 一致 +subjects: + - kind: ServiceAccount + name: pod-diag + namespace: tool-bridge # SA 在 tool-bridge ns,被授权读 default ns +roleRef: + kind: Role + name: tb-pod-diag-reader + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tb-pod-diag + namespace: tool-bridge +spec: + replicas: 1 # deviceId 固定,重建后自动重连复用同一树节点 + selector: + matchLabels: { app: tb-pod-diag } + template: + metadata: + labels: { app: tb-pod-diag } + spec: + serviceAccountName: pod-diag + automountServiceAccountToken: true # client-node loadFromCluster 需要 + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + containers: + - name: pod-diag + image: /tb-pod-diag:0.1.0 # ← 改成你推送后的镜像地址 + env: + - name: TB_BASE_URL + value: "https://your-tb.example.com" # ← 你的网关地址 + - name: TB_SK + valueFrom: + secretKeyRef: { name: tb-device-sk, key: sk } + - name: TB_DEVICE_ID + value: "pod-diag" + - name: ALLOWED_NAMESPACES + value: "default" # ← 逗号分隔;须与上面 Role 覆盖的 ns 一致 + - name: MAX_TAIL_LINES + value: "2000" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } diff --git a/deploy/k8s/pod-diag/Dockerfile b/deploy/k8s/pod-diag/Dockerfile new file mode 100644 index 0000000..f9e336c --- /dev/null +++ b/deploy/k8s/pod-diag/Dockerfile @@ -0,0 +1,20 @@ +# tb-pod-diag 镜像:纯 Node 只读诊断服务(@kubernetes/client-node 直连 in-cluster +# APIServer + @tool-bridge/sdk 反向连接网关)。无 kubectl 二进制、无 shell。 +# +# 构建(在 deploy/k8s/pod-diag 目录内): +# docker buildx build --platform linux/amd64 -t /tb-pod-diag:0.1.0 --push . +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +# 可复现构建:锁定 lockfile 版本;--omit=dev 排除冒烟用的 @hono/node-server。 +RUN npm ci --omit=dev +COPY src ./src + +FROM node:22-bookworm-slim +ENV NODE_ENV=production +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json +COPY --from=build /app/src ./src +USER node +CMD ["node", "src/index.mjs"] diff --git a/deploy/k8s/pod-diag/README.md b/deploy/k8s/pod-diag/README.md new file mode 100644 index 0000000..81fd375 --- /dev/null +++ b/deploy/k8s/pod-diag/README.md @@ -0,0 +1,95 @@ +# tb-pod-diag + +集群内**只读**诊断服务:经 `@tool-bridge/sdk` 反向连接到 tool-bridge 网关,把六个结构化只读的 K8s 查询挂到远程树 `device//tools/pod-diag`。够不到 ACK 集群的 Agent 凭一把 SK + fetch 即可查看 pod 日志、状态、事件。 + +## 为什么是这个形态(设计决策) + +- **不暴露 shell / exec / kubectl**。`kubectl` 二进制放开 = 整个命令面失守;`pods/exec` 在 K8s 层没有"只读"档,进容器即等于可写。二者都被否决。 +- **授权面 = namespace-scoped Role**,只给 `pods` / `pods/log` / `events` 的 `get`/`list`。这些子资源在 K8s API 层**没有写动作**,只读是内核保证——即便本 pod 被攻破,SA token 也偷不到写/exec 能力。 +- **SA token 只在本 pod 内**,不经 Agent、不经网关。 +- 真要 `exec` 进 pod,走人工临时授权,不常设给 Agent。 + +## 六个只读工具 + +| 工具 | K8s 调用 | 用途 | +|---|---|---| +| `listPods` | `listNamespacedPod` | 列 pod:阶段/就绪/重启数/节点 | +| `getLogs` | `readNamespacedPodLog` | 拉日志(tailLines/sinceSeconds/container/previous) | +| `describePod` | `readNamespacedPod` | spec + status 详情 | +| `getPodStatus` | `readNamespacedPod` | 精简状态/条件/容器状态 | +| `getEvents` | `listNamespacedEvent` | 事件(调度/OOM/拉镜像失败) | +| `getPodYaml` | `readNamespacedPod` | 完整 manifest(剔 managedFields) | + +所有工具的 `namespace` 入参必须落在 `ALLOWED_NAMESPACES` 白名单内。 + +## 访问方式:纯 HTTP,三入口对等 + +工具挂上树后就是 HTTP 端点。CLI、Agent、Dashboard 打的是同一个面,没有 CLI 专属通道。 +下面 `` = 网关地址,`` = **Agent 调用 SK**(见下方"两把 SK")。树路径 `device/pod-diag/tools/pod-diag`(第一段是 deviceId,第二段是注册路径)。 + +### 发现(GET,自描述) + +```sh +# 节点索引:有哪些工具(省略 schema) +curl -H "Authorization: Bearer " /device/pod-diag/tools/pod-diag/~help + +# 单个工具的完整参数 schema(required + 字段描述都在这一层) +curl -H "Authorization: Bearer " /device/pod-diag/tools/pod-diag/getLogs/~help + +# 要 JSON(可直接喂 LLM / 渲染表单) +curl -H "Authorization: Bearer " -H "Accept: application/json" \ + /device/pod-diag/tools/pod-diag/getLogs/~help +``` + +### 调用(POST,两种等价形态) + +形态 A —— 节点路径 + `{tool, arguments}` 信封: + +```sh +curl -X POST /device/pod-diag/tools/pod-diag \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"tool":"getLogs","arguments":{"namespace":"default","pod":"my-pod","tailLines":500,"previous":true}}' +``` + +形态 B —— 直连工具路径,body 直接就是 arguments: + +```sh +curl -X POST /device/pod-diag/tools/pod-diag/getLogs \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"namespace":"default","pod":"my-pod","tailLines":500}' +``` + +CLI 等价写法(内部就是发上面的 POST): + +```sh +tb call device/pod-diag/tools/pod-diag --tool getLogs \ + --args '{"namespace":"default","pod":"my-pod","tailLines":500}' +``` + +## 两把 SK(别混用) + +| 用途 | 谁持有 | scope | +|---|---|---| +| **device SK** | 诊断 pod(`TB_SK` 环境变量,Secret `tb-device-sk`) | 只需 device 注册权 | +| **Agent 调用 SK** | 调用方 Agent | `device/pod-diag/**` 的 `read` + `call` | + +权限两级:GET `~help` 要 `read`,POST 调用要 `call`。 + +## 环境变量 + +| 变量 | 必填 | 说明 | +|---|---|---| +| `TB_BASE_URL` | ✓ | 网关 base url(https) | +| `TB_SK` | ✓ | device SK | +| `TB_DEVICE_ID` | | 稳定设备 id,缺省 `pod-diag` | +| `ALLOWED_NAMESPACES` | ✓ | 逗号分隔;空 = 拒一切查询 | +| `MAX_TAIL_LINES` | | getLogs 行数上限,缺省 2000 | + +## 本地冒烟 + +`node smoke.mjs` —— 起本地 TB 实例验证反向注册 + HTTP 两种调用形态 + `~help` 两级发现 + SK 鉴权(不连真集群)。构建镜像与部署见上级目录 `../pod-diag.yaml`。 + +## 已验证 / 未验证 + +- **已验证**:依赖可解析(`@kubernetes/client-node@1.4.0`、`@tool-bridge/sdk@0.4.0`)、`index.mjs` 语法、K8s client 方法名与 SDK 导出面、`npm ci --omit=dev` 构建、tool-bridge 侧调用链路(冒烟全绿)。 +- **未验证**:连真实 in-cluster APIServer 的 K8s 调用(需集群内运行)。首次部署若报错,大概率在 `getLogs`/`listPods` 的返回字段解析处 —— client-node 1.x 返回值直接取(无 `.body`),已按此写。 diff --git a/deploy/k8s/pod-diag/package-lock.json b/deploy/k8s/pod-diag/package-lock.json new file mode 100644 index 0000000..cacbc8c --- /dev/null +++ b/deploy/k8s/pod-diag/package-lock.json @@ -0,0 +1,1856 @@ +{ + "name": "tb-pod-diag", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tb-pod-diag", + "version": "0.1.0", + "dependencies": { + "@kubernetes/client-node": "^1.0.0", + "@tool-bridge/sdk": "^0.4.0" + }, + "devDependencies": { + "@hono/node-server": "^2.0.11" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", + "integrity": "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@kubernetes/client-node": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", + "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", + "license": "Apache-2.0", + "dependencies": { + "@types/js-yaml": "^4.0.1", + "@types/node": "^24.0.0", + "@types/node-fetch": "^2.6.13", + "@types/stream-buffers": "^3.0.3", + "form-data": "^4.0.0", + "hpagent": "^1.2.0", + "isomorphic-ws": "^5.0.0", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "node-fetch": "^2.7.0", + "openid-client": "^6.1.3", + "rfc4648": "^1.3.0", + "socks-proxy-agent": "^8.0.4", + "stream-buffers": "^3.0.2", + "tar-fs": "^3.0.9", + "ws": "^8.18.2" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@tool-bridge/sdk": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@tool-bridge/sdk/-/sdk-0.4.0.tgz", + "integrity": "sha512-yib4SEZkv5jmmNGt6/Z0w+nrib27fyXj8BBdCCkMQI4YaTygW5BpncocojJV69Xpajtl1p1KXetRKIxP7252cg==", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "aws4fetch": "^1.0.20", + "hono": "^4.12.28", + "partysocket": "1.3.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@tool-bridge/sdk/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/stream-buffers": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-polyfill": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz", + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==", + "license": "MIT" + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/partysocket": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.3.0.tgz", + "integrity": "sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA==", + "license": "MIT", + "dependencies": { + "event-target-polyfill": "^0.0.4" + }, + "peerDependencies": { + "react": ">=17" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/deploy/k8s/pod-diag/package.json b/deploy/k8s/pod-diag/package.json new file mode 100644 index 0000000..a9297bd --- /dev/null +++ b/deploy/k8s/pod-diag/package.json @@ -0,0 +1,20 @@ +{ + "name": "tb-pod-diag", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "集群内只读诊断服务:经 SDK 反向注册成 tool-bridge 树上的 tools/pod-diag,只暴露结构性只读的 K8s 查询(logs/events/describe/status/list),无 shell、无 exec、无 kubectl。", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "node src/index.mjs" + }, + "dependencies": { + "@kubernetes/client-node": "^1.0.0", + "@tool-bridge/sdk": "^0.4.0" + }, + "devDependencies": { + "@hono/node-server": "^2.0.11" + } +} diff --git a/deploy/k8s/pod-diag/smoke.mjs b/deploy/k8s/pod-diag/smoke.mjs new file mode 100644 index 0000000..bcaeefa --- /dev/null +++ b/deploy/k8s/pod-diag/smoke.mjs @@ -0,0 +1,95 @@ +import { createToolBridge, MemoryStateStore } from '@tool-bridge/sdk' +/** + * 本地端到端冒烟:验证 SDK 反向连接 + 工具注册 + 纯 HTTP 调用 + ~help 这条 tool-bridge + * 链路(不连真集群 —— K8s client 用注入的假数据替换,只验证 tool-bridge 侧行为)。 + * + * 起一个本地 TB 网关(SDK fetch 面 + 内存 deviceTransport 桥),把 pod-diag 的六个 + * 工具注册进去,然后用 fetch 打 ~help 和调用,断言返回形状。 + * + * 运行:node smoke.mjs + */ +import { serve } from '@hono/node-server' + +const ADMIN_SK = 'smoke-admin-sk' +const PORT = 8799 +const BASE = `http://127.0.0.1:${PORT}` + +// 直接复用生产工具的 spec/参数校验逻辑不易(index.mjs 会 loadFromCluster 崩), +// 这里内联一个等价的最小工具集,聚焦验证 tool-bridge 链路而非 K8s 调用。 +const tb = createToolBridge({ state: new MemoryStateStore(), adminSk: ADMIN_SK }) + +tb.registerTool( + 'tools/pod-diag', + { + List: () => [ + { + name: 'getLogs', + description: '拉取某 pod 的容器日志(只读)', + effect: 'read', + inputSchema: { + type: 'object', + required: ['namespace', 'pod'], + properties: { + namespace: { type: 'string' }, + pod: { type: 'string' }, + tailLines: { type: 'number' }, + }, + }, + }, + ], + Get: name => ({ name, description: 'stub', effect: 'read' }), + Call: (name, args) => ({ content: { stubTool: name, echoedArgs: args } }), + }, + { description: '冒烟用 stub;生产实现见 src/index.mjs' }, +) + +const server = serve({ fetch: req => tb.fetch(req), port: PORT }) +await new Promise(r => setTimeout(r, 300)) + +let failures = 0 +function check(label, ok, detail) { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` — ${detail}`}`) + if (!ok) failures++ +} + +const auth = { Authorization: `Bearer ${ADMIN_SK}` } + +// 1) ~help:默认 text/plain(面向 LLM 的 Help DSL),含工具名与参数 schema +const helpRes = await fetch(`${BASE}/tools/pod-diag/~help`, { headers: auth }) +const helpText = await helpRes.text() +check('~help 200', helpRes.status === 200, `status=${helpRes.status}`) +check('~help 含 getLogs', helpText.includes('getLogs'), helpText.slice(0, 300)) +// 节点级 ~help 是索引(省略 schema);工具级 ~help 才透出完整 inputSchema。 +const toolHelp = await ( + await fetch(`${BASE}/tools/pod-diag/getLogs/~help`, { headers: auth }) +).text() +check('工具级 ~help 透出 namespace schema', toolHelp.includes('namespace'), toolHelp.slice(0, 400)) + +// 2) 形态 A:节点路径 + {tool,arguments} 信封。返回按内容协商为 markdown(json 代码块), +// 用 text 读避免 JSON.parse 报错;断言用子串。 +const aRes = await fetch(`${BASE}/tools/pod-diag`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool: 'getLogs', arguments: { namespace: 'default', pod: 'x' } }), +}) +const aText = await aRes.text() +check('调用形态A 200', aRes.status === 200, `status=${aRes.status} body=${aText.slice(0, 200)}`) +check('形态A 回显 args', aText.includes('default') && aText.includes('getLogs'), aText.slice(0, 300)) + +// 3) 形态 B:直连工具路径,body 即 arguments +const bRes = await fetch(`${BASE}/tools/pod-diag/getLogs`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ namespace: 'kube-system', pod: 'y' }), +}) +const bText = await bRes.text() +check('调用形态B 200', bRes.status === 200, `status=${bRes.status} body=${bText.slice(0, 200)}`) +check('形态B 回显 args', bText.includes('kube-system'), bText.slice(0, 300)) + +// 4) 无 SK → 拒 +const noAuth = await fetch(`${BASE}/tools/pod-diag/~help`) +check('无 SK 被拒', noAuth.status === 401 || noAuth.status === 404, `status=${noAuth.status}`) + +server.close?.() +console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`) +process.exit(failures === 0 ? 0 : 1) diff --git a/deploy/k8s/pod-diag/src/index.mjs b/deploy/k8s/pod-diag/src/index.mjs new file mode 100644 index 0000000..072fccf --- /dev/null +++ b/deploy/k8s/pod-diag/src/index.mjs @@ -0,0 +1,342 @@ +/** + * tb-pod-diag —— 集群内只读诊断服务。 + * + * 设计底线(与前序讨论一致): + * - 只暴露【结构性只读】的 K8s 查询:listPods / getLogs / describePod / + * getPodStatus / getEvents。没有 shell、没有 exec、没有 kubectl 二进制。 + * - 授权面 = RBAC 授予本 SA 的只读子资源(pods / pods/log / events,get/list)。 + * 即便本进程被攻破,SA token 也只能读,偷不到任何写/exec 能力。 + * - namespace 边界由 ALLOWED_NAMESPACES 环境变量声明(逗号分隔);工具入参里的 + * namespace 必须落在白名单内,否则拒。这是【应用层】收敛,真正的硬边界仍是 RBAC + * 用 Role(限 ns)而非 ClusterRole —— 两者叠加,纵深防御。 + * - 经 @tool-bridge/sdk 反向连接到网关,把本地函数挂到远程树 tools/pod-diag。 + * SA token 只在本 pod 内,不经 Agent、不经网关。 + * + * 环境变量: + * TB_BASE_URL 网关 base url(https) + * TB_SK 一把 scope 只含 device 注册权的 SK + * TB_DEVICE_ID 稳定设备 id(缺省 pod-diag) + * ALLOWED_NAMESPACES 逗号分隔的允许查询的 namespace(必填;不设=拒一切) + * MAX_TAIL_LINES getLogs tailLines 上限(缺省 2000) + */ + +import { createToolBridge, MemoryStateStore, TBError } from '@tool-bridge/sdk' +import { CoreV1Api, KubeConfig } from '@kubernetes/client-node' + +function requireEnv(name) { + const v = process.env[name] + if (!v || v.trim() === '') { + console.error(`[pod-diag] 缺少必填环境变量 ${name}`) + process.exit(1) + } + return v.trim() +} + +const BASE_URL = requireEnv('TB_BASE_URL') +const SK = requireEnv('TB_SK') +const DEVICE_ID = process.env.TB_DEVICE_ID?.trim() || 'pod-diag' +const MAX_TAIL_LINES = Number(process.env.MAX_TAIL_LINES ?? 2000) + +/** 允许查询的 namespace 白名单;空集 = 拒一切(默认拒,和 shell 白名单同哲学)。 */ +const ALLOWED_NS = new Set( + (process.env.ALLOWED_NAMESPACES ?? '') + .split(',') + .map(s => s.trim()) + .filter(Boolean), +) +if (ALLOWED_NS.size === 0) { + console.error('[pod-diag] ALLOWED_NAMESPACES 为空 —— 拒绝一切查询。请显式声明允许的 namespace。') +} + +// in-cluster 装配:自动读取挂载的 SA token + CA + APIServer 地址。 +const kc = new KubeConfig() +kc.loadFromCluster() +const core = kc.makeApiClient(CoreV1Api) + +/** namespace 守卫:入参 ns 必须在白名单内,否则 permission_denied。 */ +function assertNamespace(ns) { + if (typeof ns !== 'string' || ns.trim() === '') { + throw new TBError('invalid_argument', 'namespace 必填') + } + if (!ALLOWED_NS.has(ns)) { + throw new TBError( + 'permission_denied', + `namespace '${ns}' 不在允许列表内;允许:${[...ALLOWED_NS].join(', ') || '(空)'}`, + ) + } + return ns +} + +/** 提取 K8s client 错误里对人有用的信息(状态码 + message),避免把整个响应体外泄。 */ +function wrapK8sError(err) { + const code = err?.statusCode ?? err?.response?.statusCode + const msg = err?.body?.message ?? err?.message ?? String(err) + if (code === 403) return new TBError('permission_denied', `APIServer 拒绝(RBAC 不足):${msg}`) + if (code === 404) return new TBError('not_found', msg) + return new TBError('internal', `K8s 调用失败:${msg}`) +} + +function str(args, key, required = false) { + const v = args?.[key] + if (v === undefined || v === null || v === '') { + if (required) throw new TBError('invalid_argument', `${key} 必填`) + return undefined + } + if (typeof v !== 'string') throw new TBError('invalid_argument', `${key} 必须是字符串`) + return v +} + +function num(args, key) { + const v = args?.[key] + if (v === undefined || v === null) return undefined + const n = Number(v) + if (!Number.isFinite(n)) throw new TBError('invalid_argument', `${key} 必须是数字`) + return n +} + +// ── 六个只读工具 ──────────────────────────────────────────────── +const TOOLS = { + listPods: { + spec: { + description: '列出某 namespace 下的 pod(名称/阶段/就绪/重启数/节点)', + effect: 'read', + inputSchema: { + type: 'object', + required: ['namespace'], + properties: { + namespace: { type: 'string', description: '目标 namespace(须在允许列表内)' }, + labelSelector: { type: 'string', description: '标签选择器,如 app=web(可选)' }, + }, + }, + }, + async call(args) { + const ns = assertNamespace(str(args, 'namespace', true)) + const labelSelector = str(args, 'labelSelector') + const res = await core + .listNamespacedPod({ namespace: ns, ...(labelSelector ? { labelSelector } : {}) }) + .catch((e) => { throw wrapK8sError(e) }) + const pods = (res.items ?? []).map(p => ({ + name: p.metadata?.name, + phase: p.status?.phase, + ready: `${(p.status?.containerStatuses ?? []).filter(c => c.ready).length}/${(p.status?.containerStatuses ?? []).length}`, + restarts: (p.status?.containerStatuses ?? []).reduce((a, c) => a + (c.restartCount ?? 0), 0), + node: p.spec?.nodeName, + startTime: p.status?.startTime, + })) + return { content: pods } + }, + }, + + getLogs: { + spec: { + description: '拉取某 pod 的容器日志(只读)', + effect: 'read', + inputSchema: { + type: 'object', + required: ['namespace', 'pod'], + properties: { + namespace: { type: 'string', description: '目标 namespace(须在允许列表内)' }, + pod: { type: 'string', description: 'pod 名称' }, + container: { type: 'string', description: '容器名(多容器时必填,可选)' }, + tailLines: { type: 'number', description: `尾部行数,上限 ${MAX_TAIL_LINES}(可选)` }, + sinceSeconds: { type: 'number', description: '仅最近 N 秒(可选)' }, + previous: { type: 'boolean', description: '取上一个已终止容器的日志(排查 crash 用,可选)' }, + }, + }, + }, + async call(args) { + const ns = assertNamespace(str(args, 'namespace', true)) + const pod = str(args, 'pod', true) + const container = str(args, 'container') + const sinceSeconds = num(args, 'sinceSeconds') + let tailLines = num(args, 'tailLines') + if (tailLines !== undefined) tailLines = Math.min(Math.max(1, Math.floor(tailLines)), MAX_TAIL_LINES) + const res = await core + .readNamespacedPodLog({ + name: pod, + namespace: ns, + ...(container ? { container } : {}), + ...(tailLines !== undefined ? { tailLines } : {}), + ...(sinceSeconds !== undefined ? { sinceSeconds } : {}), + ...(args?.previous === true ? { previous: true } : {}), + }) + .catch((e) => { throw wrapK8sError(e) }) + return { content: typeof res === 'string' ? res : (res?.body ?? JSON.stringify(res)) } + }, + }, + + describePod: { + spec: { + description: 'pod 的 spec + status 详情(结构化,近似 kubectl describe)', + effect: 'read', + inputSchema: { + type: 'object', + required: ['namespace', 'pod'], + properties: { + namespace: { type: 'string', description: '目标 namespace(须在允许列表内)' }, + pod: { type: 'string', description: 'pod 名称' }, + }, + }, + }, + async call(args) { + const ns = assertNamespace(str(args, 'namespace', true)) + const pod = str(args, 'pod', true) + const p = await core + .readNamespacedPod({ name: pod, namespace: ns }) + .catch((e) => { throw wrapK8sError(e) }) + return { + content: { + name: p.metadata?.name, + namespace: p.metadata?.namespace, + labels: p.metadata?.labels, + annotations: p.metadata?.annotations, + node: p.spec?.nodeName, + phase: p.status?.phase, + conditions: p.status?.conditions, + containers: (p.spec?.containers ?? []).map(c => ({ + name: c.name, + image: c.image, + resources: c.resources, + })), + containerStatuses: p.status?.containerStatuses, + }, + } + }, + }, + + getPodStatus: { + spec: { + description: 'pod 的精简运行状态(阶段/条件/容器状态)', + effect: 'read', + inputSchema: { + type: 'object', + required: ['namespace', 'pod'], + properties: { + namespace: { type: 'string', description: '目标 namespace(须在允许列表内)' }, + pod: { type: 'string', description: 'pod 名称' }, + }, + }, + }, + async call(args) { + const ns = assertNamespace(str(args, 'namespace', true)) + const pod = str(args, 'pod', true) + const p = await core + .readNamespacedPod({ name: pod, namespace: ns }) + .catch((e) => { throw wrapK8sError(e) }) + return { + content: { + phase: p.status?.phase, + startTime: p.status?.startTime, + conditions: p.status?.conditions, + containerStatuses: (p.status?.containerStatuses ?? []).map(c => ({ + name: c.name, + ready: c.ready, + restartCount: c.restartCount, + state: c.state, + lastState: c.lastState, + })), + }, + } + }, + }, + + getEvents: { + spec: { + description: '某 namespace(可按 pod 过滤)的事件,排查调度/OOM/拉镜像失败', + effect: 'read', + inputSchema: { + type: 'object', + required: ['namespace'], + properties: { + namespace: { type: 'string', description: '目标 namespace(须在允许列表内)' }, + pod: { type: 'string', description: '仅该 pod 相关事件(可选)' }, + }, + }, + }, + async call(args) { + const ns = assertNamespace(str(args, 'namespace', true)) + const pod = str(args, 'pod') + const res = await core + .listNamespacedEvent({ + namespace: ns, + ...(pod ? { fieldSelector: `involvedObject.name=${pod}` } : {}), + }) + .catch((e) => { throw wrapK8sError(e) }) + const events = (res.items ?? []).map(e => ({ + type: e.type, + reason: e.reason, + message: e.message, + object: `${e.involvedObject?.kind}/${e.involvedObject?.name}`, + count: e.count, + lastTimestamp: e.lastTimestamp ?? e.eventTime, + })) + return { content: events } + }, + }, + + getPodYaml: { + spec: { + description: 'pod 的完整 manifest(JSON 形态,含 spec/status 全字段)', + effect: 'read', + inputSchema: { + type: 'object', + required: ['namespace', 'pod'], + properties: { + namespace: { type: 'string', description: '目标 namespace(须在允许列表内)' }, + pod: { type: 'string', description: 'pod 名称' }, + }, + }, + }, + async call(args) { + const ns = assertNamespace(str(args, 'namespace', true)) + const pod = str(args, 'pod', true) + const p = await core + .readNamespacedPod({ name: pod, namespace: ns }) + .catch((e) => { throw wrapK8sError(e) }) + // managedFields 噪声大且无排查价值,剔除。 + if (p.metadata) delete p.metadata.managedFields + return { content: p } + }, + }, +} + +// ── SDK 装配:注册工具节点 + 反向连接 ────────────────────────── +const toolNames = Object.keys(TOOLS) +// 本实例只作为【设备】反向连接到远程网关,不对外提供 HTTP(不调 tb.fetch), +// 故无需 adminSk / bootstrap —— connect() 走的 defaultExpose 只读内存 registrations。 +// 远程网关鉴权用的是下面 connect(BASE_URL, SK) 里的 SK,与本地实例引导无关。 +const tb = createToolBridge({ state: new MemoryStateStore() }) + +tb.registerTool( + 'tools/pod-diag', + { + List: () => toolNames.map(n => ({ name: n, ...TOOLS[n].spec })), + Get: (name) => { + const t = TOOLS[name] + if (!t) throw TBError.notFound(`unknown tool '${name}'`) + return { name, ...t.spec } + }, + Call: (name, args) => { + const t = TOOLS[name] + if (!t) throw TBError.notFound(`unknown tool '${name}'`) + return t.call(args ?? {}) + }, + }, + { description: '集群内只读 K8s 诊断(logs/events/describe/status/list/yaml);无 shell、无 exec' }, +) + +const conn = tb.connect(BASE_URL, SK, { deviceId: DEVICE_ID }) +conn.ready.then( + mountPath => console.log(`[pod-diag] 已挂载到远程树:${mountPath}(工具:${toolNames.join(', ')})`), + (err) => { + console.error(`[pod-diag] 反向连接失败:${err?.message ?? err}`) + process.exit(1) + }, +) + +for (const sig of ['SIGINT', 'SIGTERM']) { + process.once(sig, () => { + conn.close() + process.exit(0) + }) +} From 1523631f55bf0a74338b4ef4fcb1d077d680f0b8 Mon Sep 17 00:00:00 2001 From: evilstar9527 Date: Sat, 25 Jul 2026 16:15:22 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(gateway):=20=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=87=AA=E5=8A=A9=E6=8D=A2=20key(=E6=96=B9?= =?UTF-8?q?=E6=A1=88=20A:=E7=BA=AF=20open=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /login + /~feishu/callback 两条树外免认证路由:企业成员飞书 OAuth 登录后自动 rotate 签发一把 mcp/plugins/skills 可用的 SK(read+call+write, 默认 90 天),owner=user:,准入=能走完企业飞书 OAuth(不校验邮箱)。 - feishuLogin.ts:state 封解(AES-GCM,域前缀区隔 mcp-oauth)、飞书 OAuth 三段、 rotate 签发(同 owner 旧登录 key 先删再签)、meego 绑定(可注入,best-effort)、 成功/失败回调页(SK 一次性展示,no-store + CSP) - 复用 SecretStore 的 feishu-app 凭证(app_id/app_secret),经 TB_FEISHU_LOGIN_SECRET_REF 启用 - meego 自动绑定按决策做成降级待验证:接口齐全但默认关闭(open_id 跨 app 对齐未验证) - 14 个单测覆盖 state/rotate/scope/绑定;pnpm verify 全绿 --- packages/gateway/src/app.ts | 15 + packages/gateway/src/feishuLogin.ts | 410 ++++++++++++++++++ packages/gateway/src/tbApp.ts | 117 +++++ .../gateway/test/feishuLogin.unit.test.ts | 154 +++++++ 4 files changed, 696 insertions(+) create mode 100644 packages/gateway/src/feishuLogin.ts create mode 100644 packages/gateway/test/feishuLogin.unit.test.ts diff --git a/packages/gateway/src/app.ts b/packages/gateway/src/app.ts index 5e49d2e..c31a50d 100644 --- a/packages/gateway/src/app.ts +++ b/packages/gateway/src/app.ts @@ -27,11 +27,17 @@ export interface Env { TB_DEVICE: DurableObjectNamespace /** 设备断线后未重连的回收秒数(缺省 24h)。 */ TB_DEVICE_RECLAIM_SEC?: string + /** 飞书登录:签发 key 有效期秒(缺省 90 天)。 */ + TB_FEISHU_LOGIN_KEY_TTL_SEC?: string + /** 飞书登录:SecretStore 中 {app_id,app_secret} 的引用名(缺省复用 feishu plugin 的 "feishu-app")。设置后启用 /login。 */ + TB_FEISHU_LOGIN_SECRET_REF?: string /** 本实例 X-TB-Via 标识(缺省用入站 host 派生)。 */ TB_INSTANCE_ID?: string TB_KV: KVNamespace /** X-TB-Via 跳数上限(默认 4)。 */ TB_MAX_HOPS?: string + /** meego 自动绑定用的空间 projectKey(缺省 → 跳过 meego 绑定)。 */ + TB_MEEGO_PROJECT_KEY?: string TB_R2: R2Bucket /** r2 presign 凭证链的 env 段(SecretStore 'r2-presign' 优先)。 */ TB_R2_ACCESS_KEY_ID?: string @@ -157,6 +163,15 @@ function depsFromEnv(env: Env): TbAppDeps { if (refThreshold !== undefined) deps.refThresholdBytes = refThreshold const refTtl = positiveIntEnv(env.TB_REF_TTL_SEC) if (refTtl !== undefined) deps.refTtlSec = refTtl + // 飞书登录:配了 secret ref 才启用(缺省不开)。 + if (env.TB_FEISHU_LOGIN_SECRET_REF !== undefined && env.TB_FEISHU_LOGIN_SECRET_REF !== '') { + deps.feishuLoginSecretRef = env.TB_FEISHU_LOGIN_SECRET_REF + } + const loginTtl = positiveIntEnv(env.TB_FEISHU_LOGIN_KEY_TTL_SEC) + if (loginTtl !== undefined) deps.feishuLoginKeyTtlSec = loginTtl + if (env.TB_MEEGO_PROJECT_KEY !== undefined && env.TB_MEEGO_PROJECT_KEY !== '') { + deps.meegoProjectKey = env.TB_MEEGO_PROJECT_KEY + } return deps } diff --git a/packages/gateway/src/feishuLogin.ts b/packages/gateway/src/feishuLogin.ts new file mode 100644 index 0000000..fd36694 --- /dev/null +++ b/packages/gateway/src/feishuLogin.ts @@ -0,0 +1,410 @@ +/** + * 飞书登录自助换 key(方案 A:纯 open_id,不校验邮箱)。 + * + * 流程两段,状态全在网关侧闭环: + * - 发起(`GET /login`,tbApp 树外免认证挂接):生成加密 state(CSRF nonce + exp), + * 302 跳飞书 `/authen/v1/authorize`。 + * - 回调(`GET /~feishu/callback`,树外免认证):解密校验 state → code 换 + * user_access_token → `/authen/v1/user_info` 拿 open_id → **rotate 签发** SK + * (同 owner 旧 key 先删再签,因明文只返回一次)→ 回调页一次性展示。 + * + * 准入:能走完企业飞书 OAuth = 本企业成员,不校验邮箱域(方案 A)。 + * app 凭证:复用 feishu plugin 的 SecretStore 引用(默认 "feishu-app",{app_id,app_secret})。 + * state 加密:AES-256-GCM,密钥派生自 TB_SECRET_ENCRYPTION_KEY(域前缀区隔 mcp-oauth)。 + */ + +import { + base64urlDecode, + base64urlEncode, + type Scope, + type SecretKeyInput, + type SKRegistryStore, + TBError, + type Timestamp, +} from '@tool-bridge/core' + +/** 飞书开放平台 base(私有化部署可 override,当前固定公网)。 */ +export const FEISHU_BASE = 'https://open.feishu.cn' + +/** 登录回调路径(树外免认证;飞书后台需登记 `/~feishu/callback`)。 */ +export const FEISHU_CALLBACK_PATH = '/~feishu/callback' + +/** state TTL:发起 → 回调时限;过期一律拒,防 code 重放窗口拉长。 */ +const STATE_TTL_SEC = 600 + +/** 默认签发的 key 有效期(秒):90 天,过期重新登录自动 rotate。 */ +export const DEFAULT_KEY_TTL_SEC = 90 * 24 * 3600 + +/** 本流程签发的 key 打标记(description 前缀),rotate 时据此识别自己签的 key。 */ +export const LOGIN_KEY_TAG = 'feishu-login' + +/** + * 登录 key 的默认 scope:mcp / plugins / skills 的 read+call+write。 + * 不含 system/** 、device/** ,不含 admin。 + */ +export function defaultLoginScopes(): Scope[] { + const actions: Scope['actions'] = ['read', 'call', 'write'] + return [ + { pattern: 'mcp/**', actions }, + { pattern: 'plugins/**', actions }, + { pattern: 'skills/**', actions }, + ] +} + +// ---------- state(AES-256-GCM,零存储;域前缀区隔 mcp-oauth 的 state 密钥)---------- + +/** state 载荷:n = CSRF nonce,exp = 过期时刻(epoch 秒)。 */ +export interface LoginStatePayload { + exp: number + n: string +} + +/** state 加密密钥:SHA-256("tb-feishu-login-state:" + 主密钥)派生 32 字节(域分离)。 */ +async function stateCryptoKey(secret: string): Promise { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(`tb-feishu-login-state:${secret}`), + ) + return crypto.subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) +} + +/** 加密 state:base64url(iv).base64url(ciphertext);GCM 自带完整性。 */ +export async function sealLoginState(payload: LoginStatePayload, secret: string): Promise { + const key = await stateCryptoKey(secret) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + new TextEncoder().encode(JSON.stringify(payload)), + ) + return `${base64urlEncode(iv)}.${base64urlEncode(new Uint8Array(ciphertext))}` +} + +/** 解密 state;任何失败(格式/解密/形状)→ null。过期判定留给调用方(便于测钟)。 */ +export async function openLoginState( + state: string, + secret: string, +): Promise { + const dot = state.indexOf('.') + if (dot <= 0) return null + try { + const key = await stateCryptoKey(secret) + const iv = base64urlDecode(state.slice(0, dot)) + const ciphertext = base64urlDecode(state.slice(dot + 1)) + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv as Uint8Array }, + key, + ciphertext as Uint8Array, + ) + const payload = JSON.parse(new TextDecoder().decode(plain)) as LoginStatePayload + if (typeof payload.n !== 'string' || typeof payload.exp !== 'number') return null + return payload + } catch { + return null + } +} + +/** 生成一段新 state(nonce 随机 + exp=now+TTL);返回加密串。 */ +export async function newLoginState(secret: string, nowMs: number): Promise { + const nonce = base64urlEncode(crypto.getRandomValues(new Uint8Array(16))) + return await sealLoginState( + { n: nonce, exp: Math.floor(nowMs / 1000) + STATE_TTL_SEC }, + secret, + ) +} + +// ---------- 飞书凭证 ---------- + +/** SecretStore 里 feishu-app 的形状:{app_id, app_secret}(与 feishu plugin 一致)。 */ +export interface FeishuAppCredential { + app_id: string + app_secret: string +} + +/** 解析 SecretStore resolve 出的凭证 JSON;形状不符 → unavailable。 */ +export function parseFeishuCredential(raw: string | undefined, refName: string): FeishuAppCredential { + if (raw === undefined) { + throw new TBError('unavailable', `飞书登录凭证 '${refName}' 无法解析(SecretStore 未配置?)`, { + retryable: false, + }) + } + try { + const v = JSON.parse(raw) as Partial + if (typeof v.app_id === 'string' && v.app_id !== '' && typeof v.app_secret === 'string' && v.app_secret !== '') { + return { app_id: v.app_id, app_secret: v.app_secret } + } + } catch { + // fallthrough + } + throw new TBError('unavailable', `凭证 '${refName}' 不是 {"app_id","app_secret"} 形状的 JSON`, { + retryable: false, + }) +} + +// ---------- 飞书 OAuth(authorization code) ---------- + +/** 拼飞书授权页 URL(浏览器 302 落点)。方案 A 不带 scope 参数,只取默认 open_id。 */ +export function buildAuthorizeUrl(appId: string, redirectUri: string, state: string): string { + const u = new URL('/open-apis/authen/v1/authorize', FEISHU_BASE) + u.searchParams.set('app_id', appId) + u.searchParams.set('redirect_uri', redirectUri) + u.searchParams.set('response_type', 'code') + u.searchParams.set('state', state) + return u.toString() +} + +interface TokenResponse { + // 兼容旧版直返在顶层的形状 + access_token?: string + code?: number + data?: { access_token?: string } + msg?: string +} + +/** + * code → user_access_token。走 v2 OIDC 端点(app_access_token 免带,PKCE 无; + * 用 app_id/app_secret 直接换)。失败 → unavailable。 + */ +export async function exchangeUserToken( + cred: FeishuAppCredential, + code: string, + redirectUri: string, +): Promise { + let resp: Response + try { + resp = await fetch(`${FEISHU_BASE}/open-apis/authen/v2/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: cred.app_id, + client_secret: cred.app_secret, + code, + redirect_uri: redirectUri, + }), + }) + } catch (err) { + throw new TBError('unavailable', `飞书 token 换发网络失败:${err instanceof Error ? err.message : String(err)}`, { + retryable: true, + }) + } + const body = (await resp.json().catch(() => null)) as TokenResponse | null + const token = body?.access_token ?? body?.data?.access_token + if (!resp.ok || body === null || typeof token !== 'string' || token === '') { + throw new TBError( + 'unavailable', + `飞书 token 换发失败:HTTP ${resp.status} code=${body?.code ?? '?'} ${body?.msg ?? ''}`.trim(), + { retryable: false }, + ) + } + return token +} + +interface UserInfo { + name?: string + open_id: string +} + +interface UserInfoResponse { + code?: number + data?: { name?: string, open_id?: string } + msg?: string +} + +/** user_access_token → 本人 open_id(方案 A 只取 open_id + name)。 */ +export async function fetchUserInfo(userToken: string): Promise { + let resp: Response + try { + resp = await fetch(`${FEISHU_BASE}/open-apis/authen/v1/user_info`, { + headers: { authorization: `Bearer ${userToken}` }, + }) + } catch (err) { + throw new TBError('unavailable', `飞书 user_info 网络失败:${err instanceof Error ? err.message : String(err)}`, { + retryable: true, + }) + } + const body = (await resp.json().catch(() => null)) as UserInfoResponse | null + const openId = body?.data?.open_id + if (!resp.ok || body === null || body.code !== 0 || typeof openId !== 'string' || openId === '') { + throw new TBError( + 'unavailable', + `飞书 user_info 失败:HTTP ${resp.status} code=${body?.code ?? '?'} ${body?.msg ?? ''}`.trim(), + { retryable: false }, + ) + } + return { open_id: openId, ...(typeof body.data?.name === 'string' ? { name: body.data.name } : {}) } +} + +// ---------- rotate 签发 ---------- + +/** owner ref:方案 A 用 open_id 唯一标识登录者。 */ +export function loginOwner(openId: string): string { + return `user:${openId}` +} + +/** + * rotate 签发:删除同 owner 且由本流程签发(description 带 LOGIN_KEY_TAG)的旧 key, + * 再签一把新 key。明文 secret 只在返回值里出现一次。 + * @returns { keyId, secret } + */ +export async function rotateLoginKey( + sk: SKRegistryStore, + openId: string, + now: Timestamp, + opts?: { scopes?: Scope[], ttlSec?: number }, +): Promise<{ keyId: string, secret: string }> { + const owner = loginOwner(openId) + // 删旧:遍历 list 找同 owner + 本流程标记的 key(key 量小,可接受;无 owner 索引)。 + // 分页遍历直至游标耗尽。 + let cursor: string | undefined + do { + const page = await sk.list(cursor !== undefined ? { limit: 200, cursor } : { limit: 200 }) + for (const k of page.items) { + if (k.owner === owner && (k.description ?? '').startsWith(LOGIN_KEY_TAG)) { + await sk.delete(k.id) + } + } + cursor = page.cursor + } while (cursor !== undefined) + + const ttlSec = opts?.ttlSec ?? DEFAULT_KEY_TTL_SEC + const expiresAt = new Date(Date.parse(now) + ttlSec * 1000).toISOString() + const input: SecretKeyInput = { + owner, + description: `${LOGIN_KEY_TAG} @ ${now}`, + scopes: opts?.scopes ?? defaultLoginScopes(), + expiresAt, + } + const { key, secret } = await sk.write(input, now) + return { keyId: key.id, secret } +} + +// ---------- meego 自动绑定(open_id → user_key,best-effort) ---------- + +/** + * meego 绑定所需的最小注入面(解耦 Hono/provider): + * - listMemberUserKeys:拉 meego 空间成员的 user_key 候选集(分页已在实现内收敛) + * - queryOutId:给定 user_key 批量取 out_id(=飞书 open_id);返回 { user_key: out_id } + * - getMeegoUserKeys / setMeegoUserKeys:读/整体写回 plugins/meego 的 providerConfig.userKeys + */ +export interface MeegoBindDeps { + getMeegoUserKeys: () => Promise> + listMemberUserKeys: () => Promise + queryOutId: (userKeys: string[]) => Promise> + setMeegoUserKeys: (next: Record) => Promise +} + +export type MeegoBindResult + = | { bound: true, userKey: string } + | { bound: false, reason: string } + +/** + * 把新签发的 keyId 绑定到 meego 操作人身份:按 open_id 反查 user_key,写入映射表。 + * best-effort:任何环节失败(反查不到 / meego 不可用 / open_id 跨 app 不对齐)都不抛, + * 返回 { bound:false, reason },由调用方在回调页提示"meego 身份待管理员绑定"。 + */ +export async function bindMeegoIdentity( + deps: MeegoBindDeps, + keyId: string, + openId: string, +): Promise { + try { + const candidates = await deps.listMemberUserKeys() + if (candidates.length === 0) return { bound: false, reason: 'meego 空间无成员候选' } + // 批量取 out_id,匹配 open_id。meego query_user 一次限 20,分批。 + let matched: string | undefined + for (let i = 0; i < candidates.length && matched === undefined; i += 20) { + const batch = candidates.slice(i, i + 20) + const outIds = await deps.queryOutId(batch) + for (const [uk, outId] of Object.entries(outIds)) { + if (outId === openId) { + matched = uk + break + } + } + } + if (matched === undefined) { + return { bound: false, reason: 'open_id 未匹配到 meego 成员(可能跨 app 不对齐)' } + } + const current = await deps.getMeegoUserKeys() + await deps.setMeegoUserKeys({ ...current, [keyId]: matched }) + return { bound: true, userKey: matched } + } catch (err) { + return { bound: false, reason: err instanceof Error ? err.message : String(err) } + } +} + +// ---------- 回调结果页 ---------- + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** 失败页(不含机密)。 */ +export function renderLoginFailedHtml(detail: string): Response { + const safe = escapeHtml(detail) + const body = ` +登录失败 · tool-bridge + +

❌ 登录失败

${safe}

可关闭本页后重试。

` + return new Response(body, { + status: 400, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'content-security-policy': 'default-src \'none\'; style-src \'unsafe-inline\'; base-uri \'none\'; form-action \'none\'', + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'referrer-policy': 'no-referrer', + }, + }) +} + +/** + * 成功页:一次性展示 SK + baseUrl + tb login 命令。SK 是机密,页面 no-store、CSP 锁死、 + * 不外连不加载脚本;secret 经 escapeHtml 后注入(SK 字符集安全,双保险)。 + */ +export function renderLoginSuccessHtml(opts: { + baseUrl: string + meegoNote?: string + name?: string + secret: string +}): Response { + const secret = escapeHtml(opts.secret) + const baseUrl = escapeHtml(opts.baseUrl) + const who = opts.name !== undefined ? escapeHtml(opts.name) : '' + const meego = opts.meegoNote !== undefined ? `

${escapeHtml(opts.meegoNote)}

` : '' + const body = ` +登录成功 · tool-bridge + +
+

✅ 登录成功${who ? `,${who}` : ''}

+

这是你的 Secret Key(仅显示这一次,请立即保存):

+
${secret}
+

命令行接入:

+
tb login --base-url ${baseUrl} --sk ${secret}
+${meego} +

此 Key 可调用 mcp / plugins / skills(读+调用+写),90 天后过期,重新登录即自动换发。

+
` + return new Response(body, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'content-security-policy': 'default-src \'none\'; style-src \'unsafe-inline\'; base-uri \'none\'; form-action \'none\'', + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'referrer-policy': 'no-referrer', + }, + }) +} diff --git a/packages/gateway/src/tbApp.ts b/packages/gateway/src/tbApp.ts index dbec6e6..7b00eff 100644 --- a/packages/gateway/src/tbApp.ts +++ b/packages/gateway/src/tbApp.ts @@ -54,6 +54,7 @@ import { type SkillhubProvider, skillhubScopeForCmd, type SkillPublishFile, + SKRegistryStore, type StateStore, TBError, type TBErrorBody, @@ -68,6 +69,21 @@ import { } from '@tool-bridge/core' import { type Context, Hono } from 'hono' import type { UpstreamProvider } from './providers/types' +import { + bindMeegoIdentity, + buildAuthorizeUrl, + DEFAULT_KEY_TTL_SEC, + exchangeUserToken, + FEISHU_CALLBACK_PATH, + fetchUserInfo, + type MeegoBindDeps, + newLoginState, + openLoginState, + parseFeishuCredential, + renderLoginFailedHtml, + renderLoginSuccessHtml, + rotateLoginKey, +} from './feishuLogin' import { finishMcpAuthorization, invalidateMcpOAuth, @@ -135,8 +151,17 @@ export interface TbAppDeps { encryptionKey?: string /** 认证前的实例就绪钩子(引导/延迟注册 flush);每请求调用,幂等由宿主保证。 */ ensureReady?: () => Promise + /** 飞书登录签发 key 的有效期秒(缺省 90 天)。 */ + feishuLoginKeyTtlSec?: number + /** + * 飞书登录:SecretStore 中 {app_id,app_secret} 的引用名(默认复用 feishu plugin 的 "feishu-app")。 + * 缺省 → /login 与 /~feishu/callback 返回 unavailable(未启用登录)。 + */ + feishuLoginSecretRef?: string /** SDK 进程内 Provider 表(缺省无)。 */ locals?: LocalProviderHooks + /** meego 自动绑定用的空间 projectKey(缺省 → 跳过 meego 绑定,SK 照发)。 */ + meegoProjectKey?: string /** context 平台对象存储('r2' provider 的落点);缺省 → 该 provider unavailable。 */ objects?: () => Promise | ObjectStore /** context Get 的 $ref 内联阈值(字节,缺省 1 MiB)。 */ @@ -1190,6 +1215,23 @@ function renderTreeDsl(tree: TreeJson): string { walk(tree, 0) return `${lines.join('\n')}\n` } +/** + * meego 自动绑定的注入面装配(open_id → user_key)。 + * + * 现状(2026-07):暂返回 null(不绑定)——两个前置未破: + * (1) 登录 app 与 meego app 的 open_id 是否同源(跨 app 隔离,meego 不返回 union_id), + * 需真机登录拿到 open_id 与 meego out_id 实测比对; + * (2) meego 成员枚举链路不稳(mcp/meego 官方节点 project_key 传参未通)。 + * 破验证后:在此按 deps.meegoProjectKey 装配 listMemberUserKeys/queryOutId/ + * getMeegoUserKeys/setMeegoUserKeys(经 providerFor(mcp/meego) 枚举 + NodeRegistryStore + * 读改写 plugins/meego 的 providerConfig.userKeys),bindMeegoIdentity 即自动生效。 + */ +function meegoBindDepsFor(deps: TbAppDeps): MeegoBindDeps | null { + if (deps.meegoProjectKey === undefined) return null + // TODO(open_id 跨 app 对齐验证后接入):当前即便配了 projectKey 也先不绑,避免赌未验证的假设。 + return null +} + export function createTbApp(deps: TbAppDeps): Hono<{ Variables: Vars }> { const app = new Hono<{ Variables: Vars }>() const builtinsOf = (store: StateStore): Map => @@ -1331,6 +1373,81 @@ export function createTbApp(deps: TbAppDeps): Hono<{ Variables: Vars }> { }), ) + // 飞书登录换 key:redirect_uri 钉在 canonicalOrigin(或请求 origin)。 + const loginRedirectUri = (c: AppContext): string => + `${deps.canonicalOrigin ?? new URL(c.req.url).origin}${FEISHU_CALLBACK_PATH}` + + // GET /login → 生成加密 state,302 跳飞书授权页。树外免认证(无 SK 时可访问)。 + app.get('/login', c => + runHandler(async () => { + const encKey = deps.encryptionKey + const secretRef = deps.feishuLoginSecretRef + if (encKey === undefined || secretRef === undefined) { + return renderLoginFailedHtml('飞书登录未启用(缺 encryptionKey 或 feishuLoginSecretRef)') + } + await deps.ensureReady?.() + const cred = parseFeishuCredential(await deps.secrets.resolve(secretRef), secretRef) + const state = await newLoginState(encKey, Date.now()) + const url = buildAuthorizeUrl(cred.app_id, loginRedirectUri(c), state) + return c.redirect(url, 302) + }), + ) + + // GET /~feishu/callback → 校验 state → 换 token → 拿 open_id → rotate 签发 → meego 绑定 → 展示 SK。 + app.get(FEISHU_CALLBACK_PATH, c => + runHandler(async () => { + const encKey = deps.encryptionKey + const secretRef = deps.feishuLoginSecretRef + if (encKey === undefined || secretRef === undefined) { + return renderLoginFailedHtml('飞书登录未启用') + } + const q = c.req.query() + if (q.error !== undefined) return renderLoginFailedHtml(`飞书返回:${q.error}`) + const code = q.code + const state = q.state + if (code === undefined || state === undefined) { + return renderLoginFailedHtml('缺 code 或 state 参数') + } + const payload = await openLoginState(state, encKey) + if (payload === null || payload.exp * 1000 <= Date.now()) { + return renderLoginFailedHtml('state 非法或已过期,请重新登录') + } + await deps.ensureReady?.() + const cred = parseFeishuCredential(await deps.secrets.resolve(secretRef), secretRef) + let openId: string + let name: string | undefined + try { + const userToken = await exchangeUserToken(cred, code, loginRedirectUri(c)) + const info = await fetchUserInfo(userToken) + openId = info.open_id + name = info.name + } catch (err) { + return renderLoginFailedHtml(isTBError(err) ? err.message : '飞书授权失败') + } + const now = new Date().toISOString() + const sk = new SKRegistryStore(deps.state) + const { keyId, secret } = await rotateLoginKey(sk, openId, now, { + ttlSec: deps.feishuLoginKeyTtlSec ?? DEFAULT_KEY_TTL_SEC, + }) + // meego 自动绑定(best-effort;projectKey 未配则跳过)。 + let meegoNote: string | undefined + const bindDeps = meegoBindDepsFor(deps) + if (bindDeps !== null) { + const r = await bindMeegoIdentity(bindDeps, keyId, openId) + meegoNote = r.bound + ? `已绑定 meego 操作人身份(user_key=${r.userKey}),评论/写操作将以你本人落地。` + : `meego 身份未自动绑定(${r.reason}),如需 meego 写操作请联系管理员绑定。` + } + const baseUrl = deps.canonicalOrigin ?? new URL(c.req.url).origin + return renderLoginSuccessHtml({ + secret, + baseUrl, + ...(name !== undefined ? { name } : {}), + ...(meegoNote !== undefined ? { meegoNote } : {}), + }) + }), + ) + // 认证中间件(/healthz、/~ref、/~oauth/callback、/ui 静态资源之外全路由):Bearer → identify → 401 或注入 ctx。 app.use('*', async (c, next) => { const store = deps.state diff --git a/packages/gateway/test/feishuLogin.unit.test.ts b/packages/gateway/test/feishuLogin.unit.test.ts new file mode 100644 index 0000000..9eb6fec --- /dev/null +++ b/packages/gateway/test/feishuLogin.unit.test.ts @@ -0,0 +1,154 @@ +import { MemoryStateStore, SKRegistryStore } from '@tool-bridge/core' +import { describe, expect, it } from 'vitest' +import { + bindMeegoIdentity, + defaultLoginScopes, + LOGIN_KEY_TAG, + type MeegoBindDeps, + newLoginState, + openLoginState, + rotateLoginKey, + sealLoginState, +} from '../src/feishuLogin' + +const SECRET = '3ZwpbBkSrp3eT9ylcZedfN33yq9fJLlmeusH98qNbt8' + +describe('login state 封解', () => { + it('roundtrip:封后能解回同一 payload', async () => { + const state = await sealLoginState({ n: 'nonce123', exp: 9999999999 }, SECRET) + const back = await openLoginState(state, SECRET) + expect(back).toEqual({ n: 'nonce123', exp: 9999999999 }) + }) + + it('换密钥解不开 → null', async () => { + const state = await sealLoginState({ n: 'x', exp: 9999999999 }, SECRET) + expect(await openLoginState(state, 'wrong-key-wrong-key-wrong-key-000')).toBeNull() + }) + + it('篡改密文 → null', async () => { + const state = await sealLoginState({ n: 'x', exp: 9999999999 }, SECRET) + const tampered = `${state.slice(0, -2)}xy` + expect(await openLoginState(tampered, SECRET)).toBeNull() + }) + + it('格式非法 → null', async () => { + expect(await openLoginState('nodot', SECRET)).toBeNull() + expect(await openLoginState('', SECRET)).toBeNull() + }) + + it('newLoginState 带 exp = now + TTL', async () => { + const now = 1_000_000_000_000 + const state = await newLoginState(SECRET, now) + const p = await openLoginState(state, SECRET) + expect(p).not.toBeNull() + expect(p!.exp).toBe(Math.floor(now / 1000) + 600) + expect(typeof p!.n).toBe('string') + expect(p!.n.length).toBeGreaterThan(0) + }) +}) + +describe('defaultLoginScopes', () => { + it('覆盖 mcp/plugins/skills 的 read+call+write,不含 system/admin', () => { + const scopes = defaultLoginScopes() + expect(scopes.map(s => s.pattern).sort()).toEqual(['mcp/**', 'plugins/**', 'skills/**']) + for (const s of scopes) { + expect(s.actions).toEqual(['read', 'call', 'write']) + } + const flat = JSON.stringify(scopes) + expect(flat).not.toContain('admin') + expect(flat).not.toContain('system') + }) +}) + +describe('rotateLoginKey', () => { + const NOW = '2026-07-25T00:00:00.000Z' + + it('首次签发:owner=user:,带 login 标记,有过期', async () => { + const sk = new SKRegistryStore(new MemoryStateStore()) + const { keyId, secret } = await rotateLoginKey(sk, 'ou_abc', NOW) + expect(secret).toMatch(/^tbk_/) + const key = await sk.get(keyId) + expect(key.owner).toBe('user:ou_abc') + expect(key.description?.startsWith(LOGIN_KEY_TAG)).toBe(true) + expect(key.expiresAt).toBeDefined() + expect(key.scopes.map(s => s.pattern).sort()).toEqual(['mcp/**', 'plugins/**', 'skills/**']) + }) + + it('rotate:同 owner 的旧登录 key 被删,只剩新的', async () => { + const sk = new SKRegistryStore(new MemoryStateStore()) + const first = await rotateLoginKey(sk, 'ou_abc', NOW) + const second = await rotateLoginKey(sk, 'ou_abc', NOW) + expect(second.keyId).not.toBe(first.keyId) + await expect(sk.get(first.keyId)).rejects.toThrow() // 旧的已删 + const alive = await sk.get(second.keyId) + expect(alive.owner).toBe('user:ou_abc') + }) + + it('rotate 只删本 owner + 本流程签发的,不误删他人或非登录 key', async () => { + const store = new MemoryStateStore() + const sk = new SKRegistryStore(store) + // 他人登录 key + const other = await rotateLoginKey(sk, 'ou_other', NOW) + // 一个非登录 key(admin 手签,description 不带标记) + const manual = await sk.write( + { owner: 'user:ou_abc', description: '手动签的', scopes: defaultLoginScopes() }, + NOW, + ) + // 本人 rotate + const mine = await rotateLoginKey(sk, 'ou_abc', NOW) + expect((await sk.get(other.keyId)).owner).toBe('user:ou_other') // 他人未动 + expect((await sk.get(manual.key.id)).description).toBe('手动签的') // 非登录 key 未动 + expect((await sk.get(mine.keyId)).owner).toBe('user:ou_abc') + }) + + it('自定义 ttlSec 生效', async () => { + const sk = new SKRegistryStore(new MemoryStateStore()) + const { keyId } = await rotateLoginKey(sk, 'ou_abc', NOW, { ttlSec: 3600 }) + const key = await sk.get(keyId) + expect(key.expiresAt).toBe('2026-07-25T01:00:00.000Z') + }) +}) + +describe('bindMeegoIdentity(best-effort)', () => { + const base = (over: Partial): MeegoBindDeps => ({ + listMemberUserKeys: async () => ['uk1', 'uk2', 'uk3'], + queryOutId: async keys => + Object.fromEntries(keys.map(k => [k, `out_${k}`])), + getMeegoUserKeys: async () => ({ existing: 'ukX' }), + setMeegoUserKeys: async () => {}, + ...over, + }) + + it('open_id 命中成员 out_id → 绑定并合并写回', async () => { + let written: Record | undefined + const deps = base({ + queryOutId: async keys => Object.fromEntries(keys.map(k => [k, k === 'uk2' ? 'ou_target' : `out_${k}`])), + setMeegoUserKeys: async (next) => { + written = next + }, + }) + const r = await bindMeegoIdentity(deps, 'newKeyId', 'ou_target') + expect(r).toEqual({ bound: true, userKey: 'uk2' }) + expect(written).toEqual({ existing: 'ukX', newKeyId: 'uk2' }) // 合并,不覆盖已有 + }) + + it('无成员候选 → 不绑定', async () => { + const r = await bindMeegoIdentity(base({ listMemberUserKeys: async () => [] }), 'k', 'ou_x') + expect(r.bound).toBe(false) + }) + + it('open_id 不匹配任何成员 → 不绑定(跨 app 不对齐场景)', async () => { + const r = await bindMeegoIdentity(base({}), 'k', 'ou_nobody') + expect(r.bound).toBe(false) + }) + + it('任何环节抛错 → 吞掉返回 bound:false,不阻断', async () => { + const deps = base({ + listMemberUserKeys: async () => { + throw new Error('meego down') + }, + }) + const r = await bindMeegoIdentity(deps, 'k', 'ou_x') + expect(r).toEqual({ bound: false, reason: 'meego down' }) + }) +}) From b6489549bf63e25dc72cdcc619b07a3ed41501c5 Mon Sep 17 00:00:00 2001 From: evilstar9527 Date: Sat, 25 Jul 2026 16:55:49 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(gateway):=20=E9=83=A8=E7=BD=B2=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8C=87=E5=90=91=20Lightspeed/fantacy.live=20+=20?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20open=5Fid=20=E8=B7=A8=20app=20=E5=AE=9E?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wrangler.jsonc:account_id/KV id/R2 endpoint 从旧账户(0cb9b897,无权限) 改到线上真实值 Lightspeed(041d4868),域名 pdjjq.org → fantacy.live, 加 TB_FEISHU_LOGIN_SECRET_REF=feishu-app 启用登录。值均从线上 tb-gateway 绑定反查,复用现有 KV(a1e48dcf),不丢 SK。 - 记录实证结论:登录 open_id(ou_)与 meego out_id(on_)不同源,基于 open_id 的自动绑定不可行;meego 绑定暂由管理员手动 patch。union_id 路待验证。 --- packages/gateway/src/feishuLogin.ts | 10 ++++++++++ packages/gateway/src/tbApp.ts | 15 +++++++-------- packages/gateway/wrangler.jsonc | 16 +++++++++------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/gateway/src/feishuLogin.ts b/packages/gateway/src/feishuLogin.ts index fd36694..e8125e0 100644 --- a/packages/gateway/src/feishuLogin.ts +++ b/packages/gateway/src/feishuLogin.ts @@ -280,6 +280,16 @@ export async function rotateLoginKey( } // ---------- meego 自动绑定(open_id → user_key,best-effort) ---------- +// +// ⚠️ 实证结论(2026-07-25,真机登录验证):open_id 按 app 隔离,登录 app +// (cli_a9155139)与 meego app 给同一个人的 open_id **不同源**—— +// 登录 open_id = ou_fe43cd1b...(ou_ 前缀) +// meego out_id = on_9540d6b7...(on_ 前缀) +// 故「用登录 open_id 匹配 meego out_id」这条路走不通(queryOutId 匹配恒失败)。 +// 跨 app 稳定标识只有 union_id,但 meego query_user 不返回 union_id(待验证 meego +// open_api 是否支持按 union_id 查)。在此路打通前,meego 绑定只能由管理员手动 +// patch userKeys(手动绑定已验证可用:绑后登录 key 调 meego 即以本人身份落地)。 +// 下方注入面保留,便于未来 union_id 路打通后直接接入。 /** * meego 绑定所需的最小注入面(解耦 Hono/provider): diff --git a/packages/gateway/src/tbApp.ts b/packages/gateway/src/tbApp.ts index 7b00eff..1efb73d 100644 --- a/packages/gateway/src/tbApp.ts +++ b/packages/gateway/src/tbApp.ts @@ -1218,17 +1218,16 @@ function renderTreeDsl(tree: TreeJson): string { /** * meego 自动绑定的注入面装配(open_id → user_key)。 * - * 现状(2026-07):暂返回 null(不绑定)——两个前置未破: - * (1) 登录 app 与 meego app 的 open_id 是否同源(跨 app 隔离,meego 不返回 union_id), - * 需真机登录拿到 open_id 与 meego out_id 实测比对; - * (2) meego 成员枚举链路不稳(mcp/meego 官方节点 project_key 传参未通)。 - * 破验证后:在此按 deps.meegoProjectKey 装配 listMemberUserKeys/queryOutId/ - * getMeegoUserKeys/setMeegoUserKeys(经 providerFor(mcp/meego) 枚举 + NodeRegistryStore - * 读改写 plugins/meego 的 providerConfig.userKeys),bindMeegoIdentity 即自动生效。 + * 现状(2026-07-25,真机验证后):暂返回 null(不自动绑定)。 + * open_id 按 app 隔离已被实测坐实——登录 app 的 open_id(ou_fe43cd1b...)与 meego + * out_id(on_9540d6b7...)不同源,故基于 open_id 的 queryOutId 匹配恒失败(见 feishuLogin.ts + * meego 段注释)。跨 app 稳定标识只有 union_id,待验证 meego open_api 是否支持按 union_id + * 查 user_key;打通前 meego 绑定由管理员手动 patch userKeys(已验证可用)。 + * union_id 路打通后:在此按 deps.meegoProjectKey 装配注入面,bindMeegoIdentity 即自动生效。 */ function meegoBindDepsFor(deps: TbAppDeps): MeegoBindDeps | null { if (deps.meegoProjectKey === undefined) return null - // TODO(open_id 跨 app 对齐验证后接入):当前即便配了 projectKey 也先不绑,避免赌未验证的假设。 + // open_id 跨 app 不对齐已实证;union_id 路未打通前不自动绑,避免签发误绑他人身份。 return null } diff --git a/packages/gateway/wrangler.jsonc b/packages/gateway/wrangler.jsonc index 5ddf27f..a91b30c 100644 --- a/packages/gateway/wrangler.jsonc +++ b/packages/gateway/wrangler.jsonc @@ -8,24 +8,26 @@ // 生产只允许规范自定义域;关闭 workers.dev 与版本预览 URL,避免同一状态暴露多 origin。 "workers_dev": false, "preview_urls": false, - // DJJ 账户(.env CLOUDFLARE_ACCOUNT_ID);wrangler OAuth 下有多账户,须显式指定。 - "account_id": "0cb9b897010bc426cd8bf33d8c052d11", - // 生产入口 = TB_BASE_URL(.env);zone pdjjq.org 在同账户。 - "routes": [{ "pattern": "tool-bridge.pdjjq.org", "custom_domain": true }], + // Lightspeed 账户(.env CLOUDFLARE_ACCOUNT_ID);wrangler OAuth 下有多账户,须显式指定。 + "account_id": "041d4868e5611b45f9959f4f58c1e4c7", + // 生产入口 = TB_BASE_URL(.env);zone fantacy.live 在同账户。 + "routes": [{ "pattern": "tool-bridge.fantacy.live", "custom_domain": true }], "observability": { "enabled": true }, // r2 presign 的 S3 兼容端点(account_id 派生)与 bucket 名; // presign 凭证本体走 SecretStore 'r2-presign' 或 wrangler secret,不入 vars。 "vars": { - "TB_CANONICAL_ORIGIN": "https://tool-bridge.pdjjq.org", + "TB_CANONICAL_ORIGIN": "https://tool-bridge.fantacy.live", "TB_R2_BUCKET": "tb-r2", - "TB_R2_S3_ENDPOINT": "https://0cb9b897010bc426cd8bf33d8c052d11.r2.cloudflarestorage.com" + "TB_R2_S3_ENDPOINT": "https://041d4868e5611b45f9959f4f58c1e4c7.r2.cloudflarestorage.com", + // 飞书登录:复用 feishu plugin 的 SecretStore 凭证(feishu-app),设置即启用 /login。 + "TB_FEISHU_LOGIN_SECRET_REF": "feishu-app" }, // KV:树配置 / SK 哈希表 / manifest。id 为占位,由 scripts/provision.mjs // 幂等创建后回填;本地 wrangler dev / vitest-pool-workers 用 miniflare 本地实例,不校验该 id。 "kv_namespaces": [ - { "binding": "TB_KV", "id": "d18c93de33cf4ba2b1fbf7d26fd742f1" } + { "binding": "TB_KV", "id": "a1e48dcf2fe44872a828a992d6efd5e5" } ], // R2:context r2 provider 与大对象 $ref。 "r2_buckets": [ From 054cdb188a37ac138dc3bdd1d20f8af2b99feddd Mon Sep 17 00:00:00 2001 From: evilstar9527 Date: Sat, 25 Jul 2026 17:43:52 +0800 Subject: [PATCH 7/7] =?UTF-8?q?chore:=20=E5=9F=9F=E5=90=8D=20pdjjq.org=20?= =?UTF-8?q?=E2=86=92=20fantacy.live(=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= =?UTF-8?q?=20+=20=E6=B4=BB=E8=B7=83=E6=96=87=E6=A1=A3/env.example)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oauth 集成测试 redirect_uri 断言同步 canonicalOrigin(否则测试红) - llmdoc(deploy/current-state/project-brief/npm-publish)与 .env.example 更新域名 - .env.example 账户代号 DJJ → Lightspeed 保持准确 - archive/ 历史快照保留原样(记录当时确实用 pdjjq 部署的事实) --- .env.example | 6 +++--- llmdoc/guides/deploy-and-verify.md | 10 +++++----- llmdoc/guides/npm-publish.md | 2 +- llmdoc/must/current-state.md | 6 +++--- llmdoc/must/project-brief.md | 2 +- packages/gateway/test/oauth.integration.test.ts | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 5640f0f..27b8b77 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ # ============================================================ # Cloudflare(部署目标;操作经本地 wrangler OAuth,无需 API Token) # ============================================================ -# 账户:DJJ(已用 `wrangler whoami` 确认) +# 账户:Lightspeed(已用 `wrangler whoami` 确认) CLOUDFLARE_ACCOUNT_ID= # 可选:CI/无头环境用 API Token 代替本地 wrangler OAuth 登录。 @@ -16,7 +16,7 @@ CLOUDFLARE_ACCOUNT_ID= # ============================================================ # 部署形态 # ============================================================ -# 生产 BaseURL(custom domain;zone pdjjq.org 在 DJJ 账户下,Watt 已验证可挂) +# 生产 BaseURL(custom domain;zone fantacy.live 在 Lightspeed 账户下) TB_DOMAIN= TB_BASE_URL= @@ -51,7 +51,7 @@ TB_SK= # 部署后由 tb init 输出的 Admin SK 填入(明文只输 # TB_TEST_MCP_URL= # TB_TEST_MCP_BEARER= -# Phase 3 / E2E-2 的"外部 S3"兼容端点(用 DJJ 账户 R2 的 S3 API 即可: +# Phase 3 / E2E-2 的"外部 S3"兼容端点(用 Lightspeed 账户 R2 的 S3 API 即可: # https://.r2.cloudflarestorage.com) # TB_TEST_S3_ENDPOINT= # TB_TEST_S3_ACCESS_KEY_ID= diff --git a/llmdoc/guides/deploy-and-verify.md b/llmdoc/guides/deploy-and-verify.md index d726ddc..e27d74f 100644 --- a/llmdoc/guides/deploy-and-verify.md +++ b/llmdoc/guides/deploy-and-verify.md @@ -40,7 +40,7 @@ R2 bucket 'tb-r2' exists — skip provision done. … Deployed tb-gateway triggers (…) - https://tool-bridge.pdjjq.org (custom domain) + https://tool-bridge.fantacy.live (custom domain) Current Version ID: … ``` @@ -49,10 +49,10 @@ Current Version ID: … ### 3. 手工探活:curl ```sh -curl -s https://tool-bridge.pdjjq.org/healthz +curl -s https://tool-bridge.fantacy.live/healthz # → {"healthy":true,"version":""} curl -s -H "Authorization: Bearer $TB_SK" -H 'Accept: text/plain' \ - https://tool-bridge.pdjjq.org/~help | head -1 + https://tool-bridge.fantacy.live/~help | head -1 # → htbp 0.1 ``` @@ -75,7 +75,7 @@ rg -o 'assets/[A-Za-z0-9._-]+\.(js|css)' packages/dashboard/dist/index.html | so ### 5. 冒烟脚本:`TB_BASE_URL=… pnpm smoke` ```sh -TB_BASE_URL=https://tool-bridge.pdjjq.org pnpm smoke +TB_BASE_URL=https://tool-bridge.fantacy.live pnpm smoke ``` **注意:smoke 不读 `.env`**,必须显式传 `TB_BASE_URL`(或 `tsx scripts/smoke.ts `)。脚本只做只读探测(healthz + `~help`)。 @@ -88,7 +88,7 @@ ok GET /~help (no SK) → 401 TBError permission_denied ok GET /~help (with SK) → 200 text/markdown (default representation) ok GET /~help (Accept: text/plain) → 200 first line "htbp 0.1" -smoke passed against https://tool-bridge.pdjjq.org +smoke passed against https://tool-bridge.fantacy.live ``` ### 6. CLI 验证:`tb status --json` diff --git a/llmdoc/guides/npm-publish.md b/llmdoc/guides/npm-publish.md index 6932bfd..bd23d51 100644 --- a/llmdoc/guides/npm-publish.md +++ b/llmdoc/guides/npm-publish.md @@ -49,7 +49,7 @@ ### Dashboard 有两个独立发布面 - `dashboard-v<版本>` 触发 `publish-dashboard.yml`,只证明 `@tool-bridge/dashboard` 已发布到 npm;证据是 Actions run 成功 + `npm view @tool-bridge/dashboard dist-tags.latest` 命中目标版本。 -- 生产 `https://tool-bridge.pdjjq.org/ui/` 是 Gateway Worker 的 Static Assets,不从 npm dist-tag 自动更新;它随承载 Gateway 的部署流水线生效。当前项目在仓库外配置 Cloudflare Git 集成,`main` 推送后可能已经自动部署,仓库内没有对应 deploy workflow。 +- 生产 `https://tool-bridge.fantacy.live/ui/` 是 Gateway Worker 的 Static Assets,不从 npm dist-tag 自动更新;它随承载 Gateway 的部署流水线生效。当前项目在仓库外配置 Cloudflare Git 集成,`main` 推送后可能已经自动部署,仓库内没有对应 deploy workflow。 - 因此 Dashboard 发版必须分别报告「Dashboard npm 版本」与「生产 Worker version + `/ui` 产物身份」。`/healthz.version` 属于 Gateway 运行时,不能用来判断 Dashboard npm/静态资产版本。生产侧的部署去重、HTML/chunk hash 与 smoke 验收见 [deploy-and-verify.md](deploy-and-verify.md)。 ## 新增可发布包首发(两段式) diff --git a/llmdoc/must/current-state.md b/llmdoc/must/current-state.md index 3fc10d8..c36fbc5 100644 --- a/llmdoc/must/current-state.md +++ b/llmdoc/must/current-state.md @@ -31,7 +31,7 @@ | 资源 | 名称/地址 | 备注 | |---|---|---| -| Worker | `tb-gateway` @ https://tool-bridge.pdjjq.org | custom domain(zone pdjjq.org);`wrangler.jsonc` 已写死 `account_id`;DO `DeviceSession` 绑定 `TB_DEVICE`(migration v1,sqlite);Dashboard 经 Static Assets 挂 `/ui`(`run_worker_first: true`);Dashboard 0.6.0 的上线证据为 Worker version 44(`eb4e8daf-7b5d-44ff-a91b-5b8074348854`),后续内容等价部署不改变该发布事实;运行时 `/healthz` 版本属 Gateway 0.4.0,Static Assets 中 Dashboard 为 0.6.0(两者版本归属不可混用) | +| Worker | `tb-gateway` @ https://tool-bridge.fantacy.live | custom domain(zone fantacy.live);`wrangler.jsonc` 已写死 `account_id`;DO `DeviceSession` 绑定 `TB_DEVICE`(migration v1,sqlite);Dashboard 经 Static Assets 挂 `/ui`(`run_worker_first: true`);Dashboard 0.6.0 的上线证据为 Worker version 44(`eb4e8daf-7b5d-44ff-a91b-5b8074348854`),后续内容等价部署不改变该发布事实;运行时 `/healthz` 版本属 Gateway 0.4.0,Static Assets 中 Dashboard 为 0.6.0(两者版本归属不可混用) | | Worker secrets | `TB_BOOTSTRAP_ADMIN_SK` / `TB_SECRET_ENCRYPTION_KEY` | 已 `wrangler secret put`;前者是 Admin SK 明文(引导时 sha256 入库) | | KV | `tb-kv`(id `d18c93de33cf4ba2b1fbf7d26fd742f1`) | 绑定名 `TB_KV`;id 已回填 wrangler.jsonc | | R2 | `tb-r2` | 绑定名 `TB_R2` | @@ -64,7 +64,7 @@ - `pnpm deploy:all` — 幂等 provision + dashboard build + 部署 gateway。 - `pnpm --filter @tool-bridge/server start` — 本机起 Node 宿主(默认 :8787,数据落 ./data;env 面见 [../guides/docker-host.md](../guides/docker-host.md))。 - `docker build -t tool-bridge . && docker run -p 8787:8787 -v tbdata:/data …` — Docker 路径,验收命令全套见 [../guides/docker-host.md](../guides/docker-host.md)。 -- `TB_BASE_URL=https://tool-bridge.pdjjq.org pnpm smoke` — 线上冒烟(**smoke 不读 .env,须显式传 TB_BASE_URL 与 TB_SK**)。 +- `TB_BASE_URL=https://tool-bridge.fantacy.live pnpm smoke` — 线上冒烟(**smoke 不读 .env,须显式传 TB_BASE_URL 与 TB_SK**)。 - `npx tsx scripts/verify-revocation.ts` / `verify-device.ts` / `verify-plugin.ts` — 可重跑生产验收(需 TB_BASE_URL + TB_SK,消耗真实资源)。 - `TB_TEST_MCP_URL=http://127.0.0.1:39002/mcp TB_ALLOW_INSECURE_HTTP=true pnpm --filter @tool-bridge/gateway test -- tool.integration.test.ts` — opt-in MCP E2E(先 `ECHO_MCP_PORT=39002 pnpm --filter @tool-bridge/gateway echo-mcp` 起兜底上游)。 - `TB_TEST_LIVE_HTTP=1 pnpm --filter @tool-bridge/gateway test -- tool.integration.test.ts` — opt-in 真实 HTTP 上游(postman-echo)。 @@ -77,7 +77,7 @@ |---|---|---| | `CLOUDFLARE_ACCOUNT_ID` | 已配置 | DJJ 账户;验证用 `wrangler whoami`,勿用 `/user/tokens/verify` | | `CLOUDFLARE_API_TOKEN` | 空缺(注释掉) | 预期内:本地开发靠 wrangler OAuth,CI 时才需要 | -| `TB_DOMAIN` / `TB_BASE_URL` / `TB_NAME_PREFIX` | 已配置 | zone pdjjq.org;生产 BaseURL;资源命名前缀(=tb) | +| `TB_DOMAIN` / `TB_BASE_URL` / `TB_NAME_PREFIX` | 已配置 | zone fantacy.live;生产 BaseURL;资源命名前缀(=tb) | | `TB_SECRET_ENCRYPTION_KEY` | 已配置(32B base64url) | SecretStore env-only 信任根;已同步 `wrangler secret put` | | `TB_SK` | **已配置(= Admin SK)** | CLI/smoke/verify-* 脚本的默认凭证 | | `TB_TEST_MCP_URL` / `TB_TEST_MCP_BEARER` | 空缺(注释掉) | opt-in MCP 测试用;见下方兜底 | diff --git a/llmdoc/must/project-brief.md b/llmdoc/must/project-brief.md index 3865d22..50351b8 100644 --- a/llmdoc/must/project-brief.md +++ b/llmdoc/must/project-brief.md @@ -6,7 +6,7 @@ tool-bridge 是一个"自描述、可反向注册、协议开放的工具与上下文网关"——任何会 HTTP fetch 的 Agent,凭一个 Secret Key + 一个 BaseURL,就能发现并使用一个组织的全部工具、上下文与设备。产品形态 = 一棵自描述的 HTBP 树 + 围绕它的注册/鉴权/SDK/管理面;Agent、CLI、Dashboard 三类消费者共用同一入口。 -本仓库是 v1(私有仓库 TokenRollAI/tool-bridge)的重写。初步实现已完成并上线(生产 https://tool-bridge.pdjjq.org);v1 可复用资产与重写动机见 [../reference/v1-lessons.md](../reference/v1-lessons.md)。 +本仓库是 v1(私有仓库 TokenRollAI/tool-bridge)的重写。初步实现已完成并上线(生产 https://tool-bridge.fantacy.live);v1 可复用资产与重写动机见 [../reference/v1-lessons.md](../reference/v1-lessons.md)。 ## 知识真源 diff --git a/packages/gateway/test/oauth.integration.test.ts b/packages/gateway/test/oauth.integration.test.ts index 4f3cf95..1c66531 100644 --- a/packages/gateway/test/oauth.integration.test.ts +++ b/packages/gateway/test/oauth.integration.test.ts @@ -196,7 +196,7 @@ describe('mcp 托管 OAuth:授权全链路(默认离线,上游为 fetch mock)', expect(authUrl.searchParams.get('client_id')).toBe('dcr-client-1') expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256') expect(authUrl.searchParams.get('redirect_uri')).toBe( - 'https://tool-bridge.pdjjq.org/~oauth/callback', + 'https://tool-bridge.fantacy.live/~oauth/callback', ) const state = authUrl.searchParams.get('state') expect(state).toBeTruthy()