feat: add authenticated remote MCP access and complete business workflows
This commit is contained in:
1 parent
f40f4da781
commit
027a8c1b6a
35 files changed
+4430
-183
No files matched your search
@@ -3,3 +3,9 @@ PORT=3100
|
|||||||
# Local tunnel testing may use WEB_ORIGIN=*. Production requires an exact origin.
|
# Local tunnel testing may use WEB_ORIGIN=*. Production requires an exact origin.
|
||||||
WEB_ORIGIN=http://localhost:5173
|
WEB_ORIGIN=http://localhost:5173
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
# Canonical OAuth protected resource. Production must use HTTPS.
|
||||||
|
MCP_PUBLIC_URL=http://localhost:3100/mcp
|
||||||
|
# Web confirmation page, normally the same origin as WEB_ORIGIN.
|
||||||
|
MCP_WEB_URL=http://localhost:5173
|
||||||
|
# Exact browser Origin allowlist; never use *. Non-browser clients may omit Origin.
|
||||||
|
MCP_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
@@ -11,9 +11,12 @@
|
|||||||
"db:status": "node scripts/database.cjs status",
|
"db:status": "node scripts/database.cjs status",
|
||||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts",
|
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts",
|
||||||
"test:performance": "tsx scripts/performance.ts after",
|
"test:performance": "tsx scripts/performance.ts after",
|
||||||
|
"test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts",
|
||||||
|
"mcp:probe": "tsx scripts/mcp-probe.ts",
|
||||||
"icons:seed": "node scripts/seed-icons.cjs"
|
"icons:seed": "node scripts/seed-icons.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "1.31.0",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AgentPolicy` (
|
||||||
|
`userId` CHAR(36) NOT NULL,
|
||||||
|
`mode` VARCHAR(16) NOT NULL DEFAULT 'draft',
|
||||||
|
|
||||||
|
PRIMARY KEY (`userId`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AgentClient` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`metadata` JSON NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AgentAuthorization` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`userId` CHAR(36) NULL,
|
||||||
|
`clientId` CHAR(36) NOT NULL,
|
||||||
|
`parameters` JSON NOT NULL,
|
||||||
|
`codeDigest` CHAR(64) NULL,
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`status` VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
UNIQUE INDEX `AgentAuthorization_codeDigest_key`(`codeDigest`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AgentGrant` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`userId` CHAR(36) NOT NULL,
|
||||||
|
`clientId` CHAR(36) NULL,
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`scopes` JSON NOT NULL,
|
||||||
|
`resource` VARCHAR(500) NOT NULL,
|
||||||
|
`accessDigest` CHAR(64) NOT NULL,
|
||||||
|
`refreshDigest` CHAR(64) NULL,
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`refreshExpiresAt` DATETIME(3) NULL,
|
||||||
|
`revokedAt` DATETIME(3) NULL,
|
||||||
|
`sessionId` CHAR(64) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `AgentGrant_accessDigest_key`(`accessDigest`),
|
||||||
|
UNIQUE INDEX `AgentGrant_refreshDigest_key`(`refreshDigest`),
|
||||||
|
INDEX `AgentGrant_userId_createdAt_idx`(`userId`, `createdAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AgentOperation` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`userId` CHAR(36) NOT NULL,
|
||||||
|
`grantId` CHAR(36) NOT NULL,
|
||||||
|
`key` VARCHAR(128) NOT NULL,
|
||||||
|
`hash` CHAR(64) NOT NULL,
|
||||||
|
`tool` VARCHAR(100) NOT NULL,
|
||||||
|
`parameters` JSON NOT NULL,
|
||||||
|
`snapshot` CHAR(64) NOT NULL,
|
||||||
|
`status` VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`result` JSON NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`completedAt` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `AgentOperation_userId_createdAt_idx`(`userId`, `createdAt`),
|
||||||
|
UNIQUE INDEX `AgentOperation_userId_key_key`(`userId`, `key`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AgentPolicy` ADD CONSTRAINT `AgentPolicy_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AgentAuthorization` ADD CONSTRAINT `AgentAuthorization_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AgentGrant` ADD CONSTRAINT `AgentGrant_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AgentOperation` ADD CONSTRAINT `AgentOperation_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AgentCall` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`userId` CHAR(36) NOT NULL,
|
||||||
|
`grantId` CHAR(36) NOT NULL,
|
||||||
|
`tool` VARCHAR(100) NOT NULL,
|
||||||
|
`status` VARCHAR(16) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `AgentCall_userId_createdAt_idx`(`userId`, `createdAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AgentCall` ADD CONSTRAINT `AgentCall_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
ALTER TABLE `AgentPolicy` COMMENT '用户的 Agent 写入策略';
|
||||||
|
ALTER TABLE `AgentPolicy` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离';
|
||||||
|
ALTER TABLE `AgentPolicy` MODIFY `mode` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '写入策略:readonly 只读、draft 草稿、direct 普通直接写入';
|
||||||
|
ALTER TABLE `AgentClient` COMMENT 'OAuth 动态注册客户端元数据';
|
||||||
|
ALTER TABLE `AgentClient` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识';
|
||||||
|
ALTER TABLE `AgentClient` MODIFY `metadata` JSON NOT NULL COMMENT '公开客户端注册元数据,不保存用户凭据';
|
||||||
|
ALTER TABLE `AgentClient` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,UTC';
|
||||||
|
ALTER TABLE `AgentAuthorization` COMMENT '短期 OAuth 授权请求及一次性授权码摘要';
|
||||||
|
ALTER TABLE `AgentAuthorization` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识';
|
||||||
|
ALTER TABLE `AgentAuthorization` MODIFY `userId` CHAR(36) NULL COMMENT '所属用户标识,用于数据隔离';
|
||||||
|
ALTER TABLE `AgentAuthorization` MODIFY `clientId` CHAR(36) NOT NULL COMMENT 'OAuth 客户端标识;个人令牌为空';
|
||||||
|
ALTER TABLE `AgentAuthorization` MODIFY `parameters` JSON NOT NULL COMMENT '具体操作参数;禁止保存密码及令牌';
|
||||||
|
ALTER TABLE `AgentAuthorization` MODIFY `codeDigest` CHAR(64) NULL COMMENT '一次性授权码 SHA-256 摘要';
|
||||||
|
ALTER TABLE `AgentAuthorization` MODIFY `expiresAt` DATETIME(3) NOT NULL COMMENT '到期时间,UTC';
|
||||||
|
ALTER TABLE `AgentAuthorization` MODIFY `status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '当前处理状态';
|
||||||
|
ALTER TABLE `AgentGrant` COMMENT '用户授权连接、令牌摘要及资源权限';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `clientId` CHAR(36) NULL COMMENT 'OAuth 客户端标识;个人令牌为空';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `name` VARCHAR(100) NOT NULL COMMENT '用户可见连接名称';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `scopes` JSON NOT NULL COMMENT '权限列表:read、draft、write、sensitive';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `resource` VARCHAR(500) NOT NULL COMMENT '令牌适用的规范 MCP 资源地址';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `accessDigest` CHAR(64) NOT NULL COMMENT '访问令牌 SHA-256 摘要;完整值仅颁发时返回';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `refreshDigest` CHAR(64) NULL COMMENT '刷新令牌 SHA-256 摘要,使用后轮换';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `expiresAt` DATETIME(3) NOT NULL COMMENT '到期时间,UTC';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `refreshExpiresAt` DATETIME(3) NULL COMMENT '刷新授权到期时间,UTC';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `revokedAt` DATETIME(3) NULL COMMENT '撤销时间,UTC;空表示未撤销';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `sessionId` CHAR(64) NOT NULL COMMENT '本连接的独立业务授权会话标识,不作为 MCP 凭证';
|
||||||
|
ALTER TABLE `AgentGrant` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,UTC';
|
||||||
|
ALTER TABLE `AgentOperation` COMMENT 'Agent 写入草稿、幂等记录及提交结果';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `grantId` CHAR(36) NOT NULL COMMENT '发起操作的授权连接标识';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `key` VARCHAR(128) NOT NULL COMMENT '同用户唯一的客户端幂等键';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `hash` CHAR(64) NOT NULL COMMENT '工具和参数的规范摘要;拒绝同键不同参数';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `tool` VARCHAR(100) NOT NULL COMMENT '业务工具名称';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `parameters` JSON NOT NULL COMMENT '具体操作参数;禁止保存密码及令牌';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `snapshot` CHAR(64) NOT NULL COMMENT '确认前的账目版本摘要,防止覆盖并发修改';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '当前处理状态';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `expiresAt` DATETIME(3) NOT NULL COMMENT '到期时间,UTC';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `result` JSON NULL COMMENT '结构化操作结果或最近提交失败原因';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,UTC';
|
||||||
|
ALTER TABLE `AgentOperation` MODIFY `completedAt` DATETIME(3) NULL COMMENT '完成或取消时间,UTC';
|
||||||
|
ALTER TABLE `AgentCall` COMMENT 'MCP 调用审计,不保存参数、密码、令牌或财务内容';
|
||||||
|
ALTER TABLE `AgentCall` MODIFY `id` CHAR(36) NOT NULL COMMENT '审计标识';
|
||||||
|
ALTER TABLE `AgentCall` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离';
|
||||||
|
ALTER TABLE `AgentCall` MODIFY `grantId` CHAR(36) NOT NULL COMMENT '调用所属连接标识';
|
||||||
|
ALTER TABLE `AgentCall` MODIFY `tool` VARCHAR(100) NOT NULL COMMENT '调用的工具名称';
|
||||||
|
ALTER TABLE `AgentCall` MODIFY `status` VARCHAR(16) NOT NULL COMMENT '结果状态:success 或 error';
|
||||||
|
ALTER TABLE `AgentCall` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '调用完成时间,UTC';
|
||||||
@@ -42,6 +42,133 @@ model User {
|
|||||||
icons Icon[]
|
icons Icon[]
|
||||||
transfers Transfer[]
|
transfers Transfer[]
|
||||||
schedules Schedule[]
|
schedules Schedule[]
|
||||||
|
agentGrants AgentGrant[]
|
||||||
|
agentOperations AgentOperation[]
|
||||||
|
agentAuthorizations AgentAuthorization[]
|
||||||
|
agentPolicy AgentPolicy?
|
||||||
|
agentCalls AgentCall[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 调用审计,不保存工具参数、令牌、密码或财务内容
|
||||||
|
model AgentCall {
|
||||||
|
/// 审计记录标识
|
||||||
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
/// 可信认证上下文中的用户标识
|
||||||
|
userId String @db.Char(36)
|
||||||
|
/// 所属用户;注销后级联清理
|
||||||
|
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||||
|
/// 发起调用的连接标识
|
||||||
|
grantId String @db.Char(36)
|
||||||
|
/// 工具名称
|
||||||
|
tool String @db.VarChar(100)
|
||||||
|
/// 结果状态 success 或 error
|
||||||
|
status String @db.VarChar(16)
|
||||||
|
/// 调用完成时间,UTC
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
@@index([userId,createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户的 Agent 写入策略
|
||||||
|
model AgentPolicy {
|
||||||
|
/// 所属用户标识,用于数据隔离
|
||||||
|
userId String @id @db.Char(36)
|
||||||
|
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||||
|
/// 写入策略:readonly 只读、draft 草稿、direct 普通直接写入
|
||||||
|
mode String @default("draft") @db.VarChar(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OAuth 动态注册客户端元数据
|
||||||
|
model AgentClient {
|
||||||
|
/// 唯一标识
|
||||||
|
id String @id @db.Char(36)
|
||||||
|
/// 公开客户端注册元数据,不保存用户凭据
|
||||||
|
metadata Json
|
||||||
|
/// 创建时间,UTC
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 短期 OAuth 授权请求及一次性授权码摘要
|
||||||
|
model AgentAuthorization {
|
||||||
|
/// 唯一标识
|
||||||
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
/// 所属用户标识,用于数据隔离
|
||||||
|
userId String? @db.Char(36)
|
||||||
|
user User? @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||||
|
/// OAuth 客户端标识;个人令牌为空
|
||||||
|
clientId String @db.Char(36)
|
||||||
|
/// 具体操作参数;禁止保存密码及令牌
|
||||||
|
parameters Json
|
||||||
|
/// 一次性授权码 SHA-256 摘要
|
||||||
|
codeDigest String? @unique @db.Char(64)
|
||||||
|
/// 到期时间,UTC
|
||||||
|
expiresAt DateTime
|
||||||
|
/// 当前处理状态
|
||||||
|
status String @default("pending") @db.VarChar(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户授权连接、令牌摘要及资源权限
|
||||||
|
model AgentGrant {
|
||||||
|
/// 唯一标识
|
||||||
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
/// 所属用户标识,用于数据隔离
|
||||||
|
userId String @db.Char(36)
|
||||||
|
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||||
|
/// OAuth 客户端标识;个人令牌为空
|
||||||
|
clientId String? @db.Char(36)
|
||||||
|
/// 用户可见连接名称
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
/// 权限列表:read、draft、write、sensitive
|
||||||
|
scopes Json
|
||||||
|
/// 令牌适用的规范 MCP 资源地址
|
||||||
|
resource String @db.VarChar(500)
|
||||||
|
/// 访问令牌 SHA-256 摘要;完整值仅颁发时返回
|
||||||
|
accessDigest String @unique @db.Char(64)
|
||||||
|
/// 刷新令牌 SHA-256 摘要,使用后轮换
|
||||||
|
refreshDigest String? @unique @db.Char(64)
|
||||||
|
/// 到期时间,UTC
|
||||||
|
expiresAt DateTime
|
||||||
|
/// 刷新授权到期时间,UTC
|
||||||
|
refreshExpiresAt DateTime?
|
||||||
|
/// 撤销时间,UTC;空表示未撤销
|
||||||
|
revokedAt DateTime?
|
||||||
|
/// 本连接的独立业务授权会话标识,不作为 MCP 凭证
|
||||||
|
sessionId String @db.Char(64)
|
||||||
|
/// 创建时间,UTC
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
@@index([userId,createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent 写入草稿、幂等记录及提交结果
|
||||||
|
model AgentOperation {
|
||||||
|
/// 唯一标识
|
||||||
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
/// 所属用户标识,用于数据隔离
|
||||||
|
userId String @db.Char(36)
|
||||||
|
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||||
|
/// 发起操作的授权连接标识
|
||||||
|
grantId String @db.Char(36)
|
||||||
|
/// 同用户唯一的客户端幂等键
|
||||||
|
key String @db.VarChar(128)
|
||||||
|
/// 工具和参数的规范摘要;拒绝同键不同参数
|
||||||
|
hash String @db.Char(64)
|
||||||
|
/// 业务工具名称
|
||||||
|
tool String @db.VarChar(100)
|
||||||
|
/// 具体操作参数;禁止保存密码及令牌
|
||||||
|
parameters Json
|
||||||
|
/// 确认前的账目版本摘要,防止覆盖并发修改
|
||||||
|
snapshot String @db.Char(64)
|
||||||
|
/// 当前处理状态
|
||||||
|
status String @default("pending") @db.VarChar(16)
|
||||||
|
/// 到期时间,UTC
|
||||||
|
expiresAt DateTime
|
||||||
|
/// 结构化操作结果或最近提交失败原因
|
||||||
|
result Json?
|
||||||
|
/// 创建时间,UTC
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 完成或取消时间,UTC
|
||||||
|
completedAt DateTime?
|
||||||
|
@@unique([userId,key])
|
||||||
|
@@index([userId,createdAt])
|
||||||
}
|
}
|
||||||
/// 登录会话与隐藏项目查看授权
|
/// 登录会话与隐藏项目查看授权
|
||||||
model Session {
|
model Session {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Protocol diagnostic client, not an AI agent. Token stays in memory and is never printed.
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
async function main() {
|
||||||
|
if (!process.env.MCP_ACCESS_TOKEN)
|
||||||
|
throw Error('Set MCP_ACCESS_TOKEN to a WorthPath personal access token');
|
||||||
|
const client = new Client({ name: 'WorthPath MCP probe', version: '1.31.0' });
|
||||||
|
try {
|
||||||
|
await client.connect(
|
||||||
|
new StreamableHTTPClientTransport(
|
||||||
|
new URL(process.env.MCP_SERVER_URL || 'http://localhost:3100/mcp'),
|
||||||
|
{ requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
server: client.getServerVersion(),
|
||||||
|
tools: (await client.listTools()).tools.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
annotations: t.annotations,
|
||||||
|
})),
|
||||||
|
connection: await client.callTool({ name: 'connection_info', arguments: {} }),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void main().catch(() => {
|
||||||
|
console.error(
|
||||||
|
'MCP probe failed: check server URL, token expiry, revocation and resource configuration.',
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+67
-26
@@ -22,7 +22,13 @@ import { hash, compare } from 'bcryptjs';
|
|||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
import { credentials, credentialChange, defaultOverviewCards } from './validation';
|
import { credentials, credentialChange, defaultOverviewCards } from './validation';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
|
export type UserRequest = Request & {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
revealed: boolean;
|
||||||
|
agent?: boolean;
|
||||||
|
agentGrantId?: string;
|
||||||
|
};
|
||||||
const Public = () => SetMetadata('public', true);
|
const Public = () => SetMetadata('public', true);
|
||||||
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||||
export function allowedOrigin(
|
export function allowedOrigin(
|
||||||
@@ -44,7 +50,9 @@ export class AuthService {
|
|||||||
private attempts = new Map<string, { count: number; until: number }>();
|
private attempts = new Map<string, { count: number; until: number }>();
|
||||||
constructor(private db: Database) {}
|
constructor(private db: Database) {}
|
||||||
limit(req: Request) {
|
limit(req: Request) {
|
||||||
const key = req.ip || 'local',
|
const key =
|
||||||
|
(req.ip || 'local') +
|
||||||
|
('userId' in req && typeof req.userId === 'string' ? ':' + req.userId : ''),
|
||||||
now = Date.now();
|
now = Date.now();
|
||||||
let v = this.attempts.get(key);
|
let v = this.attempts.get(key);
|
||||||
if (!v || v.until < now) {
|
if (!v || v.until < now) {
|
||||||
@@ -124,20 +132,17 @@ export class AuthGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Controller('api')
|
|
||||||
export class AuthController {
|
@Injectable()
|
||||||
|
export class AuthBusinessService {
|
||||||
constructor(
|
constructor(
|
||||||
private db: Database,
|
private db: Database,
|
||||||
private auth: AuthService,
|
private auth: AuthService,
|
||||||
) {}
|
) {}
|
||||||
@Public() @Get('health') health() {
|
health() {
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
@Public() @Post('auth/register') async register(
|
async register(body: unknown, req: Request, res: Response) {
|
||||||
@Body() body: unknown,
|
|
||||||
@Req() req: Request,
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
this.auth.limit(req);
|
this.auth.limit(req);
|
||||||
const v = credentials.parse(body),
|
const v = credentials.parse(body),
|
||||||
user = await this.db.user.create({
|
user = await this.db.user.create({
|
||||||
@@ -146,11 +151,7 @@ export class AuthController {
|
|||||||
await this.auth.issue(user.id, res);
|
await this.auth.issue(user.id, res);
|
||||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||||
}
|
}
|
||||||
@Public() @Post('auth/login') async login(
|
async login(body: unknown, req: Request, res: Response) {
|
||||||
@Body() body: unknown,
|
|
||||||
@Req() req: Request,
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
this.auth.limit(req);
|
this.auth.limit(req);
|
||||||
const v = credentials.parse(body),
|
const v = credentials.parse(body),
|
||||||
user = await this.db.user.findUnique({ where: { username: v.username } });
|
user = await this.db.user.findUnique({ where: { username: v.username } });
|
||||||
@@ -162,7 +163,7 @@ export class AuthController {
|
|||||||
await this.auth.issue(user.id, res);
|
await this.auth.issue(user.id, res);
|
||||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||||
}
|
}
|
||||||
@Get('auth/me') async me(@Req() req: UserRequest) {
|
async me(req: UserRequest) {
|
||||||
const user = await this.db.user.findUniqueOrThrow({
|
const user = await this.db.user.findUniqueOrThrow({
|
||||||
where: { id: req.userId },
|
where: { id: req.userId },
|
||||||
select: {
|
select: {
|
||||||
@@ -191,11 +192,7 @@ export class AuthController {
|
|||||||
lastActivity: session.lastActivity,
|
lastActivity: session.lastActivity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Patch('auth/credentials') async changeCredentials(
|
async changeCredentials(r: UserRequest, body: unknown, res: Response) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Body() body: unknown,
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
this.auth.limit(r);
|
this.auth.limit(r);
|
||||||
const v = credentialChange.parse(body);
|
const v = credentialChange.parse(body);
|
||||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||||
@@ -221,14 +218,14 @@ export class AuthController {
|
|||||||
this.auth.cookie(token, expiresAt, res);
|
this.auth.cookie(token, expiresAt, res);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
async activity(r: UserRequest) {
|
||||||
await this.db.session.update({
|
await this.db.session.update({
|
||||||
where: { id: r.sessionId },
|
where: { id: r.sessionId },
|
||||||
data: { lastActivity: new Date() },
|
data: { lastActivity: new Date() },
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) {
|
async reveal(r: UserRequest, b: unknown) {
|
||||||
this.auth.limit(r);
|
this.auth.limit(r);
|
||||||
const revealUntil = await this.db.serial(async (tx) => {
|
const revealUntil = await this.db.serial(async (tx) => {
|
||||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||||
@@ -243,15 +240,59 @@ export class AuthController {
|
|||||||
});
|
});
|
||||||
return { revealUntil };
|
return { revealUntil };
|
||||||
}
|
}
|
||||||
@Post('auth/lock') async lock(@Req() r: UserRequest) {
|
async lock(r: UserRequest) {
|
||||||
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil: null } });
|
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil: null } });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
async logout(req: Request, res: Response) {
|
||||||
|
await this.auth.logout(req, res);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private service: AuthBusinessService) {}
|
||||||
|
@Public() @Get('health') health() {
|
||||||
|
return this.service.health();
|
||||||
|
}
|
||||||
|
@Public() @Post('auth/register') async register(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
return this.service.register(body, req, res);
|
||||||
|
}
|
||||||
|
@Public() @Post('auth/login') async login(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
return this.service.login(body, req, res);
|
||||||
|
}
|
||||||
|
@Get('auth/me') async me(@Req() req: UserRequest) {
|
||||||
|
return this.service.me(req);
|
||||||
|
}
|
||||||
|
@Patch('auth/credentials') async changeCredentials(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
return this.service.changeCredentials(r, body, res);
|
||||||
|
}
|
||||||
|
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
||||||
|
return this.service.activity(r);
|
||||||
|
}
|
||||||
|
@Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) {
|
||||||
|
return this.service.reveal(r, b);
|
||||||
|
}
|
||||||
|
@Post('auth/lock') async lock(@Req() r: UserRequest) {
|
||||||
|
return this.service.lock(r);
|
||||||
|
}
|
||||||
@Post('auth/logout') async logout(
|
@Post('auth/logout') async logout(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
) {
|
) {
|
||||||
await this.auth.logout(req, res);
|
return this.service.logout(req, res);
|
||||||
return { ok: true };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+64
-19
@@ -1,3 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { metalConfig, metalPriceInput } from './metals';
|
import { metalConfig, metalPriceInput } from './metals';
|
||||||
import { scheduleInput } from './schedules';
|
import { scheduleInput } from './schedules';
|
||||||
import { movementDeltas } from './movement';
|
import { movementDeltas } from './movement';
|
||||||
@@ -298,8 +299,9 @@ export function validateBackup(raw: unknown) {
|
|||||||
if (!b.currencies.includes(b.baseCurrency)) throw new BadRequestException('本位币清单不完整');
|
if (!b.currencies.includes(b.baseCurrency)) throw new BadRequestException('本位币清单不完整');
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
@Controller('api/backup')
|
|
||||||
export class BackupController implements OnModuleDestroy, OnModuleInit {
|
@Injectable()
|
||||||
|
export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
||||||
private uploads = new Map<
|
private uploads = new Map<
|
||||||
string,
|
string,
|
||||||
{ sessionId: string; userId: string; path: string; expires: number }
|
{ sessionId: string; userId: string; path: string; expires: number }
|
||||||
@@ -336,6 +338,20 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
: JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
|
: JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
|
||||||
}
|
}
|
||||||
constructor(private db: Database) {}
|
constructor(private db: Database) {}
|
||||||
|
async snapshot(userId: string) {
|
||||||
|
return this.fingerprint(await this.data(userId));
|
||||||
|
}
|
||||||
|
async inspectUpload(r: UserRequest, token: string) {
|
||||||
|
const v = this.uploads.get(token);
|
||||||
|
if (!v || v.userId !== r.userId || v.sessionId !== r.sessionId || v.expires < Date.now())
|
||||||
|
throw new BadRequestException('导入预览已失效,请重新上传备份');
|
||||||
|
const data = await this.uploadedData(v.path);
|
||||||
|
return { data, preview: await this.preview(r, data) };
|
||||||
|
}
|
||||||
|
async restoreUpload(r: UserRequest, token: string) {
|
||||||
|
const prepared = await this.inspectUpload(r, token);
|
||||||
|
return this.restore(r, { confirmed: true, backup: prepared.data });
|
||||||
|
}
|
||||||
private async data(
|
private async data(
|
||||||
userId: string,
|
userId: string,
|
||||||
client: Database | Prisma.TransactionClient = this.db,
|
client: Database | Prisma.TransactionClient = this.db,
|
||||||
@@ -472,10 +488,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Get() async download(@Req() r: UserRequest, @Res() res: Response) {
|
async download(r: UserRequest, res: Response, expectedFingerprint?: string) {
|
||||||
const b = await this.db.$transaction(
|
const b = await this.db.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const b = await this.data(r.userId, tx);
|
const b = await this.data(r.userId, tx);
|
||||||
|
if (expectedFingerprint && this.fingerprint(b) !== expectedFingerprint)
|
||||||
|
throw new ConflictException('账目已变化,请重新确认备份导出');
|
||||||
await tx.session.update({
|
await tx.session.update({
|
||||||
where: { id: r.sessionId },
|
where: { id: r.sessionId },
|
||||||
data: {
|
data: {
|
||||||
@@ -498,17 +516,8 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
archive.pipe(res);
|
archive.pipe(res);
|
||||||
await archive.finalize().catch(() => res.destroy());
|
await archive.finalize().catch(() => res.destroy());
|
||||||
}
|
}
|
||||||
@Post('upload')
|
|
||||||
@UseInterceptors(
|
async upload(r: UserRequest, file?: Express.Multer.File) {
|
||||||
FileInterceptor('file', {
|
|
||||||
storage: diskStorage({
|
|
||||||
destination: tmpdir(),
|
|
||||||
filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
|
|
||||||
}),
|
|
||||||
limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) {
|
|
||||||
if (!file) throw new BadRequestException('请选择 ZIP 备份文件');
|
if (!file) throw new BadRequestException('请选择 ZIP 备份文件');
|
||||||
try {
|
try {
|
||||||
const b = validateBackup(await this.uploadedData(file.path));
|
const b = validateBackup(await this.uploadedData(file.path));
|
||||||
@@ -534,7 +543,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.prune();
|
await this.prune();
|
||||||
}
|
}
|
||||||
@Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) {
|
async importFile(r: UserRequest, raw: unknown) {
|
||||||
const { token } = z
|
const { token } = z
|
||||||
.object({ confirmed: z.literal(true), token: z.string().uuid() })
|
.object({ confirmed: z.literal(true), token: z.string().uuid() })
|
||||||
.strict()
|
.strict()
|
||||||
@@ -566,11 +575,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||||
}
|
}
|
||||||
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
|
async clearStatus(r: UserRequest) {
|
||||||
const s = await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } });
|
const s = await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } });
|
||||||
return { ready: !!s.backupDigest && !!s.backupExpiresAt && +s.backupExpiresAt > Date.now() };
|
return { ready: !!s.backupDigest && !!s.backupExpiresAt && +s.backupExpiresAt > Date.now() };
|
||||||
}
|
}
|
||||||
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
async clear(r: UserRequest, raw: unknown) {
|
||||||
z.object({ confirmation: z.literal('确定清空') })
|
z.object({ confirmation: z.literal('确定清空') })
|
||||||
.strict()
|
.strict()
|
||||||
.parse(raw);
|
.parse(raw);
|
||||||
@@ -596,7 +605,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
|
async preview(r: UserRequest, raw: unknown) {
|
||||||
const b = validateBackup(raw),
|
const b = validateBackup(raw),
|
||||||
existing = await this.data(r.userId);
|
existing = await this.data(r.userId);
|
||||||
for (const i of b.icons || []) await validateStoredIcon(i.image, i.hash);
|
for (const i of b.icons || []) await validateStoredIcon(i.image, i.hash);
|
||||||
@@ -631,7 +640,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
throw new ConflictException('已有同日汇率与备份冲突,未修改数据');
|
throw new ConflictException('已有同日汇率与备份冲突,未修改数据');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) {
|
async restore(r: UserRequest, raw: unknown) {
|
||||||
const { backup } = z
|
const { backup } = z
|
||||||
.object({ confirmed: z.literal(true), backup: backupSchema })
|
.object({ confirmed: z.literal(true), backup: backupSchema })
|
||||||
.strict()
|
.strict()
|
||||||
@@ -819,3 +828,39 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api/backup')
|
||||||
|
export class BackupController {
|
||||||
|
constructor(private service: BackupBusinessService) {}
|
||||||
|
@Get() async download(@Req() r: UserRequest, @Res() res: Response) {
|
||||||
|
return this.service.download(r, res);
|
||||||
|
}
|
||||||
|
@Post('upload')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: tmpdir(),
|
||||||
|
filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
|
||||||
|
}),
|
||||||
|
limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) {
|
||||||
|
return this.service.upload(r, file);
|
||||||
|
}
|
||||||
|
@Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
|
return this.service.importFile(r, raw);
|
||||||
|
}
|
||||||
|
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
|
||||||
|
return this.service.clearStatus(r);
|
||||||
|
}
|
||||||
|
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
|
return this.service.clear(r, raw);
|
||||||
|
}
|
||||||
|
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
|
return this.service.preview(r, raw);
|
||||||
|
}
|
||||||
|
@Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
|
return this.service.restore(r, raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import Decimal from 'decimal.js';
|
import Decimal from 'decimal.js';
|
||||||
@@ -24,8 +25,9 @@ export function calendarMonth(value: unknown) {
|
|||||||
const last = new Date(+next - 86400000).toISOString().slice(0, 10);
|
const last = new Date(+next - 86400000).toISOString().slice(0, 10);
|
||||||
return { month, from, to: last > today() ? today() : last, days: Number(last.slice(-2)) };
|
return { month, from, to: last > today() ? today() : last, days: Number(last.slice(-2)) };
|
||||||
}
|
}
|
||||||
@Controller('api/calendar')
|
|
||||||
export class CalendarController {
|
@Injectable()
|
||||||
|
export class CalendarBusinessService {
|
||||||
constructor(private db: Database) {}
|
constructor(private db: Database) {}
|
||||||
private async replay(
|
private async replay(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
@@ -113,7 +115,7 @@ export class CalendarController {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Get() async month(@Req() r: UserRequest, @Query('month') input?: string) {
|
async month(r: UserRequest, input?: string) {
|
||||||
const q = calendarMonth(input);
|
const q = calendarMonth(input);
|
||||||
return this.db.$transaction(
|
return this.db.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
@@ -144,7 +146,7 @@ export class CalendarController {
|
|||||||
{ timeout: 30000 },
|
{ timeout: 30000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) {
|
async day(r: UserRequest, input?: string) {
|
||||||
const day = date.parse(input || today());
|
const day = date.parse(input || today());
|
||||||
return this.db.$transaction(
|
return this.db.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
@@ -160,3 +162,14 @@ export class CalendarController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api/calendar')
|
||||||
|
export class CalendarController {
|
||||||
|
constructor(private service: CalendarBusinessService) {}
|
||||||
|
@Get() async month(@Req() r: UserRequest, @Query('month') input?: string) {
|
||||||
|
return this.service.month(r, input);
|
||||||
|
}
|
||||||
|
@Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) {
|
||||||
|
return this.service.day(r, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,35 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient, Prisma } from '@prisma/client';
|
import { PrismaClient, Prisma } from '@prisma/client';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
const transactions = new AsyncLocalStorage<Prisma.TransactionClient>();
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
async serial<T>(work: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
constructor() {
|
||||||
|
super();
|
||||||
|
return new Proxy(this, {
|
||||||
|
get(target, key, receiver) {
|
||||||
|
const tx = transactions.getStore();
|
||||||
|
if (tx && key === '$transaction')
|
||||||
|
return (work: any) => (typeof work === 'function' ? work(tx) : Promise.all(work));
|
||||||
|
if (tx && key in tx && key !== '$disconnect' && key !== '$connect') {
|
||||||
|
const value = (tx as any)[key];
|
||||||
|
return typeof value === 'function' ? value.bind(tx) : value;
|
||||||
|
}
|
||||||
|
return Reflect.get(target, key, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async atomic<T>(work: () => Promise<T>, timeout = 30000): Promise<T> {
|
||||||
|
return this.serial((tx) => transactions.run(tx, work), timeout);
|
||||||
|
}
|
||||||
|
async serial<T>(work: (tx: Prisma.TransactionClient) => Promise<T>, timeout = 30000): Promise<T> {
|
||||||
|
const current = transactions.getStore();
|
||||||
|
if (current) return work(current);
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
return await this.$transaction(work, {
|
return await this.$transaction(work, {
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||||
timeout: 30000,
|
timeout,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Serializable deadlock/write conflict; retry the entire atomic operation.
|
// Serializable deadlock/write conflict; retry the entire atomic operation.
|
||||||
|
|||||||
+36
-20
@@ -76,10 +76,11 @@ export class IconsService {
|
|||||||
throw new BadRequestException('图标不存在或无权使用');
|
throw new BadRequestException('图标不存在或无权使用');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Controller('api/icons')
|
|
||||||
export class IconsController {
|
@Injectable()
|
||||||
|
export class IconsBusinessService {
|
||||||
constructor(private db: Database) {}
|
constructor(private db: Database) {}
|
||||||
@Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') {
|
async list(r: UserRequest, q = '', page = '1') {
|
||||||
const query = z.string().trim().max(100).parse(q);
|
const query = z.string().trim().max(100).parse(q);
|
||||||
const index = z.coerce.number().int().min(1).max(100000).parse(page);
|
const index = z.coerce.number().int().min(1).max(100000).parse(page);
|
||||||
const where = { OR: [{ shared: true }, { ownerId: r.userId }], name: { contains: query } };
|
const where = { OR: [{ shared: true }, { ownerId: r.userId }], name: { contains: query } };
|
||||||
@@ -95,11 +96,7 @@ export class IconsController {
|
|||||||
]);
|
]);
|
||||||
return { items, total, page: index };
|
return { items, total, page: index };
|
||||||
}
|
}
|
||||||
@Get(':id/image') async image(
|
async image(r: UserRequest, id: string, res: Response) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Res() res: Response,
|
|
||||||
) {
|
|
||||||
const icon = await this.db.icon.findFirst({
|
const icon = await this.db.icon.findFirst({
|
||||||
where: { id, OR: [{ shared: true }, { ownerId: r.userId }] },
|
where: { id, OR: [{ shared: true }, { ownerId: r.userId }] },
|
||||||
});
|
});
|
||||||
@@ -110,18 +107,8 @@ export class IconsController {
|
|||||||
// Preserve essential white artwork rather than applying the cutout twice.
|
// Preserve essential white artwork rather than applying the cutout twice.
|
||||||
res.send(Buffer.from(icon.data));
|
res.send(Buffer.from(icon.data));
|
||||||
}
|
}
|
||||||
@Post('upload')
|
|
||||||
@UseInterceptors(
|
async upload(r: UserRequest, raw: unknown, file?: Express.Multer.File) {
|
||||||
FileInterceptor('file', {
|
|
||||||
storage: memoryStorage(),
|
|
||||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
async upload(
|
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Body() raw: unknown,
|
|
||||||
@UploadedFile() file?: Express.Multer.File,
|
|
||||||
) {
|
|
||||||
const v = z
|
const v = z
|
||||||
.object({
|
.object({
|
||||||
name: iconName,
|
name: iconName,
|
||||||
@@ -145,3 +132,32 @@ export class IconsController {
|
|||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api/icons')
|
||||||
|
export class IconsController {
|
||||||
|
constructor(private service: IconsBusinessService) {}
|
||||||
|
@Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') {
|
||||||
|
return this.service.list(r, q, page);
|
||||||
|
}
|
||||||
|
@Get(':id/image') async image(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
return this.service.image(r, id, res);
|
||||||
|
}
|
||||||
|
@Post('upload')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async upload(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Body() raw: unknown,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.service.upload(r, raw, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-9
@@ -6,18 +6,24 @@ import { NestFactory, APP_GUARD } from '@nestjs/core';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { json } from 'express';
|
import { json } from 'express';
|
||||||
import { AuthController, AuthGuard, AuthService } from './auth';
|
import { AuthController, AuthBusinessService, AuthGuard, AuthService } from './auth';
|
||||||
import { CalendarController } from './calendar';
|
import { CalendarController, CalendarBusinessService } from './calendar';
|
||||||
import { SchedulesController } from './schedules';
|
import { SchedulesController, SchedulesBusinessService } from './schedules';
|
||||||
import { TransfersController } from './transfers';
|
import { TransfersController, TransfersBusinessService } from './transfers';
|
||||||
import { PortfolioController } from './portfolio';
|
import { PortfolioController, PortfolioBusinessService } from './portfolio';
|
||||||
import { BackupController } from './backup';
|
import { BackupController, BackupBusinessService } from './backup';
|
||||||
import { IconsController, IconsService } from './icons';
|
import { IconsController, IconsBusinessService, IconsService } from './icons';
|
||||||
import { MetalsService, MetalsController } from './metals';
|
import { MetalsService, MetalsController, MetalsBusinessService } from './metals';
|
||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
import { RatesService, SettingsController } from './rates';
|
import { RatesService, SettingsController, SettingsBusinessService } from './rates';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AgentOAuth } from './mcp/oauth';
|
||||||
|
import { AgentCatalogue } from './mcp/catalogue';
|
||||||
|
import { AgentOperations } from './mcp/operations';
|
||||||
|
import { AgentFiles } from './mcp/files';
|
||||||
|
import { AgentTransport } from './mcp/transport';
|
||||||
|
import { AgentManagementController } from './mcp/management';
|
||||||
@Catch()
|
@Catch()
|
||||||
class SafeErrors implements ExceptionFilter {
|
class SafeErrors implements ExceptionFilter {
|
||||||
catch(error: unknown, host: ArgumentsHost) {
|
catch(error: unknown, host: ArgumentsHost) {
|
||||||
@@ -53,6 +59,20 @@ class SafeErrors implements ExceptionFilter {
|
|||||||
RatesService,
|
RatesService,
|
||||||
MetalsService,
|
MetalsService,
|
||||||
IconsService,
|
IconsService,
|
||||||
|
AuthBusinessService,
|
||||||
|
CalendarBusinessService,
|
||||||
|
SchedulesBusinessService,
|
||||||
|
TransfersBusinessService,
|
||||||
|
PortfolioBusinessService,
|
||||||
|
BackupBusinessService,
|
||||||
|
IconsBusinessService,
|
||||||
|
MetalsBusinessService,
|
||||||
|
SettingsBusinessService,
|
||||||
|
AgentOAuth,
|
||||||
|
AgentCatalogue,
|
||||||
|
AgentOperations,
|
||||||
|
AgentFiles,
|
||||||
|
AgentTransport,
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
@@ -65,6 +85,7 @@ class SafeErrors implements ExceptionFilter {
|
|||||||
MetalsController,
|
MetalsController,
|
||||||
SettingsController,
|
SettingsController,
|
||||||
BackupController,
|
BackupController,
|
||||||
|
AgentManagementController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
class AppModule {}
|
class AppModule {}
|
||||||
@@ -83,6 +104,7 @@ async function bootstrap() {
|
|||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
app.get(AgentTransport).install(app.getHttpAdapter().getInstance());
|
||||||
app.useGlobalFilters(new SafeErrors());
|
app.useGlobalFilters(new SafeErrors());
|
||||||
setupOpenApi(app);
|
setupOpenApi(app);
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { UserRequest } from '../auth';
|
||||||
|
import { PortfolioBusinessService } from '../portfolio';
|
||||||
|
import { TransfersBusinessService } from '../transfers';
|
||||||
|
import { SchedulesBusinessService, scheduleInput } from '../schedules';
|
||||||
|
import { CalendarBusinessService } from '../calendar';
|
||||||
|
import { SettingsBusinessService } from '../rates';
|
||||||
|
import { MetalsBusinessService, metalConfig, metalPriceInput } from '../metals';
|
||||||
|
import { IconsBusinessService } from '../icons';
|
||||||
|
import { BackupBusinessService } from '../backup';
|
||||||
|
import {
|
||||||
|
positionInput,
|
||||||
|
positionMeta,
|
||||||
|
revisionInput,
|
||||||
|
transferInput,
|
||||||
|
settingsInput,
|
||||||
|
date,
|
||||||
|
currencies,
|
||||||
|
} from '../validation';
|
||||||
|
|
||||||
|
const id = z.string().uuid().describe('稳定对象 UUID;名称重名时先搜索,再由用户选择 ID');
|
||||||
|
export const empty = z.object({}).strict();
|
||||||
|
const pageFields = {
|
||||||
|
limit: z.number().int().min(1).max(100).default(50).describe('单页上限 100'),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(1000000)
|
||||||
|
.default(0)
|
||||||
|
.describe('列表偏移;数据变化后从第一页重读'),
|
||||||
|
};
|
||||||
|
const historyFields = {
|
||||||
|
limit: pageFields.limit,
|
||||||
|
cursor: z.string().max(512).optional().describe('上一页 nextCursor'),
|
||||||
|
from: date.optional(),
|
||||||
|
to: date.optional(),
|
||||||
|
};
|
||||||
|
const json = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||||
|
export function page<T>(items: T[], p: { limit: number; offset: number }) {
|
||||||
|
return {
|
||||||
|
items: items.slice(p.offset, p.offset + p.limit),
|
||||||
|
total: items.length,
|
||||||
|
nextOffset: p.offset + p.limit < items.length ? p.offset + p.limit : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type ToolDefinition = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
schema: z.ZodObject<any>;
|
||||||
|
scope: 'read' | 'write' | 'sensitive';
|
||||||
|
destructive?: boolean;
|
||||||
|
web?: 'credentials' | 'reveal' | 'clear';
|
||||||
|
run?: (r: UserRequest, p: any) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class AgentCatalogue {
|
||||||
|
readonly tools: ToolDefinition[];
|
||||||
|
constructor(
|
||||||
|
portfolio: PortfolioBusinessService,
|
||||||
|
transfers: TransfersBusinessService,
|
||||||
|
schedules: SchedulesBusinessService,
|
||||||
|
calendar: CalendarBusinessService,
|
||||||
|
settings: SettingsBusinessService,
|
||||||
|
metals: MetalsBusinessService,
|
||||||
|
icons: IconsBusinessService,
|
||||||
|
backup: BackupBusinessService,
|
||||||
|
) {
|
||||||
|
const read = (
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
schema: z.ZodObject<any>,
|
||||||
|
run: ToolDefinition['run'],
|
||||||
|
): ToolDefinition => ({ name, description, schema, scope: 'read', run });
|
||||||
|
const write = (
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
schema: z.ZodObject<any>,
|
||||||
|
run: ToolDefinition['run'],
|
||||||
|
destructive = false,
|
||||||
|
): ToolDefinition => ({ name, description, schema, scope: 'write', run, destructive });
|
||||||
|
this.tools = [
|
||||||
|
read(
|
||||||
|
'positions_list',
|
||||||
|
'查询账户、独立资产、借入借出债务。原币余额字符串;负债账户正数为欠款、负数为溢缴。支持名称搜索,重名不自动选择。',
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
...pageFields,
|
||||||
|
kind: z.enum(['account', 'asset', 'debt']).optional(),
|
||||||
|
q: z.string().max(100).optional(),
|
||||||
|
archived: z.boolean().optional(),
|
||||||
|
side: z.enum(['asset', 'liability']).optional(),
|
||||||
|
currency: z.enum(currencies).optional(),
|
||||||
|
groupName: z.string().max(60).optional(),
|
||||||
|
order: z.enum(['name', 'id']).default('id'),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
async (r, p) => {
|
||||||
|
const rows = (await portfolio.list(r, p.kind)).filter(
|
||||||
|
(v) =>
|
||||||
|
(!p.q || v.name.includes(p.q)) &&
|
||||||
|
(p.archived === undefined || p.archived === v.archived) &&
|
||||||
|
(!p.side || p.side === v.side) &&
|
||||||
|
(!p.currency || p.currency === v.currency) &&
|
||||||
|
(p.groupName === undefined || p.groupName === v.groupName),
|
||||||
|
);
|
||||||
|
rows.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(p.order === 'name' ? a.name.localeCompare(b.name) : 0) || a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
|
return page(rows, p);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'position_get',
|
||||||
|
'查询项目详情、配置和当前原币余额。隐藏项目需网页解锁。',
|
||||||
|
z.object({ id }).strict(),
|
||||||
|
(r, p) => portfolio.detail(r, p.id),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'position_create',
|
||||||
|
'新增账户、独立资产或债务及初始绝对余额。金额为十进制字符串,业务 date 为 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm(UTC+8)。不新增日常消费功能。',
|
||||||
|
positionInput,
|
||||||
|
(r, p) => portfolio.create(r, p),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'position_update',
|
||||||
|
'修改名称、分类、分组、图标、备注、归档、隐藏、统计开关;币种和资产负债属性固定。须提供完整元数据。',
|
||||||
|
z.object({ id, data: positionMeta }).strict(),
|
||||||
|
(r, p) => portfolio.edit(r, p.id, p.data),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'balance_record',
|
||||||
|
'新增余额/估值/负债调整/单边还款历史:amount 是变更后的绝对余额,不是增量。资产估值使用 valuation;配对转账和还款使用 movement_create。',
|
||||||
|
z.object({ id, data: revisionInput }).strict(),
|
||||||
|
(r, p) => portfolio.revise(r, p.id, p.data),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'history_list',
|
||||||
|
'分页查询全局或单项目历史,业务时间降序;返回真实前序余额。',
|
||||||
|
z.object({ ...historyFields, positionId: id.optional() }).strict(),
|
||||||
|
(r, p) => portfolio.history(r, p),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'history_update',
|
||||||
|
'更正单条非配对历史,自动重放后续金额;配对记录使用 movement_update。',
|
||||||
|
z.object({ id, revisionId: id, data: revisionInput }).strict(),
|
||||||
|
(r, p) => portfolio.correct(r, p.id, p.revisionId, p.data),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'history_delete',
|
||||||
|
'删除历史并重算;配对历史会撤销完整双边操作。',
|
||||||
|
z.object({ id, revisionId: id }).strict(),
|
||||||
|
(r, p) => portfolio.deleteRevision(r, p.id, p.revisionId),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'debt_links_set',
|
||||||
|
'替换债务与自己账户/资产的关联,不参与金额求和。',
|
||||||
|
z.object({ id, targetIds: z.array(id).max(20) }).strict(),
|
||||||
|
(r, p) => portfolio.link(r, p.id, { targetIds: p.targetIds }),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'overview_get',
|
||||||
|
'净资产、资产负债总额、结构及变化归因;遵循逐项目和独立资产纳入设置。明细分页。',
|
||||||
|
z.object(pageFields).strict(),
|
||||||
|
async (r, p) => {
|
||||||
|
const v = await portfolio.overview(r);
|
||||||
|
return { ...v, ...page(v.items, p) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'trend_get',
|
||||||
|
'净资产趋势,缺失汇率不绘制完整总额。业务日 YYYY-MM-DD;支持日/周/月粒度。',
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
from: date.optional(),
|
||||||
|
to: date.optional(),
|
||||||
|
grain: z.enum(['day', 'week', 'month']).optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
(r, p) => portfolio.trend(r, p),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'movements_list',
|
||||||
|
'查询转账、借入、借出、收款、还款配对记录,分页和业务日期筛选。',
|
||||||
|
z.object(historyFields).strict(),
|
||||||
|
(r, p) => transfers.list(r, p),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'movement_by_revision',
|
||||||
|
'根据历史 UUID 找到对应双边记录。',
|
||||||
|
z.object({ revisionId: id }).strict(),
|
||||||
|
(r, p) => transfers.byRevision(r, p.revisionId),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'movement_create',
|
||||||
|
'执行 transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款。amount 为本金,received 为到账/债务本金,fee 可负表示优惠;原币十进制字符串,双边事务和余额检查。',
|
||||||
|
transferInput.safeExtend({ requestId: z.never().optional() }),
|
||||||
|
(r, p) => transfers.create(r, p),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'movement_update',
|
||||||
|
'修改完整双边资金往来并重放后续历史,保持余额一致。',
|
||||||
|
z
|
||||||
|
.object({ id, data: transferInput.safeExtend({ requestId: z.never().optional() }) })
|
||||||
|
.strict(),
|
||||||
|
(r, p) => transfers.edit(r, p.id, p.data),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'movement_delete',
|
||||||
|
'撤销完整双边转账/借贷/还款并重算后续余额。',
|
||||||
|
z.object({ id }).strict(),
|
||||||
|
(r, p) => transfers.remove(r, p.id),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'calendar_month',
|
||||||
|
'月日历现金流统计;YYYY-MM,时区 UTC+8。',
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
month: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}$/)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
(r, p) => calendar.month(r, p.month),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'calendar_day',
|
||||||
|
'查询某业务日的收入支出变化明细,分页。',
|
||||||
|
z.object({ ...pageFields, date: date.optional() }).strict(),
|
||||||
|
async (r, p) => {
|
||||||
|
const v = await calendar.day(r, p.date);
|
||||||
|
return { ...v, ...page(v.items, p) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'schedules_list',
|
||||||
|
'计划列表及启用、完成状态,按到期时间排序。执行记录也可在 history_list 查询。',
|
||||||
|
z.object(pageFields).strict(),
|
||||||
|
async (r, p) => page(await schedules.list(r), p),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'schedule_create',
|
||||||
|
'新增现有支出或转账计划。nextAt 为 YYYY-MM-DDTHH:mm(UTC+8),intervalDays=0 表示一次。',
|
||||||
|
scheduleInput,
|
||||||
|
(r, p) => schedules.create(r, p),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'schedule_update',
|
||||||
|
'修改未完成计划的完整配置。',
|
||||||
|
z.object({ id, data: scheduleInput }).strict(),
|
||||||
|
(r, p) => schedules.edit(r, p.id, p.data),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'schedule_toggle',
|
||||||
|
'启用或停用计划;已完成一次性计划不可重启。',
|
||||||
|
z.object({ id, enabled: z.boolean() }).strict(),
|
||||||
|
(r, p) => schedules.toggle(r, p.id, { enabled: p.enabled }),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'schedule_delete',
|
||||||
|
'删除计划;保留已完成的金额历史。',
|
||||||
|
z.object({ id }).strict(),
|
||||||
|
(r, p) => schedules.remove(r, p.id),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'schedules_run',
|
||||||
|
'以事务执行最多 20 个已到期计划,任一失败回滚本批;可按 hasMore 分批继续。',
|
||||||
|
empty,
|
||||||
|
(r) => schedules.run(r, true),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'settings_get',
|
||||||
|
'个人资料、本位币、显示菜单、备注、隐私、总览卡片、账户分组顺序及汇率状态;不返回密码或令牌。',
|
||||||
|
empty,
|
||||||
|
(r) => settings.settings(r, 'true'),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'settings_update',
|
||||||
|
'修改个人设置、本位币、分组排序、登录时长及纳入统计配置。隐私设置变更需网页确认。',
|
||||||
|
settingsInput,
|
||||||
|
(r, p) => settings.update(r, p, undefined as any),
|
||||||
|
),
|
||||||
|
write('rates_refresh', '重试公共日汇率更新,失败保留原币和历史汇率。', empty, (r) =>
|
||||||
|
settings.refresh(r),
|
||||||
|
),
|
||||||
|
read('metals_prices', '最近 100 条金银每克报价和更新状态。', empty, (r) => metals.list(r)),
|
||||||
|
write('metals_refresh', '刷新贵金属报价并沿用现有自动估值规则。', empty, (r) =>
|
||||||
|
metals.refresh(r),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'metal_price_set',
|
||||||
|
'设置指定日期、币种、品种每克价格;price 十进制字符串,可能触发自动估值历史。',
|
||||||
|
metalPriceInput,
|
||||||
|
(r, p) => metals.manual(r, p),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'metal_configure',
|
||||||
|
'设置金银重量、纯度和自动估值,复用现有估值规则。',
|
||||||
|
z.object({ id, data: metalConfig }).strict(),
|
||||||
|
(r, p) => metals.configure(r, p.id, p.data),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
'metal_value',
|
||||||
|
'按已有报价为指定贵金属资产生成估值历史。',
|
||||||
|
z.object({ id }).strict(),
|
||||||
|
(r, p) => metals.value(r, p.id),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
'icons_list',
|
||||||
|
'搜索私有或共享图标,固定单页 60 个;使用返回 ID 配置项目图标。',
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
q: z.string().max(100).default(''),
|
||||||
|
page: z.number().int().min(1).max(100000).default(1),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
(r, p) => icons.list(r, p.q, String(p.page)),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
name: 'backup_import',
|
||||||
|
description:
|
||||||
|
'提交已上传备份的追加恢复。先 file_upload_request → 上传 → import_preview;强制网页展示影响并确认,事务失败不保留部分账目。',
|
||||||
|
schema: z.object({ token: id }).strict(),
|
||||||
|
scope: 'sensitive',
|
||||||
|
destructive: true,
|
||||||
|
run: (r, p) => backup.restoreUpload(r, p.token),
|
||||||
|
},
|
||||||
|
read(
|
||||||
|
'import_preview',
|
||||||
|
'预检已上传备份并显示追加影响、冲突和条数。',
|
||||||
|
z.object({ token: id }).strict(),
|
||||||
|
async (r, p) => (await backup.inspectUpload(r, p.token)).preview,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
name: 'credentials_change_request',
|
||||||
|
description:
|
||||||
|
'发起账号或密码修改,返回网页入口。当前密码及新密码仅在网页输入,不传给 Agent。完成后 operation_get 查询结果。',
|
||||||
|
schema: empty,
|
||||||
|
scope: 'sensitive',
|
||||||
|
web: 'credentials',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hidden_unlock_request',
|
||||||
|
description:
|
||||||
|
'发起隐藏项目解锁。网页用户验证密码后本连接解锁 5 分钟;operation_get 查询结果。',
|
||||||
|
schema: empty,
|
||||||
|
scope: 'sensitive',
|
||||||
|
web: 'reveal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'data_clear_request',
|
||||||
|
description:
|
||||||
|
'发起清空本账号财务数据。网页须先下载当前备份、验证密码并输入“确定清空”,展示数量;账号保留。',
|
||||||
|
schema: empty,
|
||||||
|
scope: 'sensitive',
|
||||||
|
web: 'clear',
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
get(name: string) {
|
||||||
|
return this.tools.find((t) => t.name === name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnModuleDestroy,
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Request, Response, Express } from 'express';
|
||||||
|
import multer, { diskStorage, memoryStorage } from 'multer';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { unlink } from 'node:fs/promises';
|
||||||
|
import { AgentOAuth, urls } from './oauth';
|
||||||
|
import { UserRequest } from '../auth';
|
||||||
|
import { BackupBusinessService } from '../backup';
|
||||||
|
import { IconsBusinessService } from '../icons';
|
||||||
|
import { MAX_UPLOAD_BYTES } from '../zip';
|
||||||
|
import { Database } from '../database';
|
||||||
|
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||||
|
|
||||||
|
type Ticket = {
|
||||||
|
userId: string;
|
||||||
|
grantId: string;
|
||||||
|
expires: number;
|
||||||
|
kind: 'backup' | 'icon' | 'download' | 'image';
|
||||||
|
snapshot?: string;
|
||||||
|
iconId?: string;
|
||||||
|
buffer?: Buffer;
|
||||||
|
name?: string;
|
||||||
|
preview?: unknown;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class AgentFiles implements OnModuleDestroy {
|
||||||
|
private tickets = new Map<string, Ticket>();
|
||||||
|
private timer = setInterval(() => {
|
||||||
|
for (const [id, t] of this.tickets) if (t.expires < Date.now()) this.tickets.delete(id);
|
||||||
|
}, 60000).unref();
|
||||||
|
constructor(
|
||||||
|
private oauth: AgentOAuth,
|
||||||
|
private backup: BackupBusinessService,
|
||||||
|
private icons: IconsBusinessService,
|
||||||
|
private db: Database,
|
||||||
|
) {}
|
||||||
|
onModuleDestroy() {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.tickets.clear();
|
||||||
|
}
|
||||||
|
async issue(r: UserRequest, grantId: string, kind: Ticket['kind'], iconId?: string) {
|
||||||
|
if (
|
||||||
|
this.tickets.size >= 1000 ||
|
||||||
|
[...this.tickets.values()].filter((t) => t.userId === r.userId).length >= 20
|
||||||
|
)
|
||||||
|
throw new BadRequestException('文件请求过多,请等待过期');
|
||||||
|
const id = randomUUID();
|
||||||
|
this.tickets.set(id, {
|
||||||
|
userId: r.userId,
|
||||||
|
grantId,
|
||||||
|
kind,
|
||||||
|
iconId,
|
||||||
|
expires: Date.now() + 600000,
|
||||||
|
...(kind === 'download' ? { snapshot: await this.backup.snapshot(r.userId) } : {}),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
fileId: id,
|
||||||
|
url: new URL('/api/agent/files/' + id, urls().issuer).toString(),
|
||||||
|
method: kind === 'download' || kind === 'image' ? 'GET' : 'POST',
|
||||||
|
headers: { Authorization: 'Bearer <your access token>' },
|
||||||
|
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||||
|
maxBytes: kind === 'backup' ? MAX_UPLOAD_BYTES : 2 * 1024 * 1024,
|
||||||
|
format:
|
||||||
|
kind === 'backup'
|
||||||
|
? 'multipart/form-data; field file; WorthPath ZIP/JSON'
|
||||||
|
: 'multipart/form-data; field file; image',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private ticket(r: UserRequest, grantId: string, id: string) {
|
||||||
|
const t = this.tickets.get(id);
|
||||||
|
if (!t || t.userId !== r.userId || t.grantId !== grantId || t.expires < Date.now())
|
||||||
|
throw new ForbiddenException('文件入口已失效或不属于此连接');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
async publishIcon(r: UserRequest, grantId: string, id: string, name: string, shared: boolean) {
|
||||||
|
const t = this.ticket(r, grantId, id);
|
||||||
|
if (t.kind !== 'icon' || !t.buffer) throw new BadRequestException('请先上传图标');
|
||||||
|
return this.icons.upload(
|
||||||
|
r,
|
||||||
|
{ name, shared: String(shared), ...(shared ? { confirmed: 'true' } : {}) },
|
||||||
|
{ buffer: t.buffer } as Express.Multer.File,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async inspect(r: UserRequest, grantId: string, id: string) {
|
||||||
|
const t = this.ticket(r, grantId, id);
|
||||||
|
return {
|
||||||
|
fileId: id,
|
||||||
|
uploaded: !!t.buffer || !!t.token,
|
||||||
|
token: t.token,
|
||||||
|
preview: t.preview,
|
||||||
|
expiresAt: new Date(t.expires).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async context(req: Request) {
|
||||||
|
const match = /^Bearer ([A-Za-z0-9_-]+)$/.exec(req.headers.authorization || '');
|
||||||
|
if (!match) throw new UnauthorizedException('Bearer token required');
|
||||||
|
const auth = await this.oauth.verifyAccessToken(match[1]),
|
||||||
|
grant = await this.oauth.grant(String(auth.extra.grantId));
|
||||||
|
return {
|
||||||
|
grant,
|
||||||
|
r: {
|
||||||
|
...req,
|
||||||
|
userId: grant.userId,
|
||||||
|
sessionId: grant.sessionId,
|
||||||
|
revealed: false,
|
||||||
|
agent: true,
|
||||||
|
} as UserRequest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
install(app: Express) {
|
||||||
|
const disk = multer({
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: tmpdir(),
|
||||||
|
filename: (_r, _f, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
|
||||||
|
}),
|
||||||
|
limits: { fileSize: MAX_UPLOAD_BYTES, files: 1, fields: 0 },
|
||||||
|
}).single('file');
|
||||||
|
const memory = multer({
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 },
|
||||||
|
}).single('file');
|
||||||
|
app.all('/api/agent/files/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { grant, r } = await this.context(req);
|
||||||
|
const t = this.ticket(r, grant.id, String(req.params.id));
|
||||||
|
if (!['GET', 'POST'].includes(req.method)) {
|
||||||
|
res.status(405).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((req.method === 'POST') !== (t.kind === 'backup' || t.kind === 'icon')) {
|
||||||
|
res.status(405).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
if (t.kind === 'download') await this.backup.download(r, res, t.snapshot);
|
||||||
|
else await this.icons.image(r, t.iconId!, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = grant.scopes as string[];
|
||||||
|
if (!selected.includes('draft') && !selected.includes('write'))
|
||||||
|
throw new ForbiddenException('上传需要 draft 或 write 权限');
|
||||||
|
if (
|
||||||
|
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ===
|
||||||
|
'readonly'
|
||||||
|
)
|
||||||
|
throw new ForbiddenException('当前策略为只读');
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
(t.kind === 'backup' ? disk : memory)(req, res, (e) => (e ? reject(e) : resolve())),
|
||||||
|
);
|
||||||
|
if (!req.file) throw new BadRequestException('请选择文件');
|
||||||
|
try {
|
||||||
|
if (t.kind === 'backup') {
|
||||||
|
const v = await this.backup.upload(r, req.file);
|
||||||
|
t.token = v.token;
|
||||||
|
t.preview = v;
|
||||||
|
} else t.buffer = req.file.buffer;
|
||||||
|
res.json(await this.inspect(r, grant.id, String(req.params.id)));
|
||||||
|
} catch (e) {
|
||||||
|
if (req.file.path) await unlink(req.file.path).catch(() => {});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!res.headersSent)
|
||||||
|
res
|
||||||
|
.status(
|
||||||
|
e instanceof HttpException
|
||||||
|
? e.getStatus()
|
||||||
|
: e instanceof InvalidTokenError
|
||||||
|
? 401
|
||||||
|
: 400,
|
||||||
|
)
|
||||||
|
.json({ message: e instanceof HttpException ? e.message : '文件操作失败或认证已失效' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Req,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Res,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { compare } from 'bcryptjs';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Database } from '../database';
|
||||||
|
import { AuthService, UserRequest } from '../auth';
|
||||||
|
import { AgentOAuth, urls, scopeInput } from './oauth';
|
||||||
|
import { AgentOperations } from './operations';
|
||||||
|
@Controller('api/agent')
|
||||||
|
export class AgentManagementController {
|
||||||
|
constructor(
|
||||||
|
private db: Database,
|
||||||
|
private oauth: AgentOAuth,
|
||||||
|
private operations: AgentOperations,
|
||||||
|
private auth: AuthService,
|
||||||
|
) {}
|
||||||
|
@Get() async list(@Req() r: UserRequest) {
|
||||||
|
const grants = await this.db.agentGrant.findMany({
|
||||||
|
where: { userId: r.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
scopes: true,
|
||||||
|
expiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
revokedAt: true,
|
||||||
|
clientId: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
const operations = await this.db.agentOperation.findMany({
|
||||||
|
where: { userId: r.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
tool: true,
|
||||||
|
status: true,
|
||||||
|
expiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
const calls = await this.db.agentCall.findMany({
|
||||||
|
where: { userId: r.userId },
|
||||||
|
select: { id: true, tool: true, status: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
mcpUrl: urls().resource.toString(),
|
||||||
|
mode:
|
||||||
|
(await this.db.agentPolicy.findUnique({ where: { userId: r.userId } }))?.mode || 'draft',
|
||||||
|
grants,
|
||||||
|
operations,
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Put('policy') async policy(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
|
const { mode, password } = z
|
||||||
|
.object({ mode: z.enum(['readonly', 'draft', 'direct']), password: z.string().max(72) })
|
||||||
|
.strict()
|
||||||
|
.parse(raw);
|
||||||
|
this.auth.limit(r);
|
||||||
|
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||||
|
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||||
|
await this.db.agentPolicy.upsert({
|
||||||
|
where: { userId: r.userId },
|
||||||
|
create: { userId: r.userId, mode },
|
||||||
|
update: { mode },
|
||||||
|
});
|
||||||
|
return { mode };
|
||||||
|
}
|
||||||
|
@Post('tokens') async token(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
|
const p = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
scopes: scopeInput,
|
||||||
|
days: z.number().int().min(1).max(90),
|
||||||
|
password: z.string().max(72),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.parse(raw);
|
||||||
|
this.auth.limit(r);
|
||||||
|
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||||
|
if (!(await compare(p.password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||||
|
return this.db.atomic(async () => {
|
||||||
|
const v = await this.oauth.issue(r.userId, p.name, p.scopes, p.days);
|
||||||
|
return { id: v.grant.id, token: v.tokens.access_token, expiresAt: v.grant.expiresAt };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@Delete('connections/:id') async revoke(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
|
z.string().uuid().parse(id);
|
||||||
|
await this.db.agentGrant.updateMany({
|
||||||
|
where: { id, userId: r.userId },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
@Get('authorizations/:id') pending(@Param('id') id: string) {
|
||||||
|
return this.oauth.pending(z.string().uuid().parse(id));
|
||||||
|
}
|
||||||
|
@Post('authorizations/:id') async consent(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() raw: unknown,
|
||||||
|
) {
|
||||||
|
const { approve } = z.object({ approve: z.boolean() }).strict().parse(raw);
|
||||||
|
return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve);
|
||||||
|
}
|
||||||
|
@Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
|
return this.operations.preview(r.userId, z.string().uuid().parse(id));
|
||||||
|
}
|
||||||
|
@Post('operations/:id') async confirm(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() raw: unknown,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
this.auth.limit(r);
|
||||||
|
z.string().uuid().parse(id);
|
||||||
|
try {
|
||||||
|
return await this.operations.confirm(r, id, raw, res);
|
||||||
|
} catch (e) {
|
||||||
|
const row = await this.db.agentOperation.findFirst({
|
||||||
|
where: { id, userId: r.userId, status: 'pending' },
|
||||||
|
});
|
||||||
|
if (row) {
|
||||||
|
await this.db.agentOperation.updateMany({
|
||||||
|
where: { id, userId: r.userId, status: 'pending' },
|
||||||
|
data: {
|
||||||
|
result: {
|
||||||
|
status: 'submission_failed',
|
||||||
|
message:
|
||||||
|
e instanceof HttpException ? e.message : '提交失败,账目已回滚;可重试或取消',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.db.agentCall.create({
|
||||||
|
data: { userId: r.userId, grantId: row.grantId, tool: row.tool, status: 'error' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Database } from '../database';
|
||||||
|
import {
|
||||||
|
OAuthServerProvider,
|
||||||
|
AuthorizationParams,
|
||||||
|
} from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||||
|
import {
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthTokens,
|
||||||
|
OAuthTokenRevocationRequest,
|
||||||
|
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
|
import {
|
||||||
|
InvalidClientMetadataError,
|
||||||
|
InvalidGrantError,
|
||||||
|
InvalidScopeError,
|
||||||
|
InvalidTokenError,
|
||||||
|
InvalidTargetError,
|
||||||
|
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||||
|
|
||||||
|
export const scopes = ['read', 'draft', 'write', 'sensitive'] as const;
|
||||||
|
export const scopeInput = z
|
||||||
|
.array(z.enum(scopes))
|
||||||
|
.min(1)
|
||||||
|
.max(4)
|
||||||
|
.refine((v) => v.includes('read') && new Set(v).size === v.length);
|
||||||
|
export const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||||
|
const secret = () => randomBytes(32).toString('base64url');
|
||||||
|
export function urls() {
|
||||||
|
const resource = new URL(process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp');
|
||||||
|
if (
|
||||||
|
resource.pathname !== '/mcp' ||
|
||||||
|
resource.search ||
|
||||||
|
resource.hash ||
|
||||||
|
resource.username ||
|
||||||
|
resource.password
|
||||||
|
)
|
||||||
|
throw Error('MCP_PUBLIC_URL must be the canonical /mcp URL');
|
||||||
|
if (
|
||||||
|
resource.protocol !== 'https:' &&
|
||||||
|
!(
|
||||||
|
process.env.NODE_ENV !== 'production' &&
|
||||||
|
['localhost', '127.0.0.1', '[::1]'].includes(resource.hostname)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw Error('MCP requires HTTPS except local development');
|
||||||
|
const web = new URL(process.env.MCP_WEB_URL || 'http://localhost:5173');
|
||||||
|
if (
|
||||||
|
web.protocol !== 'https:' &&
|
||||||
|
!(process.env.NODE_ENV !== 'production' && ['localhost', '127.0.0.1'].includes(web.hostname))
|
||||||
|
)
|
||||||
|
throw Error('MCP web confirmation requires HTTPS');
|
||||||
|
return { resource, issuer: new URL(resource.origin), web };
|
||||||
|
}
|
||||||
|
export function webLink(key: string, id: string) {
|
||||||
|
const u = new URL(urls().web);
|
||||||
|
u.searchParams.set(key, id);
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentOAuth implements OAuthServerProvider {
|
||||||
|
constructor(private db: Database) {}
|
||||||
|
get clientsStore() {
|
||||||
|
return {
|
||||||
|
getClient: async (id: string) => {
|
||||||
|
const row = await this.db.agentClient.findUnique({ where: { id } });
|
||||||
|
return row?.metadata as OAuthClientInformationFull | undefined;
|
||||||
|
},
|
||||||
|
registerClient: async (
|
||||||
|
input: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||||
|
) => {
|
||||||
|
if (input.token_endpoint_auth_method !== 'none')
|
||||||
|
throw new InvalidClientMetadataError('Only public PKCE clients are supported');
|
||||||
|
if (!input.redirect_uris.length || input.redirect_uris.length > 10)
|
||||||
|
throw new InvalidClientMetadataError('Invalid redirect URIs');
|
||||||
|
for (const value of input.redirect_uris) {
|
||||||
|
const u = new URL(value);
|
||||||
|
if (
|
||||||
|
u.hash ||
|
||||||
|
u.username ||
|
||||||
|
u.password ||
|
||||||
|
!(
|
||||||
|
u.protocol === 'https:' ||
|
||||||
|
(u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new InvalidClientMetadataError('HTTPS or loopback redirect required');
|
||||||
|
}
|
||||||
|
if ((await this.db.agentClient.count()) >= 10000)
|
||||||
|
throw new InvalidClientMetadataError('Client registration limit reached');
|
||||||
|
const client = {
|
||||||
|
...input,
|
||||||
|
client_name: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.parse(input.client_name || 'MCP client'),
|
||||||
|
client_id: randomUUID(),
|
||||||
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
await this.db.agentClient.create({
|
||||||
|
data: { id: client.client_id, metadata: JSON.parse(JSON.stringify(client)) },
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private resource(resource?: URL) {
|
||||||
|
if (resource?.toString() !== urls().resource.toString())
|
||||||
|
throw new InvalidTargetError('WorthPath resource is required');
|
||||||
|
}
|
||||||
|
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response) {
|
||||||
|
this.resource(params.resource);
|
||||||
|
const selected = params.scopes?.length ? params.scopes : ['read'];
|
||||||
|
if (!scopeInput.safeParse(selected).success) throw new InvalidScopeError('Unsupported scope');
|
||||||
|
const row = await this.db.agentAuthorization.create({
|
||||||
|
data: {
|
||||||
|
clientId: client.client_id,
|
||||||
|
parameters: JSON.parse(
|
||||||
|
JSON.stringify({ ...params, resource: params.resource!.toString(), scopes: selected }),
|
||||||
|
),
|
||||||
|
expiresAt: new Date(Date.now() + 600000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.redirect(webLink('agent_authorization', row.id));
|
||||||
|
}
|
||||||
|
async pending(id: string) {
|
||||||
|
const row = await this.db.agentAuthorization.findUnique({ where: { id } });
|
||||||
|
if (!row || row.status !== 'pending' || row.expiresAt <= new Date())
|
||||||
|
throw new BadRequestException('授权请求已失效');
|
||||||
|
const client = await this.clientsStore.getClient(row.clientId);
|
||||||
|
const p = row.parameters as any;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: client?.client_name,
|
||||||
|
redirectUri: p.redirectUri,
|
||||||
|
scopes: p.scopes,
|
||||||
|
resource: p.resource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async consent(userId: string, id: string, approved: boolean) {
|
||||||
|
return this.db.atomic(async () => {
|
||||||
|
await this.pending(id);
|
||||||
|
const row = await this.db.agentAuthorization.findUniqueOrThrow({ where: { id } });
|
||||||
|
const code = secret();
|
||||||
|
const changed = await this.db.agentAuthorization.updateMany({
|
||||||
|
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
status: approved ? 'approved' : 'denied',
|
||||||
|
codeDigest: approved ? digest(code) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!changed.count) throw new BadRequestException('授权请求已处理');
|
||||||
|
const p = row.parameters as any,
|
||||||
|
callback = new URL(p.redirectUri);
|
||||||
|
callback.searchParams.set(approved ? 'code' : 'error', approved ? code : 'access_denied');
|
||||||
|
if (p.state) callback.searchParams.set('state', p.state);
|
||||||
|
return { redirect: callback.toString() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async challengeForAuthorizationCode(client: OAuthClientInformationFull, code: string) {
|
||||||
|
const row = await this.db.agentAuthorization.findUnique({
|
||||||
|
where: { codeDigest: digest(code) },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
row.clientId !== client.client_id ||
|
||||||
|
row.status !== 'approved' ||
|
||||||
|
row.expiresAt <= new Date()
|
||||||
|
)
|
||||||
|
throw new InvalidGrantError('Invalid authorization code');
|
||||||
|
return (row.parameters as any).codeChallenge as string;
|
||||||
|
}
|
||||||
|
async issue(userId: string, name: string, selected: string[], days: number, clientId?: string) {
|
||||||
|
const access = secret(),
|
||||||
|
refresh = clientId ? secret() : undefined,
|
||||||
|
sessionId = digest(secret());
|
||||||
|
const expiresAt = new Date(Date.now() + days * 86400000),
|
||||||
|
refreshExpiresAt = clientId ? new Date(Date.now() + 30 * 86400000) : null;
|
||||||
|
await this.db.session.create({
|
||||||
|
data: { id: sessionId, userId, expiresAt: refreshExpiresAt || expiresAt },
|
||||||
|
});
|
||||||
|
const grant = await this.db.agentGrant.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
clientId,
|
||||||
|
scopes: selected,
|
||||||
|
resource: urls().resource.toString(),
|
||||||
|
accessDigest: digest(access),
|
||||||
|
refreshDigest: refresh ? digest(refresh) : null,
|
||||||
|
expiresAt,
|
||||||
|
refreshExpiresAt,
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
grant,
|
||||||
|
tokens: {
|
||||||
|
access_token: access,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: Math.floor(days * 86400),
|
||||||
|
scope: selected.join(' '),
|
||||||
|
...(refresh ? { refresh_token: refresh } : {}),
|
||||||
|
} as OAuthTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async exchangeAuthorizationCode(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
code: string,
|
||||||
|
verifier?: string,
|
||||||
|
redirectUri?: string,
|
||||||
|
resource?: URL,
|
||||||
|
) {
|
||||||
|
this.resource(resource);
|
||||||
|
return this.db.atomic(async () => {
|
||||||
|
await this.challengeForAuthorizationCode(client, code);
|
||||||
|
const row = await this.db.agentAuthorization.findUniqueOrThrow({
|
||||||
|
where: { codeDigest: digest(code) },
|
||||||
|
}),
|
||||||
|
p = row.parameters as any;
|
||||||
|
// SDK tokenHandler validates S256 PKCE before invoking this provider, and passes
|
||||||
|
// undefined for verifier after successful local validation (skipLocalPkceValidation=false).
|
||||||
|
if (
|
||||||
|
redirectUri !== p.redirectUri ||
|
||||||
|
(verifier && createHash('sha256').update(verifier).digest('base64url') !== p.codeChallenge)
|
||||||
|
)
|
||||||
|
throw new InvalidGrantError('PKCE or redirect mismatch');
|
||||||
|
const changed = await this.db.agentAuthorization.updateMany({
|
||||||
|
where: { id: row.id, status: 'approved' },
|
||||||
|
data: { status: 'used', codeDigest: null },
|
||||||
|
});
|
||||||
|
if (!changed.count) throw new InvalidGrantError('Code already used');
|
||||||
|
return (
|
||||||
|
await this.issue(
|
||||||
|
row.userId!,
|
||||||
|
client.client_name || 'MCP client',
|
||||||
|
p.scopes,
|
||||||
|
1 / 24,
|
||||||
|
client.client_id,
|
||||||
|
)
|
||||||
|
).tokens;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async exchangeRefreshToken(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
token: string,
|
||||||
|
selected?: string[],
|
||||||
|
resource?: URL,
|
||||||
|
) {
|
||||||
|
this.resource(resource);
|
||||||
|
return this.db.atomic(async () => {
|
||||||
|
const row = await this.db.agentGrant.findUnique({ where: { refreshDigest: digest(token) } });
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
row.clientId !== client.client_id ||
|
||||||
|
row.revokedAt ||
|
||||||
|
!row.refreshExpiresAt ||
|
||||||
|
row.refreshExpiresAt <= new Date()
|
||||||
|
)
|
||||||
|
throw new InvalidGrantError('Invalid refresh token');
|
||||||
|
const current = row.scopes as string[];
|
||||||
|
if (
|
||||||
|
selected &&
|
||||||
|
(!scopeInput.safeParse(selected).success || selected.some((s) => !current.includes(s)))
|
||||||
|
)
|
||||||
|
throw new InvalidScopeError('Scope escalation rejected');
|
||||||
|
const access = secret(),
|
||||||
|
refresh = secret();
|
||||||
|
const changed = await this.db.agentGrant.updateMany({
|
||||||
|
where: { id: row.id, refreshDigest: digest(token), revokedAt: null },
|
||||||
|
data: {
|
||||||
|
accessDigest: digest(access),
|
||||||
|
refreshDigest: digest(refresh),
|
||||||
|
expiresAt: new Date(Date.now() + 3600000),
|
||||||
|
scopes: selected || current,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!changed.count) throw new InvalidGrantError('Refresh token already used');
|
||||||
|
return {
|
||||||
|
access_token: access,
|
||||||
|
refresh_token: refresh,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: (selected || current).join(' '),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async verifyAccessToken(token: string) {
|
||||||
|
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new InvalidTokenError('Invalid token');
|
||||||
|
const row = await this.db.agentGrant.findUnique({ where: { accessDigest: digest(token) } });
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
row.revokedAt ||
|
||||||
|
row.expiresAt <= new Date() ||
|
||||||
|
row.resource !== urls().resource.toString()
|
||||||
|
)
|
||||||
|
throw new InvalidTokenError('Expired, revoked or invalid resource token');
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
clientId: row.clientId || row.id,
|
||||||
|
scopes: row.scopes as string[],
|
||||||
|
expiresAt: Math.floor(+row.expiresAt / 1000),
|
||||||
|
resource: new URL(row.resource),
|
||||||
|
extra: { grantId: row.id, userId: row.userId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) {
|
||||||
|
await this.db.agentGrant.updateMany({
|
||||||
|
where: {
|
||||||
|
clientId: client.client_id,
|
||||||
|
OR: [{ accessDigest: digest(request.token) }, { refreshDigest: digest(request.token) }],
|
||||||
|
},
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async grant(id: string, userId?: string) {
|
||||||
|
const row = await this.db.agentGrant.findFirst({
|
||||||
|
where: { id, ...(userId ? { userId } : {}), revokedAt: null, expiresAt: { gt: new Date() } },
|
||||||
|
});
|
||||||
|
if (!row) throw new ForbiddenException('Agent 连接已过期或撤销');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma, AgentGrant } from '@prisma/client';
|
||||||
|
import { compare } from 'bcryptjs';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Database } from '../database';
|
||||||
|
import { AuthBusinessService, UserRequest } from '../auth';
|
||||||
|
import { BackupBusinessService } from '../backup';
|
||||||
|
import { AgentOAuth, digest, webLink } from './oauth';
|
||||||
|
import { AgentCatalogue, ToolDefinition, empty } from './catalogue';
|
||||||
|
import { AgentFiles } from './files';
|
||||||
|
export const writing = {
|
||||||
|
idempotencyKey: z
|
||||||
|
.string()
|
||||||
|
.min(8)
|
||||||
|
.max(128)
|
||||||
|
.regex(/^[A-Za-z0-9_.:-]+$/)
|
||||||
|
.describe('同用户唯一幂等键;重试使用相同键和全部参数,改动参数必须换键'),
|
||||||
|
expectedState: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-f0-9]{64}$/)
|
||||||
|
.describe('先 state_get 获取 state,避免覆盖并发修改;状态变化后重新读取并使用新幂等键'),
|
||||||
|
};
|
||||||
|
export const plain = (v: unknown) => JSON.parse(JSON.stringify(v));
|
||||||
|
function stable(v: any): string {
|
||||||
|
return JSON.stringify(v, (_k, x) =>
|
||||||
|
x && typeof x === 'object' && !Array.isArray(x)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.keys(x)
|
||||||
|
.sort()
|
||||||
|
.map((k) => [k, x[k]]),
|
||||||
|
)
|
||||||
|
: x,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class AgentOperations {
|
||||||
|
readonly tools: ToolDefinition[];
|
||||||
|
constructor(
|
||||||
|
private db: Database,
|
||||||
|
private oauth: AgentOAuth,
|
||||||
|
catalogue: AgentCatalogue,
|
||||||
|
private auth: AuthBusinessService,
|
||||||
|
private backup: BackupBusinessService,
|
||||||
|
private files: AgentFiles,
|
||||||
|
) {
|
||||||
|
this.tools = [
|
||||||
|
...catalogue.tools,
|
||||||
|
{
|
||||||
|
name: 'backup_export',
|
||||||
|
description:
|
||||||
|
'创建短期受 Bearer 保护的完整 ZIP 备份下载入口,包含隐藏项目;网页验证密码并确认后 operation_get 获取入口,URL 本身不是凭证。',
|
||||||
|
schema: empty,
|
||||||
|
scope: 'sensitive',
|
||||||
|
run: async (r) => this.files.issue(r, r.agentGrantId!, 'download'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'icon_publish',
|
||||||
|
description:
|
||||||
|
'保存已上传图标,shared=true 发布到共享库,须中文名称及网页确认。先 file_upload_request(kind=icon)。',
|
||||||
|
schema: z
|
||||||
|
.object({
|
||||||
|
fileId: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
shared: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
scope: 'write',
|
||||||
|
run: (r, p) => this.files.publishIcon(r, r.agentGrantId!, p.fileId, p.name, p.shared),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hidden_lock',
|
||||||
|
description: '立即锁定本连接的隐藏项目授权。',
|
||||||
|
schema: empty,
|
||||||
|
scope: 'write',
|
||||||
|
run: async (r) => this.auth.lock(r),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
get(name: string) {
|
||||||
|
const t = this.tools.find((t) => t.name === name);
|
||||||
|
if (!t) throw new BadRequestException('未知工具');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
async context(grant: AgentGrant) {
|
||||||
|
const s = await this.db.session.findUnique({ where: { id: grant.sessionId } });
|
||||||
|
if (!s || s.expiresAt <= new Date()) throw new ForbiddenException('连接会话失效,请重新授权');
|
||||||
|
return {
|
||||||
|
userId: grant.userId,
|
||||||
|
sessionId: grant.sessionId,
|
||||||
|
revealed: !!s.revealUntil && +s.revealUntil > Date.now(),
|
||||||
|
agent: true,
|
||||||
|
agentGrantId: grant.id,
|
||||||
|
cookies: {},
|
||||||
|
} as UserRequest;
|
||||||
|
}
|
||||||
|
async state(userId: string) {
|
||||||
|
const data = await Promise.all([
|
||||||
|
this.db.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
baseCurrency: true,
|
||||||
|
hiddenMenus: true,
|
||||||
|
showNotes: true,
|
||||||
|
idleMinutes: true,
|
||||||
|
accountGroupOrder: true,
|
||||||
|
sessionHours: true,
|
||||||
|
requireHiddenPassword: true,
|
||||||
|
overviewCards: true,
|
||||||
|
includeIndependentAssets: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.db.position.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
include: { revisions: { orderBy: { id: 'asc' } }, outgoing: { orderBy: { id: 'asc' } } },
|
||||||
|
}),
|
||||||
|
this.db.transfer.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
|
||||||
|
this.db.schedule.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
|
||||||
|
this.db.exchangeRate.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
|
||||||
|
this.db.metalPrice.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
|
||||||
|
this.db.icon.findMany({
|
||||||
|
where: { ownerId: userId },
|
||||||
|
select: { id: true, name: true, hash: true, shared: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return digest(stable(plain(data)));
|
||||||
|
}
|
||||||
|
private sensitive(t: ToolDefinition, p: any) {
|
||||||
|
return (
|
||||||
|
t.scope === 'sensitive' ||
|
||||||
|
(t.name === 'settings_update' && p.requireHiddenPassword !== undefined) ||
|
||||||
|
(t.name === 'icon_publish' && p.shared)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private async permission(grant: AgentGrant, t: ToolDefinition, p: any) {
|
||||||
|
const selected = grant.scopes as string[],
|
||||||
|
mode =
|
||||||
|
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ||
|
||||||
|
'draft';
|
||||||
|
if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限');
|
||||||
|
if (t.scope === 'read') return { mode, sensitive: false };
|
||||||
|
const sensitive = this.sensitive(t, p);
|
||||||
|
if (sensitive && !selected.includes('sensitive'))
|
||||||
|
throw new ForbiddenException('此操作需要 sensitive 权限');
|
||||||
|
if (!sensitive && !selected.includes('write') && !selected.includes('draft'))
|
||||||
|
throw new ForbiddenException('缺少 draft 或 write 权限');
|
||||||
|
if (mode === 'readonly' && !['backup_export', 'hidden_unlock_request'].includes(t.name))
|
||||||
|
throw new ForbiddenException('当前用户写入策略为只读');
|
||||||
|
return { mode, sensitive };
|
||||||
|
}
|
||||||
|
async call(grantId: string, name: string, input: any) {
|
||||||
|
const t = this.get(name);
|
||||||
|
const parsed = (t.scope === 'read' ? t.schema : t.schema.safeExtend(writing)).parse(input);
|
||||||
|
const { idempotencyKey, expectedState, ...p } = parsed as any;
|
||||||
|
const grant = await this.oauth.grant(grantId),
|
||||||
|
permission = await this.permission(grant, t, p);
|
||||||
|
if (t.scope === 'read') return t.run!(await this.context(grant), p);
|
||||||
|
const hash = digest(stable({ tool: name, parameters: p, expectedState }));
|
||||||
|
return this.db.atomic(async () => {
|
||||||
|
await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${grant.userId} FOR UPDATE`);
|
||||||
|
const fresh = await this.oauth.grant(grantId),
|
||||||
|
access = await this.permission(fresh, t, p);
|
||||||
|
const existing = await this.db.agentOperation.findUnique({
|
||||||
|
where: { userId_key: { userId: grant.userId, key: idempotencyKey } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (existing.hash !== hash || existing.grantId !== grant.id)
|
||||||
|
throw new ConflictException('幂等键已用于不同参数或连接');
|
||||||
|
return this.view(existing);
|
||||||
|
}
|
||||||
|
const snapshot = await this.state(grant.userId);
|
||||||
|
if (snapshot !== expectedState)
|
||||||
|
throw new ConflictException('账目已变化,请重新读取 state_get 和数据后使用新幂等键');
|
||||||
|
const row = await this.db.agentOperation.create({
|
||||||
|
data: {
|
||||||
|
userId: grant.userId,
|
||||||
|
grantId,
|
||||||
|
key: idempotencyKey,
|
||||||
|
hash,
|
||||||
|
tool: name,
|
||||||
|
parameters: plain(p),
|
||||||
|
snapshot,
|
||||||
|
expiresAt: new Date(Date.now() + 600000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
access.sensitive ||
|
||||||
|
access.mode === 'draft' ||
|
||||||
|
!(fresh.scopes as string[]).includes('write')
|
||||||
|
)
|
||||||
|
return this.view(row);
|
||||||
|
const result = await this.execute(t, fresh, p);
|
||||||
|
return this.view(
|
||||||
|
await this.db.agentOperation.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { status: 'completed', result: plain(result), completedAt: new Date() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private async execute(t: ToolDefinition, grant: AgentGrant, p: any) {
|
||||||
|
return t.run!(await this.context(grant), p);
|
||||||
|
}
|
||||||
|
private view(row: any) {
|
||||||
|
return {
|
||||||
|
operationId: row.id,
|
||||||
|
tool: row.tool,
|
||||||
|
status: row.status === 'pending' && row.expiresAt < new Date() ? 'expired' : row.status,
|
||||||
|
expiresAt: row.expiresAt,
|
||||||
|
result: row.result,
|
||||||
|
confirmationUrl: row.status === 'pending' ? webLink('agent_operation', row.id) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async status(grantId: string, id: string) {
|
||||||
|
const grant = await this.oauth.grant(grantId),
|
||||||
|
row = await this.db.agentOperation.findFirst({
|
||||||
|
where: { id, userId: grant.userId, grantId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('操作不存在');
|
||||||
|
return this.view(row);
|
||||||
|
}
|
||||||
|
async preview(userId: string, id: string) {
|
||||||
|
const row = await this.db.agentOperation.findFirst({ where: { id, userId } });
|
||||||
|
if (!row) throw new NotFoundException('操作不存在');
|
||||||
|
const t = this.get(row.tool),
|
||||||
|
grant = await this.oauth.grant(row.grantId, userId);
|
||||||
|
let impact: unknown = { parameters: row.parameters, message: t.description };
|
||||||
|
if (t.name === 'backup_import')
|
||||||
|
impact = (
|
||||||
|
await this.backup.inspectUpload(await this.context(grant), (row.parameters as any).token)
|
||||||
|
).preview;
|
||||||
|
if (t.web === 'clear')
|
||||||
|
impact = {
|
||||||
|
positions: await this.db.position.count({ where: { userId } }),
|
||||||
|
history: await this.db.revision.count({ where: { position: { userId } } }),
|
||||||
|
schedules: await this.db.schedule.count({ where: { userId } }),
|
||||||
|
message: '清空账户、资产、债务、历史、私有图标和汇率;保留账号和个人设置。先下载当前备份。',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...this.view(row),
|
||||||
|
impact,
|
||||||
|
web: t.web,
|
||||||
|
sensitive: this.sensitive(t, row.parameters),
|
||||||
|
description: t.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async confirm(r: UserRequest, id: string, raw: unknown, res: Response) {
|
||||||
|
const input = z
|
||||||
|
.object({
|
||||||
|
approve: z.boolean(),
|
||||||
|
password: z.string().max(72).optional(),
|
||||||
|
username: z.string().max(64).optional(),
|
||||||
|
newPassword: z.string().max(72).optional(),
|
||||||
|
confirmation: z.string().max(20).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.parse(raw);
|
||||||
|
return this.db.atomic(async () => {
|
||||||
|
await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
|
||||||
|
const row = await this.db.agentOperation.findFirst({ where: { id, userId: r.userId } });
|
||||||
|
if (!row) throw new NotFoundException('操作不存在');
|
||||||
|
if (row.status !== 'pending' || row.expiresAt <= new Date())
|
||||||
|
throw new ConflictException('操作已完成或失效');
|
||||||
|
if (!input.approve)
|
||||||
|
return this.view(
|
||||||
|
await this.db.agentOperation.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'cancelled', completedAt: new Date() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const grant = await this.oauth.grant(row.grantId, r.userId),
|
||||||
|
t = this.get(row.tool),
|
||||||
|
p = row.parameters as any;
|
||||||
|
const access = await this.permission(grant, t, p);
|
||||||
|
if (access.sensitive) {
|
||||||
|
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||||
|
if (!input.password || !(await compare(input.password, u.passwordHash)))
|
||||||
|
throw new ForbiddenException('请验证当前密码');
|
||||||
|
}
|
||||||
|
if ((await this.state(r.userId)) !== row.snapshot)
|
||||||
|
throw new ConflictException('账目已变化,请取消并重新创建操作');
|
||||||
|
let result: unknown;
|
||||||
|
if (t.web === 'credentials') {
|
||||||
|
result = await this.auth.changeCredentials(
|
||||||
|
r,
|
||||||
|
{
|
||||||
|
currentPassword: input.password,
|
||||||
|
username: input.username,
|
||||||
|
newPassword: input.newPassword,
|
||||||
|
},
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
await this.db.agentGrant.updateMany({
|
||||||
|
where: { userId: r.userId, id: { not: grant.id } },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
await this.db.session.create({
|
||||||
|
data: { id: grant.sessionId, userId: r.userId, expiresAt: new Date(Date.now() + 300000) },
|
||||||
|
});
|
||||||
|
await this.db.agentGrant.update({
|
||||||
|
where: { id: grant.id },
|
||||||
|
data: { scopes: ['read'], expiresAt: new Date(Date.now() + 300000), refreshDigest: null },
|
||||||
|
});
|
||||||
|
} else if (t.web === 'reveal')
|
||||||
|
result = await this.auth.reveal(
|
||||||
|
Object.assign(Object.create(r), { sessionId: grant.sessionId }),
|
||||||
|
{ password: input.password },
|
||||||
|
);
|
||||||
|
else if (t.web === 'clear')
|
||||||
|
result = await this.backup.clear(r, { confirmation: input.confirmation });
|
||||||
|
else result = await this.execute(t, grant, p);
|
||||||
|
return this.view(
|
||||||
|
await this.db.agentOperation.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'completed', result: plain(result), completedAt: new Date() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, 300000);
|
||||||
|
}
|
||||||
|
async uploadRequest(grantId: string, kind: 'backup' | 'icon') {
|
||||||
|
const grant = await this.oauth.grant(grantId);
|
||||||
|
const selected = grant.scopes as string[];
|
||||||
|
if (!selected.includes('draft') && !selected.includes('write'))
|
||||||
|
throw new ForbiddenException('上传需要 draft 或 write 权限');
|
||||||
|
if (
|
||||||
|
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ===
|
||||||
|
'readonly'
|
||||||
|
)
|
||||||
|
throw new ForbiddenException('当前策略为只读');
|
||||||
|
return this.files.issue(await this.context(grant), grantId, kind);
|
||||||
|
}
|
||||||
|
async fileStatus(grantId: string, id: string) {
|
||||||
|
const g = await this.oauth.grant(grantId);
|
||||||
|
return this.files.inspect(await this.context(g), grantId, id);
|
||||||
|
}
|
||||||
|
async iconImage(grantId: string, id: string) {
|
||||||
|
const g = await this.oauth.grant(grantId);
|
||||||
|
return this.files.issue(await this.context(g), grantId, 'image', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Express } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import {
|
||||||
|
mcpAuthRouter,
|
||||||
|
getOAuthProtectedResourceMetadataUrl,
|
||||||
|
} from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||||
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
||||||
|
import { AgentOAuth, urls, scopes } from './oauth';
|
||||||
|
import { AgentOperations, writing, plain } from './operations';
|
||||||
|
import { AgentFiles } from './files';
|
||||||
|
import { Database } from '../database';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
export function toolResult(value: unknown) {
|
||||||
|
const data = plain(value);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
summary: data?.status ? `操作状态:${data.status}` : '查询或操作已完成',
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: { data },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class AgentTransport {
|
||||||
|
constructor(
|
||||||
|
private oauth: AgentOAuth,
|
||||||
|
private operations: AgentOperations,
|
||||||
|
private files: AgentFiles,
|
||||||
|
private db: Database,
|
||||||
|
) {}
|
||||||
|
install(app: Express) {
|
||||||
|
const { issuer, resource } = urls();
|
||||||
|
app.use(
|
||||||
|
mcpAuthRouter({
|
||||||
|
provider: this.oauth,
|
||||||
|
issuerUrl: issuer,
|
||||||
|
resourceServerUrl: resource,
|
||||||
|
scopesSupported: [...scopes],
|
||||||
|
resourceName: 'WorthPath',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.files.install(app);
|
||||||
|
app.all(
|
||||||
|
'/mcp',
|
||||||
|
(req, res, next) => {
|
||||||
|
const allowed = (process.env.MCP_ALLOWED_ORIGINS || urls().web.origin)
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim());
|
||||||
|
if (req.headers.origin && !allowed.includes(req.headers.origin)) {
|
||||||
|
res.status(403).json({ error: 'Untrusted origin' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validHosts = [resource.host];
|
||||||
|
if (process.env.NODE_ENV !== 'production')
|
||||||
|
validHosts.push('127.0.0.1:' + resource.port, 'localhost:' + resource.port);
|
||||||
|
if (!validHosts.includes(req.headers.host || '')) {
|
||||||
|
res.status(403).json({ error: 'Untrusted host' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
|
||||||
|
res.setHeader(
|
||||||
|
'Access-Control-Allow-Headers',
|
||||||
|
'Authorization, Content-Type, Accept, MCP-Protocol-Version, MCP-Session-Id',
|
||||||
|
);
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireBearerAuth({
|
||||||
|
verifier: this.oauth,
|
||||||
|
requiredScopes: ['read'],
|
||||||
|
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.setHeader('Allow', 'POST');
|
||||||
|
res.status(405).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grantId = String(req.auth?.extra?.grantId);
|
||||||
|
const invoke = async (name: string, work: () => Promise<unknown>) => {
|
||||||
|
let status = 'success';
|
||||||
|
try {
|
||||||
|
return toolResult(await work());
|
||||||
|
} catch (e) {
|
||||||
|
status = 'error';
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text:
|
||||||
|
e instanceof HttpException || e instanceof ZodError
|
||||||
|
? e.message
|
||||||
|
: '操作失败,未提交账目变更;请检查参数或稍后重试',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
const grant = await this.oauth.grant(grantId).catch(() => null);
|
||||||
|
if (grant)
|
||||||
|
await this.db.agentCall
|
||||||
|
.create({ data: { userId: grant.userId, grantId, tool: name, status } })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: 'WorthPath', version: '1.0.0' },
|
||||||
|
{
|
||||||
|
instructions:
|
||||||
|
'金额为十进制字符串;业务日期 UTC+8,时间戳 ISO UTC。先 state_get 再写入,所有写入要求幂等键。pending 返回网页入口,用户确认后用 operation_get 查询。只使用稳定 UUID;重名询问用户。',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (const tool of this.operations.tools) {
|
||||||
|
server.registerTool(
|
||||||
|
tool.name,
|
||||||
|
{
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.scope === 'read' ? tool.schema : tool.schema.safeExtend(writing),
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: tool.scope === 'read',
|
||||||
|
destructiveHint: !!tool.destructive,
|
||||||
|
idempotentHint: tool.scope !== 'read',
|
||||||
|
openWorldHint: ['rates_refresh', 'metals_refresh'].includes(tool.name),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (p: any) => {
|
||||||
|
return invoke(tool.name, () => this.operations.call(grantId, tool.name, p));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const register = (
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
schema: z.ZodObject<any>,
|
||||||
|
run: (p: any) => Promise<unknown>,
|
||||||
|
read = true,
|
||||||
|
) =>
|
||||||
|
server.registerTool(
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
description,
|
||||||
|
inputSchema: schema,
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: read,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: read,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (p: any) => invoke(name, () => run(p)),
|
||||||
|
);
|
||||||
|
register(
|
||||||
|
'state_get',
|
||||||
|
'获取当前用户账目并发版本 state,写入时作为 expectedState。',
|
||||||
|
z.object({}).strict(),
|
||||||
|
async () => ({
|
||||||
|
state: await this.db.$transaction(async () =>
|
||||||
|
this.operations.state((await this.oauth.grant(grantId)).userId),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
register(
|
||||||
|
'operation_get',
|
||||||
|
'查询此连接发起的操作状态、过期时间和最终结果。',
|
||||||
|
z.object({ operationId: z.string().uuid() }).strict(),
|
||||||
|
(p) => this.operations.status(grantId, p.operationId),
|
||||||
|
);
|
||||||
|
register(
|
||||||
|
'file_upload_request',
|
||||||
|
'创建受 Bearer 保护的短期文件上传入口;multipart/form-data 的 file 字段。',
|
||||||
|
z.object({ kind: z.enum(['backup', 'icon']) }).strict(),
|
||||||
|
(p) => this.operations.uploadRequest(grantId, p.kind),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
register(
|
||||||
|
'file_status',
|
||||||
|
'查看此连接文件上传状态、预检结果和备份导入 token。',
|
||||||
|
z.object({ fileId: z.string().uuid() }).strict(),
|
||||||
|
(p) => this.operations.fileStatus(grantId, p.fileId),
|
||||||
|
);
|
||||||
|
register(
|
||||||
|
'icon_image',
|
||||||
|
'取得受 Bearer 保护的短期 PNG 图标读取入口。',
|
||||||
|
z.object({ id: z.string().uuid() }).strict(),
|
||||||
|
(p) => this.operations.iconImage(grantId, p.id),
|
||||||
|
);
|
||||||
|
register(
|
||||||
|
'connection_info',
|
||||||
|
'查询本连接权限、到期时间、资源和用户写入策略;不返回任何令牌。',
|
||||||
|
z.object({}).strict(),
|
||||||
|
async () => {
|
||||||
|
const g = await this.oauth.grant(grantId);
|
||||||
|
return {
|
||||||
|
name: g.name,
|
||||||
|
scopes: g.scopes,
|
||||||
|
expiresAt: g.expiresAt,
|
||||||
|
resource: g.resource,
|
||||||
|
writePolicy:
|
||||||
|
(await this.db.agentPolicy.findUnique({ where: { userId: g.userId } }))?.mode ||
|
||||||
|
'draft',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
register(
|
||||||
|
'connection_revoke',
|
||||||
|
'立即撤销本 Agent 连接并退出授权;不影响网页登录。撤销后无法继续查询,请先保存操作结果。',
|
||||||
|
z.object({}).strict(),
|
||||||
|
async () => {
|
||||||
|
const g = await this.oauth.grant(grantId);
|
||||||
|
await this.db.agentGrant.update({
|
||||||
|
where: { id: g.id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
return { revoked: true };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
enableJsonResponse: true,
|
||||||
|
});
|
||||||
|
res.once('close', () => {
|
||||||
|
void transport.close();
|
||||||
|
void server.close();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch {
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: 'MCP request failed' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-12
@@ -258,14 +258,15 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Controller('api/metals')
|
|
||||||
export class MetalsController {
|
@Injectable()
|
||||||
|
export class MetalsBusinessService {
|
||||||
constructor(
|
constructor(
|
||||||
private db: Database,
|
private db: Database,
|
||||||
private metals: MetalsService,
|
private metals: MetalsService,
|
||||||
) {}
|
) {}
|
||||||
@Get() async list(@Req() r: UserRequest) {
|
async list(r: UserRequest) {
|
||||||
void this.metals.daily(r.userId);
|
if (!r.agent) void this.metals.daily(r.userId);
|
||||||
return {
|
return {
|
||||||
status: this.metals.status(r.userId),
|
status: this.metals.status(r.userId),
|
||||||
prices: await this.db.metalPrice.findMany({
|
prices: await this.db.metalPrice.findMany({
|
||||||
@@ -275,10 +276,10 @@ export class MetalsController {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Post('refresh') refresh(@Req() r: UserRequest) {
|
refresh(r: UserRequest) {
|
||||||
return this.metals.refresh(r.userId);
|
return this.metals.refresh(r.userId);
|
||||||
}
|
}
|
||||||
@Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) {
|
async manual(r: UserRequest, body: unknown) {
|
||||||
const v = metalPriceInput.parse(body);
|
const v = metalPriceInput.parse(body);
|
||||||
const key = {
|
const key = {
|
||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
@@ -309,11 +310,7 @@ export class MetalsController {
|
|||||||
return { message: '贵金属价格已保存' };
|
return { message: '贵金属价格已保存' };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Put(':id') async configure(
|
async configure(r: UserRequest, id: string, body: unknown) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: unknown,
|
|
||||||
) {
|
|
||||||
const v = metalConfig.parse(body);
|
const v = metalConfig.parse(body);
|
||||||
const result = await this.db.serial(async (tx) => {
|
const result = await this.db.serial(async (tx) => {
|
||||||
await tx.$queryRaw(
|
await tx.$queryRaw(
|
||||||
@@ -348,7 +345,31 @@ export class MetalsController {
|
|||||||
this.metals.invalidate(r.userId);
|
this.metals.invalidate(r.userId);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@Post(':id/value') value(@Req() r: UserRequest, @Param('id') id: string) {
|
value(r: UserRequest, id: string) {
|
||||||
return this.db.serial((tx) => this.metals.apply(tx, r.userId, id, r.revealed, true));
|
return this.db.serial((tx) => this.metals.apply(tx, r.userId, id, r.revealed, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api/metals')
|
||||||
|
export class MetalsController {
|
||||||
|
constructor(private service: MetalsBusinessService) {}
|
||||||
|
@Get() async list(@Req() r: UserRequest) {
|
||||||
|
return this.service.list(r);
|
||||||
|
}
|
||||||
|
@Post('refresh') refresh(@Req() r: UserRequest) {
|
||||||
|
return this.service.refresh(r);
|
||||||
|
}
|
||||||
|
@Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) {
|
||||||
|
return this.service.manual(r, body);
|
||||||
|
}
|
||||||
|
@Put(':id') async configure(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.configure(r, id, body);
|
||||||
|
}
|
||||||
|
@Post(':id/value') value(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
|
return this.service.value(r, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-42
@@ -1,3 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -32,8 +33,9 @@ import { MetalsService } from './metals';
|
|||||||
import { RatesService } from './rates';
|
import { RatesService } from './rates';
|
||||||
import { captureReplay } from './replay';
|
import { captureReplay } from './replay';
|
||||||
import { changeMovement } from './transfers';
|
import { changeMovement } from './transfers';
|
||||||
@Controller('api')
|
|
||||||
export class PortfolioController {
|
@Injectable()
|
||||||
|
export class PortfolioBusinessService {
|
||||||
constructor(
|
constructor(
|
||||||
private db: Database,
|
private db: Database,
|
||||||
private fx: RatesService,
|
private fx: RatesService,
|
||||||
@@ -45,7 +47,7 @@ export class PortfolioController {
|
|||||||
if (!p) throw new NotFoundException('项目不存在');
|
if (!p) throw new NotFoundException('项目不存在');
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
@Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) {
|
async list(r: UserRequest, inputKind?: string) {
|
||||||
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
|
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
|
||||||
return this.db.$transaction(async (tx) => {
|
return this.db.$transaction(async (tx) => {
|
||||||
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
|
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
|
||||||
@@ -71,18 +73,14 @@ export class PortfolioController {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
|
async detail(r: UserRequest, id: string) {
|
||||||
const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed);
|
const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed);
|
||||||
return { ...p, amount: revisions[0]?.amount.toString() || '0' };
|
return { ...p, amount: revisions[0]?.amount.toString() || '0' };
|
||||||
}
|
}
|
||||||
@Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) {
|
async history(r: UserRequest, query: unknown) {
|
||||||
return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query));
|
return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query));
|
||||||
}
|
}
|
||||||
@Get('positions/:id/history') async positionHistory(
|
async positionHistory(r: UserRequest, id: string, query: Record<string, string>) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Query() query: Record<string, string>,
|
|
||||||
) {
|
|
||||||
return this.db.$transaction(async (tx) => {
|
return this.db.$transaction(async (tx) => {
|
||||||
const p = await tx.position.findFirst({
|
const p = await tx.position.findFirst({
|
||||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||||
@@ -91,7 +89,7 @@ export class PortfolioController {
|
|||||||
return historyPage(tx, r.userId, r.revealed, { ...query, positionId: id });
|
return historyPage(tx, r.userId, r.revealed, { ...query, positionId: id });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) {
|
async trend(r: UserRequest, query: unknown) {
|
||||||
const q = trendInput(query);
|
const q = trendInput(query);
|
||||||
return this.db.$transaction(
|
return this.db.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
@@ -106,7 +104,7 @@ export class PortfolioController {
|
|||||||
{ timeout: 30000 },
|
{ timeout: 30000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
async create(r: UserRequest, b: unknown) {
|
||||||
const v = positionInput.parse(b),
|
const v = positionInput.parse(b),
|
||||||
{ amount, date, ...meta } = v;
|
{ amount, date, ...meta } = v;
|
||||||
await this.icons.requireVisible(r.userId, meta.iconId);
|
await this.icons.requireVisible(r.userId, meta.iconId);
|
||||||
@@ -128,11 +126,7 @@ export class PortfolioController {
|
|||||||
this.fx.invalidate(r.userId);
|
this.fx.invalidate(r.userId);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
@Patch('positions/:id') async edit(
|
async edit(r: UserRequest, id: string, b: unknown) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() b: unknown,
|
|
||||||
) {
|
|
||||||
const v = positionMeta.parse(b),
|
const v = positionMeta.parse(b),
|
||||||
p = await this.own(r.userId, id, r.revealed);
|
p = await this.own(r.userId, id, r.revealed);
|
||||||
if (
|
if (
|
||||||
@@ -153,11 +147,7 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Post('positions/:id/revisions') async revise(
|
async revise(r: UserRequest, id: string, b: unknown) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() b: unknown,
|
|
||||||
) {
|
|
||||||
const v = revisionInput.parse(b);
|
const v = revisionInput.parse(b);
|
||||||
if (pairedReasons.includes(v.reason)) throw new BadRequestException('请使用账户转账接口');
|
if (pairedReasons.includes(v.reason)) throw new BadRequestException('请使用账户转账接口');
|
||||||
return this.db.serial(async (tx) => {
|
return this.db.serial(async (tx) => {
|
||||||
@@ -197,12 +187,7 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Put('positions/:id/revisions/:revisionId') async correct(
|
async correct(r: UserRequest, id: string, revisionId: string, b: unknown) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Param('revisionId') revisionId: string,
|
|
||||||
@Body() b: unknown,
|
|
||||||
) {
|
|
||||||
const v = revisionInput.parse(b);
|
const v = revisionInput.parse(b);
|
||||||
return this.db.serial(async (tx) => {
|
return this.db.serial(async (tx) => {
|
||||||
await tx.$queryRaw(
|
await tx.$queryRaw(
|
||||||
@@ -234,11 +219,7 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('positions/:id/revisions/:revisionId') async deleteRevision(
|
async deleteRevision(r: UserRequest, id: string, revisionId: string) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Param('revisionId') revisionId: string,
|
|
||||||
) {
|
|
||||||
return this.db.serial(async (tx) => {
|
return this.db.serial(async (tx) => {
|
||||||
const p = await tx.position.findFirst({
|
const p = await tx.position.findFirst({
|
||||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||||
@@ -264,11 +245,7 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('positions/:id/links') async link(
|
async link(r: UserRequest, id: string, b: unknown) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() b: unknown,
|
|
||||||
) {
|
|
||||||
const { targetIds } = z
|
const { targetIds } = z
|
||||||
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
||||||
.strict()
|
.strict()
|
||||||
@@ -281,7 +258,12 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
if (!source) throw new NotFoundException('债务不存在');
|
if (!source) throw new NotFoundException('债务不存在');
|
||||||
const count = await tx.position.count({
|
const count = await tx.position.count({
|
||||||
where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } },
|
where: {
|
||||||
|
id: { in: targetIds },
|
||||||
|
userId: r.userId,
|
||||||
|
kind: { in: ['account', 'asset'] },
|
||||||
|
...(r.revealed ? {} : { hidden: false }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产');
|
if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产');
|
||||||
await tx.positionLink.deleteMany({ where: { sourceId: id } });
|
await tx.positionLink.deleteMany({ where: { sourceId: id } });
|
||||||
@@ -291,9 +273,11 @@ export class PortfolioController {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Get('overview') async overview(@Req() r: UserRequest) {
|
async overview(r: UserRequest) {
|
||||||
void this.fx.daily(r.userId);
|
if (!r.agent) {
|
||||||
void this.metals.daily(r.userId);
|
void this.fx.daily(r.userId);
|
||||||
|
void this.metals.daily(r.userId);
|
||||||
|
}
|
||||||
return this.db.$transaction(async (tx) => {
|
return this.db.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUniqueOrThrow({
|
const user = await tx.user.findUniqueOrThrow({
|
||||||
where: { id: r.userId },
|
where: { id: r.userId },
|
||||||
@@ -323,3 +307,69 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class PortfolioController {
|
||||||
|
constructor(private service: PortfolioBusinessService) {}
|
||||||
|
@Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) {
|
||||||
|
return this.service.list(r, inputKind);
|
||||||
|
}
|
||||||
|
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
|
return this.service.detail(r, id);
|
||||||
|
}
|
||||||
|
@Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) {
|
||||||
|
return this.service.history(r, query);
|
||||||
|
}
|
||||||
|
@Get('positions/:id/history') async positionHistory(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query() query: Record<string, string>,
|
||||||
|
) {
|
||||||
|
return this.service.positionHistory(r, id, query);
|
||||||
|
}
|
||||||
|
@Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) {
|
||||||
|
return this.service.trend(r, query);
|
||||||
|
}
|
||||||
|
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||||
|
return this.service.create(r, b);
|
||||||
|
}
|
||||||
|
@Patch('positions/:id') async edit(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() b: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.edit(r, id, b);
|
||||||
|
}
|
||||||
|
@Post('positions/:id/revisions') async revise(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() b: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.revise(r, id, b);
|
||||||
|
}
|
||||||
|
@Put('positions/:id/revisions/:revisionId') async correct(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('revisionId') revisionId: string,
|
||||||
|
@Body() b: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.correct(r, id, revisionId, b);
|
||||||
|
}
|
||||||
|
@Delete('positions/:id/revisions/:revisionId') async deleteRevision(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('revisionId') revisionId: string,
|
||||||
|
) {
|
||||||
|
return this.service.deleteRevision(r, id, revisionId);
|
||||||
|
}
|
||||||
|
@Put('positions/:id/links') async link(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() b: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.link(r, id, b);
|
||||||
|
}
|
||||||
|
@Get('overview') async overview(@Req() r: UserRequest) {
|
||||||
|
return this.service.overview(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-10
@@ -169,14 +169,15 @@ export class RatesService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Controller('api')
|
|
||||||
export class SettingsController {
|
@Injectable()
|
||||||
|
export class SettingsBusinessService {
|
||||||
constructor(
|
constructor(
|
||||||
private db: Database,
|
private db: Database,
|
||||||
private fx: RatesService,
|
private fx: RatesService,
|
||||||
private auth: AuthService,
|
private auth: AuthService,
|
||||||
) {}
|
) {}
|
||||||
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
|
async settings(r: UserRequest, includeRates?: string) {
|
||||||
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
|
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
|
||||||
const u = await this.db.user.findUniqueOrThrow({
|
const u = await this.db.user.findUniqueOrThrow({
|
||||||
where: { id: r.userId },
|
where: { id: r.userId },
|
||||||
@@ -217,11 +218,7 @@ export class SettingsController {
|
|||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Patch('settings') async update(
|
async update(r: UserRequest, b: unknown, res: Response) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Body() b: unknown,
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
const data = settingsInput.parse(b);
|
const data = settingsInput.parse(b);
|
||||||
const expiresAt =
|
const expiresAt =
|
||||||
data.sessionHours === undefined
|
data.sessionHours === undefined
|
||||||
@@ -236,11 +233,29 @@ export class SettingsController {
|
|||||||
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
||||||
if (expiresAt) await tx.session.update({ where: { id: r.sessionId }, data: { expiresAt } });
|
if (expiresAt) await tx.session.update({ where: { id: r.sessionId }, data: { expiresAt } });
|
||||||
});
|
});
|
||||||
if (expiresAt) this.auth.cookie(r.cookies.wp_session, expiresAt, res);
|
if (expiresAt && !r.agent) this.auth.cookie(r.cookies.wp_session, expiresAt, res);
|
||||||
this.fx.invalidate(r.userId);
|
this.fx.invalidate(r.userId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Post('rates/refresh') async refresh(@Req() r: UserRequest) {
|
async refresh(r: UserRequest) {
|
||||||
return this.fx.refresh(r.userId);
|
return this.fx.refresh(r.userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private service: SettingsBusinessService) {}
|
||||||
|
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
|
||||||
|
return this.service.settings(r, includeRates);
|
||||||
|
}
|
||||||
|
@Patch('settings') async update(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Body() b: unknown,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
return this.service.update(r, b, res);
|
||||||
|
}
|
||||||
|
@Post('rates/refresh') async refresh(@Req() r: UserRequest) {
|
||||||
|
return this.service.refresh(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-12
@@ -1,3 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -56,8 +57,9 @@ export function occurrenceId(id: string, when: Date) {
|
|||||||
export function nextOccurrence(when: Date, intervalDays: number) {
|
export function nextOccurrence(when: Date, intervalDays: number) {
|
||||||
return new Date(+when + intervalDays * 86400000);
|
return new Date(+when + intervalDays * 86400000);
|
||||||
}
|
}
|
||||||
@Controller('api/schedules')
|
|
||||||
export class SchedulesController {
|
@Injectable()
|
||||||
|
export class SchedulesBusinessService {
|
||||||
constructor(private db: Database) {}
|
constructor(private db: Database) {}
|
||||||
private async visible(r: UserRequest, tx: Prisma.TransactionClient = this.db) {
|
private async visible(r: UserRequest, tx: Prisma.TransactionClient = this.db) {
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +73,7 @@ export class SchedulesController {
|
|||||||
})
|
})
|
||||||
).map((p) => p.id);
|
).map((p) => p.id);
|
||||||
}
|
}
|
||||||
@Get() async list(@Req() r: UserRequest) {
|
async list(r: UserRequest) {
|
||||||
const ids = await this.visible(r);
|
const ids = await this.visible(r);
|
||||||
const rows = await this.db.schedule.findMany({
|
const rows = await this.db.schedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -83,7 +85,7 @@ export class SchedulesController {
|
|||||||
});
|
});
|
||||||
return rows.map(({ userId, ...v }) => ({ ...v, nextAt: businessTime(v.nextAt) }));
|
return rows.map(({ userId, ...v }) => ({ ...v, nextAt: businessTime(v.nextAt) }));
|
||||||
}
|
}
|
||||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
async create(r: UserRequest, body: unknown) {
|
||||||
const v = scheduleInput.parse(body);
|
const v = scheduleInput.parse(body);
|
||||||
return this.db.serial(async (tx) => {
|
return this.db.serial(async (tx) => {
|
||||||
await this.validateAccounts(tx, r, v);
|
await this.validateAccounts(tx, r, v);
|
||||||
@@ -123,7 +125,7 @@ export class SchedulesController {
|
|||||||
)
|
)
|
||||||
throw new BadRequestException('同币种转出与到账金额必须一致');
|
throw new BadRequestException('同币种转出与到账金额必须一致');
|
||||||
}
|
}
|
||||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
async edit(r: UserRequest, id: string, body: unknown) {
|
||||||
const v = scheduleInput.parse(body);
|
const v = scheduleInput.parse(body);
|
||||||
return this.db.serial(async (tx) => {
|
return this.db.serial(async (tx) => {
|
||||||
await tx.$queryRaw(
|
await tx.$queryRaw(
|
||||||
@@ -148,11 +150,7 @@ export class SchedulesController {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Patch(':id') async toggle(
|
async toggle(r: UserRequest, id: string, body: unknown) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: unknown,
|
|
||||||
) {
|
|
||||||
const v = z.object({ enabled: z.boolean() }).strict().parse(body);
|
const v = z.object({ enabled: z.boolean() }).strict().parse(body);
|
||||||
const ids = await this.visible(r);
|
const ids = await this.visible(r);
|
||||||
const row = await this.db.schedule.findFirst({
|
const row = await this.db.schedule.findFirst({
|
||||||
@@ -176,7 +174,7 @@ export class SchedulesController {
|
|||||||
if (!result.count) throw new NotFoundException('计划不存在');
|
if (!result.count) throw new NotFoundException('计划不存在');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
async remove(r: UserRequest, id: string) {
|
||||||
const ids = await this.visible(r);
|
const ids = await this.visible(r);
|
||||||
const result = await this.db.schedule.deleteMany({
|
const result = await this.db.schedule.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -189,7 +187,7 @@ export class SchedulesController {
|
|||||||
if (!result.count) throw new NotFoundException('计划不存在');
|
if (!result.count) throw new NotFoundException('计划不存在');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Post('run') async run(@Req() r: UserRequest) {
|
async run(r: UserRequest, atomicBatch = false) {
|
||||||
const ids = await this.visible(r),
|
const ids = await this.visible(r),
|
||||||
now = new Date();
|
now = new Date();
|
||||||
const due = await this.db.schedule.findMany({
|
const due = await this.db.schedule.findMany({
|
||||||
@@ -281,6 +279,9 @@ export class SchedulesController {
|
|||||||
});
|
});
|
||||||
if (applied) executed++;
|
if (applied) executed++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// MCP wraps the complete call plus its idempotency result in one transaction.
|
||||||
|
// A failed nested plan must abort that batch rather than leave partial work.
|
||||||
|
if (atomicBatch) throw e;
|
||||||
errors.push({
|
errors.push({
|
||||||
id: candidate.id,
|
id: candidate.id,
|
||||||
message:
|
message:
|
||||||
@@ -297,3 +298,30 @@ export class SchedulesController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api/schedules')
|
||||||
|
export class SchedulesController {
|
||||||
|
constructor(private service: SchedulesBusinessService) {}
|
||||||
|
@Get() async list(@Req() r: UserRequest) {
|
||||||
|
return this.service.list(r);
|
||||||
|
}
|
||||||
|
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||||
|
return this.service.create(r, body);
|
||||||
|
}
|
||||||
|
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||||
|
return this.service.edit(r, id, body);
|
||||||
|
}
|
||||||
|
@Patch(':id') async toggle(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.toggle(r, id, body);
|
||||||
|
}
|
||||||
|
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
|
return this.service.remove(r, id);
|
||||||
|
}
|
||||||
|
@Post('run') async run(@Req() r: UserRequest) {
|
||||||
|
return this.service.run(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-10
@@ -1,3 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -21,10 +22,11 @@ import { businessTime } from './calculation';
|
|||||||
import { movementDeltas } from './movement';
|
import { movementDeltas } from './movement';
|
||||||
import { captureReplay } from './replay';
|
import { captureReplay } from './replay';
|
||||||
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
|
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
|
||||||
@Controller('api/transfers')
|
|
||||||
export class TransfersController {
|
@Injectable()
|
||||||
|
export class TransfersBusinessService {
|
||||||
constructor(private db: Database) {}
|
constructor(private db: Database) {}
|
||||||
@Get() async list(@Req() r: UserRequest, @Query() query: unknown) {
|
async list(r: UserRequest, query: unknown) {
|
||||||
const q = pageInput(query);
|
const q = pageInput(query);
|
||||||
const rows = await this.db.$transaction(async (tx) => {
|
const rows = await this.db.$transaction(async (tx) => {
|
||||||
const ids = await transferPageIds(tx, r.userId, r.revealed, q);
|
const ids = await transferPageIds(tx, r.userId, r.revealed, q);
|
||||||
@@ -48,10 +50,7 @@ export class TransfersController {
|
|||||||
revealed: r.revealed,
|
revealed: r.revealed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Get('revision/:revisionId') async byRevision(
|
async byRevision(r: UserRequest, revisionId: string) {
|
||||||
@Req() r: UserRequest,
|
|
||||||
@Param('revisionId') revisionId: string,
|
|
||||||
) {
|
|
||||||
const row = await this.db.transfer.findFirst({
|
const row = await this.db.transfer.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
@@ -67,11 +66,11 @@ export class TransfersController {
|
|||||||
const { userId, importedFromId, effectiveDate, ...v } = row;
|
const { userId, importedFromId, effectiveDate, ...v } = row;
|
||||||
return { ...v, date: businessTime(effectiveDate) };
|
return { ...v, date: businessTime(effectiveDate) };
|
||||||
}
|
}
|
||||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
async edit(r: UserRequest, id: string, body: unknown) {
|
||||||
const v = transferInput.parse(body);
|
const v = transferInput.parse(body);
|
||||||
return this.change(r, id, v);
|
return this.change(r, id, v);
|
||||||
}
|
}
|
||||||
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
async remove(r: UserRequest, id: string) {
|
||||||
return this.change(r, id);
|
return this.change(r, id);
|
||||||
}
|
}
|
||||||
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
|
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
|
||||||
@@ -79,7 +78,7 @@ export class TransfersController {
|
|||||||
return changeMovement(tx, r, id, v);
|
return changeMovement(tx, r, id, v);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
async create(r: UserRequest, body: unknown) {
|
||||||
const v = transferInput.parse(body);
|
const v = transferInput.parse(body);
|
||||||
return this.db.serial((tx) => executeMovement(tx, r, v));
|
return this.db.serial((tx) => executeMovement(tx, r, v));
|
||||||
}
|
}
|
||||||
@@ -244,3 +243,26 @@ export async function executeMovement(
|
|||||||
});
|
});
|
||||||
return { id: row.id };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('api/transfers')
|
||||||
|
export class TransfersController {
|
||||||
|
constructor(private service: TransfersBusinessService) {}
|
||||||
|
@Get() async list(@Req() r: UserRequest, @Query() query: unknown) {
|
||||||
|
return this.service.list(r, query);
|
||||||
|
}
|
||||||
|
@Get('revision/:revisionId') async byRevision(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('revisionId') revisionId: string,
|
||||||
|
) {
|
||||||
|
return this.service.byRevision(r, revisionId);
|
||||||
|
}
|
||||||
|
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||||
|
return this.service.edit(r, id, body);
|
||||||
|
}
|
||||||
|
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
|
return this.service.remove(r, id);
|
||||||
|
}
|
||||||
|
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||||
|
return this.service.create(r, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,7 +160,7 @@ test('all account transfer directions, replay, scheduled transfers and backup ar
|
|||||||
).status,
|
).status,
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
const backupController = (await import('../src/backup')).BackupController;
|
const backupController = (await import('../src/backup')).BackupBusinessService;
|
||||||
const controller = new backupController(db as any);
|
const controller = new backupController(db as any);
|
||||||
const backup = await (controller as any).data(a.id);
|
const backup = await (controller as any).data(a.id);
|
||||||
assert.equal((await import('../src/backup')).validateBackup(backup).transfers?.length, 2);
|
assert.equal((await import('../src/backup')).validateBackup(backup).transfers?.length, 2);
|
||||||
@@ -257,7 +257,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
|
|||||||
date: d,
|
date: d,
|
||||||
});
|
});
|
||||||
assert.equal(await db.revision.count({ where: { positionId: metal } }), count);
|
assert.equal(await db.revision.count({ where: { positionId: metal } }), count);
|
||||||
const controller = new (await import('../src/backup')).BackupController(db as any);
|
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
|
||||||
const backup = await (controller as any).data(a.id);
|
const backup = await (controller as any).data(a.id);
|
||||||
const archive = (await import('../src/zip')).archiveBackup(backup);
|
const archive = (await import('../src/zip')).archiveBackup(backup);
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,875 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID, randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { auth, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||||
|
import { today } from '../src/validation';
|
||||||
|
import { readBackupZip } from '../src/zip';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
const root = process.env.TEST_API_URL?.replace(/\/api$/, '') || 'http://127.0.0.1:3100';
|
||||||
|
const resource = process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp';
|
||||||
|
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||||
|
|
||||||
|
test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isolation, files and sensitive confirmation', async () => {
|
||||||
|
const db = new PrismaClient(),
|
||||||
|
users: string[] = [],
|
||||||
|
clients: Client[] = [];
|
||||||
|
async function web(cookie: string, path: string, method = 'GET', body?: unknown) {
|
||||||
|
const response = await fetch(root + '/api' + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Origin: origin,
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
data: await response.json(),
|
||||||
|
cookie: response.headers.get('set-cookie')?.split(';')[0] || cookie,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function fixture(mode = 'direct', selected = ['read', 'draft', 'write', 'sensitive']) {
|
||||||
|
const username = 'mcp_test_' + randomUUID().slice(0, 12),
|
||||||
|
password = randomBytes(20).toString('hex');
|
||||||
|
const registered = await web('', '/auth/register', 'POST', { username, password });
|
||||||
|
assert.equal(registered.status, 201);
|
||||||
|
const user = await db.user.findUniqueOrThrow({ where: { username } });
|
||||||
|
users.push(user.id);
|
||||||
|
const cookie = registered.cookie;
|
||||||
|
assert.equal((await web(cookie, '/agent/policy', 'PUT', { mode, password })).status, 200);
|
||||||
|
const token = await web(cookie, '/agent/tokens', 'POST', {
|
||||||
|
name: 'Official SDK integration',
|
||||||
|
days: 1,
|
||||||
|
scopes: selected,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
assert.equal(token.status, 201);
|
||||||
|
const client = new Client({ name: 'WorthPath integration', version: '1.31.0' });
|
||||||
|
clients.push(client);
|
||||||
|
await client.connect(
|
||||||
|
new StreamableHTTPClientTransport(new URL(resource), {
|
||||||
|
requestInit: { headers: { Authorization: 'Bearer ' + token.data.token } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
client,
|
||||||
|
cookie,
|
||||||
|
password,
|
||||||
|
token: token.data.token,
|
||||||
|
grantId: token.data.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function call(a: any, name: string, args: any = {}) {
|
||||||
|
const v: any = await a.client.callTool({ name, arguments: args });
|
||||||
|
assert.ok(!v.isError, JSON.stringify(v));
|
||||||
|
return v.structuredContent.data;
|
||||||
|
}
|
||||||
|
async function fail(a: any, name: string, args: any = {}) {
|
||||||
|
const v: any = await a.client.callTool({ name, arguments: args });
|
||||||
|
assert.equal(v.isError, true, JSON.stringify(v));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
async function write(a: any, name: string, args: any = {}) {
|
||||||
|
const state = (await call(a, 'state_get')).state;
|
||||||
|
return call(a, name, { ...args, expectedState: state, idempotencyKey: randomUUID() });
|
||||||
|
}
|
||||||
|
async function confirm(a: any, operation: any, extra: any = {}) {
|
||||||
|
const v = await web(a.cookie, '/agent/operations/' + operation.operationId, 'POST', {
|
||||||
|
approve: true,
|
||||||
|
password: a.password,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
assert.equal(v.status, 201, JSON.stringify(v.data));
|
||||||
|
a.cookie = v.cookie;
|
||||||
|
return call(a, 'operation_get', { operationId: operation.operationId });
|
||||||
|
}
|
||||||
|
const day = today(),
|
||||||
|
position = {
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
name: 'same name',
|
||||||
|
category: 'cash',
|
||||||
|
currency: 'CNY',
|
||||||
|
amount: '1000.87654321',
|
||||||
|
date: day,
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const unauth = await fetch(resource, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
assert.equal(unauth.status, 401);
|
||||||
|
assert.match(unauth.headers.get('www-authenticate') || '', /resource_metadata/);
|
||||||
|
const metadata = await (await fetch(root + '/.well-known/oauth-protected-resource/mcp')).json();
|
||||||
|
assert.equal(metadata.resource, resource);
|
||||||
|
const a = await fixture(),
|
||||||
|
b = await fixture(),
|
||||||
|
d = await fixture('draft', ['read', 'draft']);
|
||||||
|
await write(a, 'rates_refresh');
|
||||||
|
await write(a, 'metals_refresh');
|
||||||
|
await call(a, 'connection_info');
|
||||||
|
const discovered = await a.client.listTools();
|
||||||
|
assert.ok(discovered.tools.length >= 40);
|
||||||
|
assert.equal(
|
||||||
|
discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const first = await write(a, 'position_create', position),
|
||||||
|
cash = first.result.id;
|
||||||
|
const second = (await write(a, 'position_create', { ...position, amount: '0' })).result.id;
|
||||||
|
const liability = (
|
||||||
|
await write(a, 'position_create', {
|
||||||
|
...position,
|
||||||
|
name: 'credit',
|
||||||
|
side: 'liability',
|
||||||
|
category: 'credit_card',
|
||||||
|
amount: '100',
|
||||||
|
})
|
||||||
|
).result.id;
|
||||||
|
const debt = (
|
||||||
|
await write(a, 'position_create', {
|
||||||
|
...position,
|
||||||
|
kind: 'debt',
|
||||||
|
side: 'liability',
|
||||||
|
name: 'loan',
|
||||||
|
category: 'loan',
|
||||||
|
amount: '100',
|
||||||
|
})
|
||||||
|
).result.id;
|
||||||
|
const metal = (
|
||||||
|
await write(a, 'position_create', {
|
||||||
|
...position,
|
||||||
|
kind: 'asset',
|
||||||
|
category: 'gold',
|
||||||
|
name: 'gold',
|
||||||
|
amount: '200',
|
||||||
|
})
|
||||||
|
).result.id;
|
||||||
|
assert.equal((await call(a, 'positions_list', { limit: 1, q: 'same name' })).total, 2);
|
||||||
|
assert.equal((await call(a, 'positions_list', { limit: 1, q: 'same name' })).nextOffset, 1);
|
||||||
|
await fail(b, 'position_get', { id: cash });
|
||||||
|
await fail(a, 'positions_list', { userId: b.id });
|
||||||
|
await fail(a, 'position_create', {
|
||||||
|
...position,
|
||||||
|
amount: 12,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
expectedState: (await call(a, 'state_get')).state,
|
||||||
|
});
|
||||||
|
const key = randomUUID(),
|
||||||
|
state = (await call(a, 'state_get')).state,
|
||||||
|
args = { ...position, name: 'idempotent', expectedState: state, idempotencyKey: key };
|
||||||
|
const once = await call(a, 'position_create', args),
|
||||||
|
again = await call(a, 'position_create', args);
|
||||||
|
assert.equal(once.result.id, again.result.id);
|
||||||
|
await fail(a, 'position_create', { ...args, name: 'changed' });
|
||||||
|
await fail(a, 'balance_record', {
|
||||||
|
id: cash,
|
||||||
|
data: { amount: '10', date: day },
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
expectedState: state,
|
||||||
|
});
|
||||||
|
const draft = await write(d, 'position_create', position);
|
||||||
|
assert.equal(draft.status, 'pending');
|
||||||
|
assert.equal((await call(d, 'positions_list')).total, 0);
|
||||||
|
assert.equal((await web(b.cookie, '/agent/operations/' + draft.operationId)).status, 404);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await web(d.cookie, '/agent/operations/' + draft.operationId, 'POST', {
|
||||||
|
approve: true,
|
||||||
|
confirmed: true,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const applied = await confirm(d, draft);
|
||||||
|
assert.ok(applied.result.id);
|
||||||
|
assert.equal((await call(d, 'positions_list')).total, 1);
|
||||||
|
const expired = await write(d, 'position_create', { ...position, name: 'expired' });
|
||||||
|
await db.agentOperation.update({
|
||||||
|
where: { id: expired.operationId },
|
||||||
|
data: { expiresAt: new Date(0) },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await call(d, 'operation_get', { operationId: expired.operationId })).status,
|
||||||
|
'expired',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await web(d.cookie, '/agent/operations/' + expired.operationId, 'POST', { approve: true }))
|
||||||
|
.status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
const cancelled = await write(d, 'position_create', { ...position, name: 'cancelled' });
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await web(d.cookie, '/agent/operations/' + cancelled.operationId, 'POST', {
|
||||||
|
approve: false,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
const stale = await write(d, 'position_create', { ...position, name: 'stale' });
|
||||||
|
await web(d.cookie, '/settings', 'PATCH', { showNotes: false });
|
||||||
|
assert.equal(
|
||||||
|
(await web(d.cookie, '/agent/operations/' + stale.operationId, 'POST', { approve: true }))
|
||||||
|
.status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
const mv = (
|
||||||
|
await write(a, 'movement_create', {
|
||||||
|
sourceId: cash,
|
||||||
|
targetId: second,
|
||||||
|
amount: '30.00000001',
|
||||||
|
received: '30.00000001',
|
||||||
|
fee: '0',
|
||||||
|
date: day,
|
||||||
|
})
|
||||||
|
).result;
|
||||||
|
assert.equal((await call(a, 'position_get', { id: cash })).amount, '970.8765432');
|
||||||
|
await write(a, 'movement_update', {
|
||||||
|
id: mv.id,
|
||||||
|
data: { sourceId: cash, targetId: second, amount: '40', received: '40', fee: '0', date: day },
|
||||||
|
});
|
||||||
|
assert.equal((await call(a, 'position_get', { id: second })).amount, '40');
|
||||||
|
const movementPage = await call(a, 'movements_list', { limit: 1 });
|
||||||
|
await call(a, 'movement_by_revision', { revisionId: movementPage.items[0].sourceRevisionId });
|
||||||
|
await write(a, 'movement_delete', { id: mv.id });
|
||||||
|
assert.equal((await call(a, 'position_get', { id: cash })).amount, '1000.87654321');
|
||||||
|
await write(a, 'movement_create', {
|
||||||
|
operation: 'repay',
|
||||||
|
sourceId: cash,
|
||||||
|
targetId: debt,
|
||||||
|
amount: '10',
|
||||||
|
received: '10',
|
||||||
|
date: day,
|
||||||
|
});
|
||||||
|
assert.equal((await call(a, 'position_get', { id: debt })).amount, '90');
|
||||||
|
await write(a, 'movement_create', {
|
||||||
|
sourceId: cash,
|
||||||
|
targetId: liability,
|
||||||
|
amount: '150',
|
||||||
|
received: '150',
|
||||||
|
date: day,
|
||||||
|
});
|
||||||
|
assert.equal((await call(a, 'position_get', { id: liability })).amount, '-50');
|
||||||
|
await write(a, 'debt_links_set', { id: debt, targetIds: [cash, metal] });
|
||||||
|
const rev = (
|
||||||
|
await write(a, 'balance_record', {
|
||||||
|
id: second,
|
||||||
|
data: { amount: '33.25', date: day, reason: 'balance' },
|
||||||
|
})
|
||||||
|
).result;
|
||||||
|
await write(a, 'history_update', {
|
||||||
|
id: second,
|
||||||
|
revisionId: rev.id,
|
||||||
|
data: { amount: '35.25', date: day },
|
||||||
|
});
|
||||||
|
assert.equal((await call(a, 'position_get', { id: second })).amount, '35.25');
|
||||||
|
await write(a, 'history_delete', { id: second, revisionId: rev.id });
|
||||||
|
assert.equal((await call(a, 'position_get', { id: second })).amount, '0');
|
||||||
|
const h = await call(a, 'history_list', { limit: 1 });
|
||||||
|
assert.equal(h.items.length, 1);
|
||||||
|
assert.ok(h.nextCursor);
|
||||||
|
await call(a, 'history_list', { limit: 1, cursor: h.nextCursor });
|
||||||
|
await write(a, 'settings_update', {
|
||||||
|
accountGroupOrder: ['invest', ''],
|
||||||
|
baseCurrency: 'CNY',
|
||||||
|
overviewCards: ['net'],
|
||||||
|
includeIndependentAssets: true,
|
||||||
|
});
|
||||||
|
await write(a, 'position_update', {
|
||||||
|
id: cash,
|
||||||
|
data: {
|
||||||
|
name: 'cash',
|
||||||
|
category: 'cash',
|
||||||
|
groupName: 'invest',
|
||||||
|
notes: 'memo',
|
||||||
|
archived: false,
|
||||||
|
hidden: false,
|
||||||
|
included: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await write(a, 'position_update', {
|
||||||
|
id: metal,
|
||||||
|
data: { name: 'gold', category: 'gold', notes: '', archived: true, hidden: false },
|
||||||
|
});
|
||||||
|
await fail(a, 'balance_record', {
|
||||||
|
id: metal,
|
||||||
|
data: { amount: '1', date: day },
|
||||||
|
expectedState: (await call(a, 'state_get')).state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
});
|
||||||
|
await write(a, 'position_update', {
|
||||||
|
id: metal,
|
||||||
|
data: { name: 'gold', category: 'gold', notes: '', archived: false, hidden: false },
|
||||||
|
});
|
||||||
|
await write(a, 'metal_price_set', {
|
||||||
|
metalType: 'gold',
|
||||||
|
currency: 'CNY',
|
||||||
|
price: '10.876543210987',
|
||||||
|
date: day,
|
||||||
|
});
|
||||||
|
await write(a, 'metal_configure', {
|
||||||
|
id: metal,
|
||||||
|
data: { metalType: 'gold', metalGrams: '2', metalPurity: '1', autoValuation: true },
|
||||||
|
});
|
||||||
|
assert.equal((await call(a, 'position_get', { id: metal })).amount, '21.75308642');
|
||||||
|
await write(a, 'metal_value', { id: metal });
|
||||||
|
await call(a, 'metals_prices');
|
||||||
|
await call(a, 'settings_get');
|
||||||
|
await call(a, 'overview_get', { limit: 1 });
|
||||||
|
await call(a, 'trend_get', { from: day, to: day, grain: 'day' });
|
||||||
|
await call(a, 'calendar_month', { month: day.slice(0, 7) });
|
||||||
|
await call(a, 'calendar_day', { date: day, limit: 1 });
|
||||||
|
await call(a, 'icons_list', { q: '', page: 1 });
|
||||||
|
const planInput = {
|
||||||
|
name: 'once',
|
||||||
|
operation: 'expense',
|
||||||
|
sourceId: cash,
|
||||||
|
amount: '1.25',
|
||||||
|
nextAt: day + 'T00:00',
|
||||||
|
intervalDays: 0,
|
||||||
|
};
|
||||||
|
const plan = (await write(a, 'schedule_create', planInput)).result.id;
|
||||||
|
await write(a, 'schedule_update', { id: plan, data: { ...planInput, amount: '2.25' } });
|
||||||
|
await write(a, 'schedule_toggle', { id: plan, enabled: false });
|
||||||
|
await call(a, 'schedules_list', { limit: 1 });
|
||||||
|
await write(a, 'schedule_toggle', { id: plan, enabled: true });
|
||||||
|
const before = (await call(a, 'position_get', { id: cash })).amount;
|
||||||
|
assert.equal((await write(a, 'schedules_run')).result.executed, 1);
|
||||||
|
assert.notEqual((await call(a, 'position_get', { id: cash })).amount, before);
|
||||||
|
assert.equal((await write(a, 'schedules_run')).result.executed, 0);
|
||||||
|
await write(a, 'schedule_delete', { id: plan });
|
||||||
|
const hidden = (
|
||||||
|
await write(a, 'position_create', { ...position, name: 'hidden', hidden: true })
|
||||||
|
).result.id;
|
||||||
|
await fail(a, 'position_get', { id: hidden });
|
||||||
|
await fail(a, 'debt_links_set', {
|
||||||
|
id: debt,
|
||||||
|
targetIds: [hidden],
|
||||||
|
expectedState: (await call(a, 'state_get')).state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
});
|
||||||
|
await fail(d, 'settings_update', {
|
||||||
|
requireHiddenPassword: false,
|
||||||
|
expectedState: (await call(d, 'state_get')).state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
});
|
||||||
|
const unlock = await write(a, 'hidden_unlock_request');
|
||||||
|
await confirm(a, unlock);
|
||||||
|
assert.equal((await call(a, 'position_get', { id: hidden })).id, hidden);
|
||||||
|
await write(a, 'hidden_lock');
|
||||||
|
await fail(a, 'position_get', { id: hidden });
|
||||||
|
const exported = await write(a, 'backup_export');
|
||||||
|
assert.equal(exported.status, 'pending');
|
||||||
|
const out = (await confirm(a, exported)).result;
|
||||||
|
assert.equal((await fetch(out.url)).status, 401);
|
||||||
|
assert.equal(
|
||||||
|
(await fetch(out.url, { headers: { Authorization: 'Bearer ' + b.token } })).status,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
const download = await fetch(out.url, { headers: { Authorization: 'Bearer ' + a.token } });
|
||||||
|
assert.equal(download.status, 200);
|
||||||
|
const zipped = Buffer.from(await download.arrayBuffer());
|
||||||
|
const backup: any = await readBackupZip(zipped);
|
||||||
|
assert.ok(backup.positions.find((p: any) => p.id === hidden));
|
||||||
|
assert.equal(JSON.stringify(backup).includes(a.token), false);
|
||||||
|
const target = await fixture('draft'),
|
||||||
|
upload = await call(target, 'file_upload_request', { kind: 'backup' }),
|
||||||
|
form = new FormData();
|
||||||
|
form.append('file', new Blob([zipped]), 'backup.zip');
|
||||||
|
const uploaded = await fetch(upload.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + target.token },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
assert.equal(uploaded.status, 200);
|
||||||
|
const info = await uploaded.json();
|
||||||
|
assert.ok(info.token);
|
||||||
|
await call(target, 'file_status', { fileId: upload.fileId });
|
||||||
|
await call(target, 'import_preview', { token: info.token });
|
||||||
|
const imported = await write(target, 'backup_import', { token: info.token });
|
||||||
|
await confirm(target, imported);
|
||||||
|
assert.equal(
|
||||||
|
await db.position.count({ where: { userId: target.id } }),
|
||||||
|
backup.positions.length,
|
||||||
|
);
|
||||||
|
const retry = await write(target, 'backup_import', { token: info.token });
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await web(target.cookie, '/agent/operations/' + retry.operationId, 'POST', {
|
||||||
|
approve: true,
|
||||||
|
password: target.password,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await db.position.count({ where: { userId: target.id } }),
|
||||||
|
backup.positions.length,
|
||||||
|
);
|
||||||
|
const clear = await write(target, 'data_clear_request');
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await web(target.cookie, '/agent/operations/' + clear.operationId, 'POST', {
|
||||||
|
approve: true,
|
||||||
|
password: target.password,
|
||||||
|
confirmation: '确定清空',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const save = await fetch(root + '/api/backup', { headers: { Cookie: target.cookie } });
|
||||||
|
assert.equal(save.status, 200);
|
||||||
|
await save.arrayBuffer();
|
||||||
|
await confirm(target, clear, { confirmation: '确定清空' });
|
||||||
|
assert.equal(await db.position.count({ where: { userId: target.id } }), 0);
|
||||||
|
assert.equal(
|
||||||
|
(await web(a.cookie, '/agent/policy', 'PUT', { mode: 'readonly', password: a.password }))
|
||||||
|
.status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
await fail(a, 'position_create', {
|
||||||
|
...position,
|
||||||
|
expectedState: (await call(a, 'state_get')).state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
});
|
||||||
|
await db.agentGrant.update({ where: { id: b.grantId }, data: { expiresAt: new Date(0) } });
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await fetch(resource, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + b.token, 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
await call(d, 'connection_revoke');
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await fetch(resource, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + d.token, 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
const originDenied = await fetch(resource, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Origin: 'https://evil.invalid',
|
||||||
|
Authorization: 'Bearer ' + a.token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
assert.equal(originDenied.status, 403);
|
||||||
|
} finally {
|
||||||
|
for (const c of clients) await c.close().catch(() => {});
|
||||||
|
await db.user.deleteMany({ where: { id: { in: users } } });
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback and credentials completion', async () => {
|
||||||
|
const db = new PrismaClient();
|
||||||
|
const username = 'mcp_extra_' + randomUUID().slice(0, 10),
|
||||||
|
password = randomBytes(20).toString('hex');
|
||||||
|
let userId = '',
|
||||||
|
cookie = '';
|
||||||
|
const clients: Client[] = [];
|
||||||
|
const iconIds: string[] = [];
|
||||||
|
async function web(path: string, method = 'GET', body?: unknown) {
|
||||||
|
const r = await fetch(root + '/api' + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Origin: origin,
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
cookie = r.headers.get('set-cookie')?.split(';')[0] || cookie;
|
||||||
|
return { status: r.status, data: await r.json() };
|
||||||
|
}
|
||||||
|
async function tool(c: Client, name: string, args: any = {}) {
|
||||||
|
return c.callTool({ name, arguments: args }) as Promise<any>;
|
||||||
|
}
|
||||||
|
async function call(c: Client, name: string, args: any = {}) {
|
||||||
|
const r = await tool(c, name, args);
|
||||||
|
assert.ok(!r.isError, JSON.stringify(r));
|
||||||
|
return r.structuredContent.data;
|
||||||
|
}
|
||||||
|
async function write(c: Client, name: string, args: any = {}) {
|
||||||
|
return call(c, name, {
|
||||||
|
...args,
|
||||||
|
expectedState: (await call(c, 'state_get')).state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
assert.equal((await web('/auth/register', 'POST', { username, password })).status, 201);
|
||||||
|
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
|
||||||
|
await web('/agent/policy', 'PUT', { mode: 'direct', password });
|
||||||
|
const grant = (
|
||||||
|
await web('/agent/tokens', 'POST', {
|
||||||
|
name: 'extra',
|
||||||
|
days: 1,
|
||||||
|
scopes: ['read', 'draft', 'write', 'sensitive'],
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
const headers = { Authorization: 'Bearer ' + grant.token };
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const c = new Client({ name: 'concurrent', version: '1.31.0' });
|
||||||
|
clients.push(c);
|
||||||
|
await c.connect(
|
||||||
|
new StreamableHTTPClientTransport(new URL(resource), { requestInit: { headers } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const c = clients[0],
|
||||||
|
position = {
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
name: 'concurrent',
|
||||||
|
category: 'cash',
|
||||||
|
currency: 'CNY',
|
||||||
|
amount: '100',
|
||||||
|
date: today(),
|
||||||
|
};
|
||||||
|
const args = {
|
||||||
|
...position,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
expectedState: (await call(c, 'state_get')).state,
|
||||||
|
};
|
||||||
|
const [one, two] = await Promise.all(
|
||||||
|
clients.map((client) => call(client, 'position_create', args)),
|
||||||
|
);
|
||||||
|
assert.equal(one.operationId, two.operationId);
|
||||||
|
assert.equal(await db.position.count({ where: { userId } }), 1);
|
||||||
|
const state = (await call(c, 'state_get')).state;
|
||||||
|
const results = await Promise.all(
|
||||||
|
clients.map((client) =>
|
||||||
|
tool(client, 'position_create', {
|
||||||
|
...position,
|
||||||
|
name: randomUUID(),
|
||||||
|
expectedState: state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(results.filter((r) => !r.isError).length, 1);
|
||||||
|
assert.equal(await db.position.count({ where: { userId } }), 2);
|
||||||
|
const upload = await call(c, 'file_upload_request', { kind: 'icon' }),
|
||||||
|
form = new FormData();
|
||||||
|
form.append(
|
||||||
|
'file',
|
||||||
|
new Blob([
|
||||||
|
await sharp({ create: { width: 4, height: 4, channels: 4, background: '#33aa88' } })
|
||||||
|
.png()
|
||||||
|
.toBuffer(),
|
||||||
|
]),
|
||||||
|
'icon.png',
|
||||||
|
);
|
||||||
|
assert.equal((await fetch(upload.url, { method: 'POST', headers, body: form })).status, 200);
|
||||||
|
const published = (
|
||||||
|
await write(c, 'icon_publish', { fileId: upload.fileId, name: '测试私有图标', shared: false })
|
||||||
|
).result;
|
||||||
|
iconIds.push(published.id);
|
||||||
|
const image = await call(c, 'icon_image', { id: published.id });
|
||||||
|
assert.equal((await fetch(image.url, { headers })).status, 200);
|
||||||
|
const shared = await write(c, 'icon_publish', {
|
||||||
|
fileId: upload.fileId,
|
||||||
|
name: '测试共享图标',
|
||||||
|
shared: true,
|
||||||
|
});
|
||||||
|
assert.equal(shared.status, 'pending');
|
||||||
|
assert.equal(
|
||||||
|
(await web('/agent/operations/' + shared.operationId, 'POST', { approve: true, password }))
|
||||||
|
.status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
iconIds.push((await call(c, 'operation_get', { operationId: shared.operationId })).result.id);
|
||||||
|
// A failed paired transfer leaves neither side changed, including inside outer
|
||||||
|
// idempotency transaction and nested service savepoints.
|
||||||
|
const account = one.result.id,
|
||||||
|
foreign = randomUUID(),
|
||||||
|
balance = (await call(c, 'position_get', { id: account })).amount;
|
||||||
|
const failure = await tool(c, 'movement_create', {
|
||||||
|
sourceId: account,
|
||||||
|
targetId: foreign,
|
||||||
|
amount: '1',
|
||||||
|
received: '1',
|
||||||
|
date: today(),
|
||||||
|
expectedState: (await call(c, 'state_get')).state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
});
|
||||||
|
assert.equal(failure.isError, true);
|
||||||
|
assert.equal((await call(c, 'position_get', { id: account })).amount, balance);
|
||||||
|
assert.equal(await db.transfer.count({ where: { userId } }), 0);
|
||||||
|
const other = (await call(c, 'positions_list')).items.find((v: any) => v.id !== account).id;
|
||||||
|
const good = (
|
||||||
|
await write(c, 'schedule_create', {
|
||||||
|
name: 'first valid',
|
||||||
|
operation: 'expense',
|
||||||
|
sourceId: account,
|
||||||
|
amount: '2',
|
||||||
|
nextAt: today() + 'T00:00',
|
||||||
|
intervalDays: 0,
|
||||||
|
})
|
||||||
|
).result.id;
|
||||||
|
const bad = (
|
||||||
|
await write(c, 'schedule_create', {
|
||||||
|
name: 'second archived',
|
||||||
|
operation: 'expense',
|
||||||
|
sourceId: other,
|
||||||
|
amount: '3',
|
||||||
|
nextAt: today() + 'T00:01',
|
||||||
|
intervalDays: 0,
|
||||||
|
})
|
||||||
|
).result.id;
|
||||||
|
await write(c, 'position_update', {
|
||||||
|
id: other,
|
||||||
|
data: { name: 'archived', category: 'cash', notes: '', archived: true, hidden: false },
|
||||||
|
});
|
||||||
|
const revisions = await db.revision.count({ where: { position: { userId } } });
|
||||||
|
const batch = await tool(c, 'schedules_run', {
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
expectedState: (await call(c, 'state_get')).state,
|
||||||
|
});
|
||||||
|
assert.equal(batch.isError, true);
|
||||||
|
assert.equal((await call(c, 'position_get', { id: account })).amount, balance);
|
||||||
|
assert.equal(await db.revision.count({ where: { position: { userId } } }), revisions);
|
||||||
|
assert.equal((await db.schedule.findUniqueOrThrow({ where: { id: good } })).completed, false);
|
||||||
|
await write(c, 'schedule_delete', { id: good });
|
||||||
|
await write(c, 'schedule_delete', { id: bad });
|
||||||
|
const management = (await web('/agent')).data;
|
||||||
|
assert.ok(management.calls.some((v: any) => v.status === 'error'));
|
||||||
|
assert.equal(JSON.stringify(management).includes(grant.token), false);
|
||||||
|
const operation = await write(c, 'credentials_change_request');
|
||||||
|
const replacement = randomBytes(20).toString('hex');
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await web('/agent/operations/' + operation.operationId, 'POST', {
|
||||||
|
approve: true,
|
||||||
|
password,
|
||||||
|
newPassword: replacement,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call(c, 'operation_get', { operationId: operation.operationId })).status,
|
||||||
|
'completed',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await tool(c, 'position_create', {
|
||||||
|
...position,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
expectedState: (await call(c, 'state_get')).state,
|
||||||
|
})
|
||||||
|
).isError,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await web('/auth/login', 'POST', { username, password: replacement })).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
for (const c of clients) await c.close().catch(() => {});
|
||||||
|
await db.icon.deleteMany({ where: { id: { in: iconIds } } });
|
||||||
|
if (userId) await db.user.deleteMany({ where: { id: userId } });
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation and resource validation', async () => {
|
||||||
|
const db = new PrismaClient();
|
||||||
|
const username = 'mcp_oauth_' + randomUUID().slice(0, 10),
|
||||||
|
password = randomBytes(20).toString('hex');
|
||||||
|
let userId = '',
|
||||||
|
clientId = '';
|
||||||
|
let saved: any,
|
||||||
|
tokens: any,
|
||||||
|
verifier = '',
|
||||||
|
authorization: URL | undefined;
|
||||||
|
const provider: OAuthClientProvider = {
|
||||||
|
redirectUrl: 'http://127.0.0.1:47891/callback',
|
||||||
|
clientMetadata: {
|
||||||
|
client_name: 'WorthPath official OAuth test',
|
||||||
|
redirect_uris: ['http://127.0.0.1:47891/callback'],
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
scope: 'read draft write sensitive',
|
||||||
|
},
|
||||||
|
clientInformation: () => saved,
|
||||||
|
saveClientInformation: (v) => {
|
||||||
|
saved = v;
|
||||||
|
clientId = v.client_id;
|
||||||
|
},
|
||||||
|
tokens: () => tokens,
|
||||||
|
saveTokens: (v) => {
|
||||||
|
tokens = v;
|
||||||
|
},
|
||||||
|
redirectToAuthorization: (v) => {
|
||||||
|
authorization = v;
|
||||||
|
},
|
||||||
|
saveCodeVerifier: (v) => {
|
||||||
|
verifier = v;
|
||||||
|
},
|
||||||
|
codeVerifier: () => verifier,
|
||||||
|
state: () => 'test-state',
|
||||||
|
};
|
||||||
|
async function post(path: string, body: any, cookie = '') {
|
||||||
|
const r = await fetch(root + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Origin: origin,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: r.status,
|
||||||
|
data: await r.json(),
|
||||||
|
cookie: r.headers.get('set-cookie')?.split(';')[0] || cookie,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const registered = await post('/api/auth/register', { username, password });
|
||||||
|
assert.equal(registered.status, 201);
|
||||||
|
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
|
||||||
|
assert.equal(
|
||||||
|
await auth(provider, { serverUrl: resource, scope: 'read draft write sensitive' }),
|
||||||
|
'REDIRECT',
|
||||||
|
);
|
||||||
|
assert.ok(authorization);
|
||||||
|
const redirected = await fetch(authorization!, { redirect: 'manual' });
|
||||||
|
assert.equal(redirected.status, 302);
|
||||||
|
const location = new URL(redirected.headers.get('location')!);
|
||||||
|
const id = location.searchParams.get('agent_authorization');
|
||||||
|
assert.ok(id);
|
||||||
|
const consent = await post(
|
||||||
|
'/api/agent/authorizations/' + id,
|
||||||
|
{ approve: true },
|
||||||
|
registered.cookie,
|
||||||
|
);
|
||||||
|
assert.equal(consent.status, 201);
|
||||||
|
const callback = new URL(consent.data.redirect);
|
||||||
|
assert.equal(callback.searchParams.get('state'), 'test-state');
|
||||||
|
const code = callback.searchParams.get('code')!;
|
||||||
|
assert.equal(
|
||||||
|
await auth(provider, { serverUrl: resource, authorizationCode: code }),
|
||||||
|
'AUTHORIZED',
|
||||||
|
);
|
||||||
|
assert.ok(tokens.access_token);
|
||||||
|
const old = tokens;
|
||||||
|
const client = new Client({ name: 'oauth-client', version: '1.31.0' });
|
||||||
|
await client.connect(
|
||||||
|
new StreamableHTTPClientTransport(new URL(resource), { authProvider: provider }),
|
||||||
|
);
|
||||||
|
assert.ok((await client.listTools()).tools.length >= 40);
|
||||||
|
await client.close();
|
||||||
|
async function exchange(params: Record<string, string>) {
|
||||||
|
const r = await fetch(root + '/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(params),
|
||||||
|
});
|
||||||
|
return { status: r.status, data: await r.json() };
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await exchange({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: clientId,
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: String(provider.redirectUrl),
|
||||||
|
resource,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await exchange({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: clientId,
|
||||||
|
refresh_token: old.refresh_token,
|
||||||
|
resource: 'https://evil.invalid/mcp',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const refreshed = await exchange({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: clientId,
|
||||||
|
refresh_token: old.refresh_token,
|
||||||
|
resource,
|
||||||
|
});
|
||||||
|
assert.equal(refreshed.status, 200);
|
||||||
|
assert.notEqual(refreshed.data.refresh_token, old.refresh_token);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await exchange({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: clientId,
|
||||||
|
refresh_token: old.refresh_token,
|
||||||
|
resource,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await fetch(resource, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + old.access_token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
const grant = await db.agentGrant.findFirstOrThrow({ where: { userId } });
|
||||||
|
assert.notEqual(grant.accessDigest, refreshed.data.access_token);
|
||||||
|
const revoke = await fetch(root + '/revoke', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ client_id: clientId, token: refreshed.data.access_token }),
|
||||||
|
});
|
||||||
|
assert.equal(revoke.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await fetch(resource, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + refreshed.data.access_token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (userId) await db.user.deleteMany({ where: { id: userId } });
|
||||||
|
if (clientId) await db.agentClient.deleteMany({ where: { id: clientId } });
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,17 +4,17 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Database } from '../src/database';
|
import { Database } from '../src/database';
|
||||||
import { TransfersController } from '../src/transfers';
|
import { TransfersBusinessService } from '../src/transfers';
|
||||||
import { PortfolioController } from '../src/portfolio';
|
import { PortfolioBusinessService } from '../src/portfolio';
|
||||||
import { CalendarController } from '../src/calendar';
|
import { CalendarBusinessService } from '../src/calendar';
|
||||||
import { toBusinessDate } from '../src/validation';
|
import { toBusinessDate } from '../src/validation';
|
||||||
|
|
||||||
test('record edits replay paired movements, expense deltas, anchors and calendar atomically', async () => {
|
test('record edits replay paired movements, expense deltas, anchors and calendar atomically', async () => {
|
||||||
const db = new Database();
|
const db = new Database();
|
||||||
const users: string[] = [];
|
const users: string[] = [];
|
||||||
const movements = new TransfersController(db);
|
const movements = new TransfersBusinessService(db);
|
||||||
const portfolio = new PortfolioController(db, {} as any, {} as any);
|
const portfolio = new PortfolioBusinessService(db, {} as any, {} as any);
|
||||||
const calendar = new CalendarController(db);
|
const calendar = new CalendarBusinessService(db);
|
||||||
try {
|
try {
|
||||||
const user = await db.user.create({
|
const user = await db.user.create({
|
||||||
data: { username: 'wp_edit_' + randomUUID(), passwordHash: 'unused' },
|
data: { username: 'wp_edit_' + randomUUID(), passwordHash: 'unused' },
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import 'reflect-metadata';
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { cashflowDelta, calendarMonth, CalendarController } from '../src/calendar';
|
import { cashflowDelta, calendarMonth, CalendarBusinessService } from '../src/calendar';
|
||||||
import { executeMovement } from '../src/transfers';
|
import { executeMovement } from '../src/transfers';
|
||||||
import { transferInput, positionInput, revisionInput } from '../src/validation';
|
import { transferInput, positionInput, revisionInput } from '../src/validation';
|
||||||
import { scheduleInput, occurrenceId, nextOccurrence, SchedulesController } from '../src/schedules';
|
import {
|
||||||
|
scheduleInput,
|
||||||
|
occurrenceId,
|
||||||
|
nextOccurrence,
|
||||||
|
SchedulesBusinessService,
|
||||||
|
} from '../src/schedules';
|
||||||
import { trend, totals, type Holding } from '../src/calculation';
|
import { trend, totals, type Holding } from '../src/calculation';
|
||||||
import { positionAmount, accountInputAmount, money, type Position } from '../../web/src/api';
|
import { positionAmount, accountInputAmount, money, type Position } from '../../web/src/api';
|
||||||
const now = new Date(Date.now() - 60000);
|
const now = new Date(Date.now() - 60000);
|
||||||
@@ -221,7 +226,7 @@ test('due expense runs once, updates balance exactly and advances a recurring pl
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const db: any = { ...tx, serial: async (work: any) => work(tx) };
|
const db: any = { ...tx, serial: async (work: any) => work(tx) };
|
||||||
const c = new SchedulesController(db),
|
const c = new SchedulesBusinessService(db),
|
||||||
r: any = { userId: owner, revealed: false };
|
r: any = { userId: owner, revealed: false };
|
||||||
assert.equal((await c.run(r)).executed, 1);
|
assert.equal((await c.run(r)).executed, 1);
|
||||||
assert.equal(balance, '-2.00000001');
|
assert.equal(balance, '-2.00000001');
|
||||||
@@ -335,7 +340,7 @@ test('calendar replays only the range, preserves fractional totals, and hides in
|
|||||||
$queryRaw: async () => [],
|
$queryRaw: async () => [],
|
||||||
};
|
};
|
||||||
const db: any = { $transaction: async (work: any) => work(tx) };
|
const db: any = { $transaction: async (work: any) => work(tx) };
|
||||||
const c = new CalendarController(db),
|
const c = new CalendarBusinessService(db),
|
||||||
r: any = { userId: 'owner', revealed: false };
|
r: any = { userId: 'owner', revealed: false };
|
||||||
const month = await c.month(r, '2026-09');
|
const month = await c.month(r, '2026-09');
|
||||||
assert.equal(month.items[1].income, '0.01');
|
assert.equal(month.items[1].income, '0.01');
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
type Connection = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
expiresAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
};
|
||||||
|
type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string };
|
||||||
|
type Management = {
|
||||||
|
mcpUrl: string;
|
||||||
|
mode: string;
|
||||||
|
grants: Connection[];
|
||||||
|
operations: Operation[];
|
||||||
|
calls: { id: string; tool: string; status: string; createdAt: string }[];
|
||||||
|
};
|
||||||
|
type Preview = {
|
||||||
|
operationId: string;
|
||||||
|
tool: string;
|
||||||
|
status: string;
|
||||||
|
description: string;
|
||||||
|
impact: unknown;
|
||||||
|
web?: string;
|
||||||
|
sensitive: boolean;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
type Consent = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
redirectUri: string;
|
||||||
|
resource: string;
|
||||||
|
};
|
||||||
|
export function AgentConnections() {
|
||||||
|
const [data, setData] = useState<Management | null>(null),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[message, setMessage] = useState(''),
|
||||||
|
[token, setToken] = useState(''),
|
||||||
|
[preview, setPreview] = useState<Preview | null>(null),
|
||||||
|
[consent, setConsent] = useState<Consent | null>(null),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
const load = async () => setData(await api<Management>('/agent'));
|
||||||
|
const act = async (work: () => Promise<unknown>) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const show = async (id: string) => setPreview(await api<Preview>('/agent/operations/' + id));
|
||||||
|
useEffect(() => {
|
||||||
|
void act(async () => {
|
||||||
|
await load();
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const authorization = params.get('agent_authorization'),
|
||||||
|
operation = params.get('agent_operation');
|
||||||
|
if (authorization) setConsent(await api<Consent>('/agent/authorizations/' + authorization));
|
||||||
|
if (operation) await show(operation);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<section className="panel agent-panel">
|
||||||
|
<h2>连接 Agent</h2>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="danger-text">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{message && <p role="status">{message}</p>}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<p>远程 MCP 地址</p>
|
||||||
|
<code className="agent-address">{data.mcpUrl}</code>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(data.mcpUrl)}
|
||||||
|
>
|
||||||
|
复制地址
|
||||||
|
</button>
|
||||||
|
<p className="muted">
|
||||||
|
支持 Streamable HTTP。OAuth 客户端使用此地址发现授权信息,浏览器登录 WorthPath
|
||||||
|
后审核连接名称、回调地址和权限。访问令牌每小时过期,刷新令牌最多 30
|
||||||
|
天并在使用时轮换。个人令牌适用于支持 Bearer 头的客户端。
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
已验证客户端:官方 TypeScript SDK 1.31.0(OAuth / Bearer)。其他 Agent
|
||||||
|
尚未验证;不会保证任意客户端兼容。
|
||||||
|
</p>
|
||||||
|
<details>
|
||||||
|
<summary>官方 SDK 的已验证接入配置</summary>
|
||||||
|
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void navigator.clipboard.writeText(
|
||||||
|
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
复制本项目诊断客户端命令
|
||||||
|
</button>
|
||||||
|
<p className="muted">
|
||||||
|
先在终端设置 MCP_ACCESS_TOKEN,再运行复制的命令。完整 OAuth 示例及安全存储说明见
|
||||||
|
docs/mcp.md;此配置仅针对官方 SDK 1.31.0。
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = new FormData(e.currentTarget),
|
||||||
|
form = e.currentTarget;
|
||||||
|
void act(async () => {
|
||||||
|
await api('/agent/policy', 'PUT', {
|
||||||
|
mode: f.get('mode'),
|
||||||
|
password: f.get('password'),
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
setMessage('写入策略已保存');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>写入策略</h3>
|
||||||
|
<select name="mode" defaultValue={data.mode} key={data.mode}>
|
||||||
|
<option value="readonly">只读</option>
|
||||||
|
<option value="draft">创建草稿,由网页确认</option>
|
||||||
|
<option value="direct">已授权 write 的连接可直接普通写入</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
placeholder="当前密码"
|
||||||
|
/>
|
||||||
|
<button className="secondary" disabled={busy}>
|
||||||
|
保存策略
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<h3>OAuth 授权与个人令牌</h3>
|
||||||
|
<p className="muted">
|
||||||
|
read 查询;draft 创建草稿;write 按写入策略执行普通写入;sensitive
|
||||||
|
发起敏感操作,仍须网页验证密码。令牌到期可新建并撤销旧令牌。
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget,
|
||||||
|
f = new FormData(form);
|
||||||
|
void act(async () => {
|
||||||
|
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
|
||||||
|
name: f.get('name'),
|
||||||
|
days: Number(f.get('days')),
|
||||||
|
password: f.get('password'),
|
||||||
|
scopes: f.getAll('scope'),
|
||||||
|
});
|
||||||
|
setToken(v.token);
|
||||||
|
form.reset();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="name" required maxLength={100} placeholder="连接名称" />
|
||||||
|
<input
|
||||||
|
name="days"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={90}
|
||||||
|
defaultValue={30}
|
||||||
|
required
|
||||||
|
aria-label="有效天数"
|
||||||
|
/>
|
||||||
|
{['read', 'draft', 'write', 'sensitive'].map((s) => (
|
||||||
|
<label key={s}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="scope"
|
||||||
|
value={s}
|
||||||
|
defaultChecked={s === 'read'}
|
||||||
|
required={s === 'read'}
|
||||||
|
/>
|
||||||
|
{s}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="当前密码"
|
||||||
|
/>
|
||||||
|
<button className="secondary" disabled={busy}>
|
||||||
|
创建个人令牌
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{token && (
|
||||||
|
<div role="status">
|
||||||
|
<p>完整令牌仅显示这一次,请妥善保存。</p>
|
||||||
|
<code className="agent-address">{token}</code>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(token)}
|
||||||
|
>
|
||||||
|
复制令牌
|
||||||
|
</button>
|
||||||
|
<button className="secondary" type="button" onClick={() => setToken('')}>
|
||||||
|
隐藏令牌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3>已授权连接(最近 100 条)</h3>
|
||||||
|
{data.grants.map((g) => (
|
||||||
|
<div key={g.id} className="agent-row">
|
||||||
|
<span>
|
||||||
|
{g.name} · {g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '}
|
||||||
|
{new Date(g.expiresAt).toLocaleString()} · {g.revokedAt ? '已撤销' : ''}
|
||||||
|
</span>
|
||||||
|
{!g.revokedAt && (
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void act(async () => {
|
||||||
|
await api('/agent/connections/' + g.id, 'DELETE');
|
||||||
|
setMessage('连接已撤销');
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<h3>待确认及最近操作(最近 100 条)</h3>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void act(load)}
|
||||||
|
>
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
{data.operations.map((o) => (
|
||||||
|
<div key={o.id} className="agent-row">
|
||||||
|
<span>
|
||||||
|
{o.tool} ·{' '}
|
||||||
|
{o.status === 'pending' && new Date(o.expiresAt) < new Date()
|
||||||
|
? 'expired'
|
||||||
|
: o.status}{' '}
|
||||||
|
· {new Date(o.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void act(() => show(o.id))}
|
||||||
|
>
|
||||||
|
查看影响和结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<h3>最近调用(不保存参数和敏感内容)</h3>
|
||||||
|
{data.calls.map((c) => (
|
||||||
|
<div key={c.id} className="agent-row">
|
||||||
|
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{consent && (
|
||||||
|
<div className="agent-confirm">
|
||||||
|
<h3>审核 OAuth 连接</h3>
|
||||||
|
<p>{consent.name}</p>
|
||||||
|
<p>权限:{consent.scopes.join(', ')}</p>
|
||||||
|
<p>资源:{consent.resource}</p>
|
||||||
|
<p>回调地址:{consent.redirectUri}</p>
|
||||||
|
<p className="muted">只批准你正在连接的客户端;请核对回调地址。</p>
|
||||||
|
{[true, false].map((approve) => (
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
type="button"
|
||||||
|
key={String(approve)}
|
||||||
|
onClick={() =>
|
||||||
|
void act(async () => {
|
||||||
|
const v = await api<{ redirect: string }>(
|
||||||
|
'/agent/authorizations/' + consent.id,
|
||||||
|
'POST',
|
||||||
|
{ approve },
|
||||||
|
);
|
||||||
|
location.assign(v.redirect);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{approve ? '授权此连接' : '拒绝授权'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{preview && (
|
||||||
|
<div className="agent-confirm">
|
||||||
|
<h3>
|
||||||
|
{preview.tool} · {preview.status}
|
||||||
|
</h3>
|
||||||
|
<p>{preview.description}</p>
|
||||||
|
<pre>{JSON.stringify(preview.impact, null, 2)}</pre>
|
||||||
|
{preview.result !== undefined && <pre>{JSON.stringify(preview.result, null, 2)}</pre>}
|
||||||
|
{preview.status === 'pending' && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = new FormData(e.currentTarget),
|
||||||
|
form = e.currentTarget;
|
||||||
|
void act(async () => {
|
||||||
|
await api('/agent/operations/' + preview.operationId, 'POST', {
|
||||||
|
approve: true,
|
||||||
|
...(f.get('password') ? { password: f.get('password') } : {}),
|
||||||
|
...(f.get('username') ? { username: f.get('username') } : {}),
|
||||||
|
...(f.get('newPassword') ? { newPassword: f.get('newPassword') } : {}),
|
||||||
|
...(f.get('confirmation') ? { confirmation: f.get('confirmation') } : {}),
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
await show(preview.operationId);
|
||||||
|
setMessage('操作已提交,Agent 可以查询最终结果');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview.web === 'clear' && (
|
||||||
|
<>
|
||||||
|
<a href="/api/backup" download>
|
||||||
|
先下载当前账号备份
|
||||||
|
</a>
|
||||||
|
<p>确认备份已保存后输入“确定清空”。</p>
|
||||||
|
<input name="confirmation" required placeholder="确定清空" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{preview.web === 'credentials' && (
|
||||||
|
<>
|
||||||
|
<input name="username" placeholder="新账号(可选)" autoComplete="username" />
|
||||||
|
<input
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
minLength={10}
|
||||||
|
maxLength={72}
|
||||||
|
placeholder="新密码(可选)"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{preview.sensitive && (
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="当前密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button className="secondary" disabled={busy}>
|
||||||
|
确认执行上述操作
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void act(async () => {
|
||||||
|
await api('/agent/operations/' + preview.operationId, 'POST', {
|
||||||
|
approve: false,
|
||||||
|
});
|
||||||
|
await show(preview.operationId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
-3
@@ -53,6 +53,7 @@ import { GroupOrderList } from './GroupOrderList';
|
|||||||
import { orderedGroups, mergeGroupOrder } from './group-order';
|
import { orderedGroups, mergeGroupOrder } from './group-order';
|
||||||
import { Calendar } from './Calendar';
|
import { Calendar } from './Calendar';
|
||||||
import { SchedulePanel } from './SchedulePanel';
|
import { SchedulePanel } from './SchedulePanel';
|
||||||
|
import { AgentConnections } from './AgentConnections';
|
||||||
import { IconPicker } from './IconPicker';
|
import { IconPicker } from './IconPicker';
|
||||||
import { MetalPanel } from './MetalPanel';
|
import { MetalPanel } from './MetalPanel';
|
||||||
import { QuickTransfer } from './QuickTransfer';
|
import { QuickTransfer } from './QuickTransfer';
|
||||||
@@ -229,7 +230,12 @@ export default function App() {
|
|||||||
const [user, setUser] = useState<User | null>(null),
|
const [user, setUser] = useState<User | null>(null),
|
||||||
[boot, setBoot] = useState(true),
|
[boot, setBoot] = useState(true),
|
||||||
[register, setRegister] = useState(false),
|
[register, setRegister] = useState(false),
|
||||||
[page, setPage] = useState('overview'),
|
[page, setPage] = useState(
|
||||||
|
new URLSearchParams(location.search).has('agent_authorization') ||
|
||||||
|
new URLSearchParams(location.search).has('agent_operation')
|
||||||
|
? 'settings'
|
||||||
|
: 'overview',
|
||||||
|
),
|
||||||
[positions, setPositions] = useState<Position[]>([]),
|
[positions, setPositions] = useState<Position[]>([]),
|
||||||
[overview, setOverview] = useState<Overview | null>(null),
|
[overview, setOverview] = useState<Overview | null>(null),
|
||||||
[rates, setRates] = useState<Rate[]>([]),
|
[rates, setRates] = useState<Rate[]>([]),
|
||||||
@@ -297,7 +303,12 @@ export default function App() {
|
|||||||
[clearConfirmation, setClearConfirmation] = useState(0);
|
[clearConfirmation, setClearConfirmation] = useState(0);
|
||||||
useToast(error, 'error');
|
useToast(error, 'error');
|
||||||
useToast(success, 'success');
|
useToast(success, 'success');
|
||||||
const [settingsSection, setSettingsSection] = useState('general'),
|
const [settingsSection, setSettingsSection] = useState(
|
||||||
|
new URLSearchParams(location.search).has('agent_authorization') ||
|
||||||
|
new URLSearchParams(location.search).has('agent_operation')
|
||||||
|
? 'agent'
|
||||||
|
: 'general',
|
||||||
|
),
|
||||||
[transfers, setTransfers] = useState<Transfer[]>([]);
|
[transfers, setTransfers] = useState<Transfer[]>([]);
|
||||||
const [historyRows, setHistoryRows] = useState<History[]>([]),
|
const [historyRows, setHistoryRows] = useState<History[]>([]),
|
||||||
[historyCursor, setHistoryCursor] = useState<string | null>(null),
|
[historyCursor, setHistoryCursor] = useState<string | null>(null),
|
||||||
@@ -674,7 +685,11 @@ export default function App() {
|
|||||||
password: f.get('password'),
|
password: f.get('password'),
|
||||||
});
|
});
|
||||||
if (session !== sessionGeneration.current) return;
|
if (session !== sessionGeneration.current) return;
|
||||||
setPage('overview');
|
const agentReturn =
|
||||||
|
new URLSearchParams(location.search).has('agent_authorization') ||
|
||||||
|
new URLSearchParams(location.search).has('agent_operation');
|
||||||
|
setPage(agentReturn ? 'settings' : 'overview');
|
||||||
|
if (agentReturn) setSettingsSection('agent');
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setModal(null);
|
setModal(null);
|
||||||
activityAt.current = Date.now();
|
activityAt.current = Date.now();
|
||||||
@@ -1748,6 +1763,7 @@ export default function App() {
|
|||||||
<nav className="settings-tabs" aria-label={tr('设置分类')}>
|
<nav className="settings-tabs" aria-label={tr('设置分类')}>
|
||||||
{Object.entries({
|
{Object.entries({
|
||||||
general: '常规设置',
|
general: '常规设置',
|
||||||
|
agent: '连接 Agent',
|
||||||
display: '显示与菜单',
|
display: '显示与菜单',
|
||||||
privacy: '登录与隐私',
|
privacy: '登录与隐私',
|
||||||
security: '账号与密码',
|
security: '账号与密码',
|
||||||
@@ -1772,6 +1788,7 @@ export default function App() {
|
|||||||
<IconLibrary />
|
<IconLibrary />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{settingsSection === 'agent' && <AgentConnections />}
|
||||||
{settingsSection === 'security' && (
|
{settingsSection === 'security' && (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>{tr('账号与密码')}</h2>
|
<h2>{tr('账号与密码')}</h2>
|
||||||
|
|||||||
@@ -1827,3 +1827,56 @@ textarea,
|
|||||||
padding-bottom: 300px;
|
padding-bottom: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.agent-panel form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.agent-panel form h3 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.agent-panel input {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.agent-address {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: text;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.agent-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.agent-confirm {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.agent-confirm pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-height: 25rem;
|
||||||
|
overflow: auto;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.agent-panel form > input,
|
||||||
|
.agent-panel form > select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 12rem;
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
}
|
||||||
|
.agent-panel form > label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.agent-panel form > label input[type='checkbox'] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
+246
-2
@@ -1,7 +1,31 @@
|
|||||||
{
|
{
|
||||||
"tableCount": 10,
|
"tableCount": 16,
|
||||||
"columnCount": 106,
|
"columnCount": 150,
|
||||||
"tables": [
|
"tables": [
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"TABLE_COMMENT": "短期 OAuth 授权请求及一次性授权码摘要"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentcall",
|
||||||
|
"TABLE_COMMENT": "MCP 调用审计,不保存参数、密码、令牌或财务内容"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentclient",
|
||||||
|
"TABLE_COMMENT": "OAuth 动态注册客户端元数据"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"TABLE_COMMENT": "用户授权连接、令牌摘要及资源权限"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"TABLE_COMMENT": "Agent 写入草稿、幂等记录及提交结果"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentpolicy",
|
||||||
|
"TABLE_COMMENT": "用户的 Agent 写入策略"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "exchangerate",
|
"TABLE_NAME": "exchangerate",
|
||||||
"TABLE_COMMENT": "用户原币兑本位币的历史汇率"
|
"TABLE_COMMENT": "用户原币兑本位币的历史汇率"
|
||||||
@@ -44,6 +68,226 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"columns": [
|
"columns": [
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"COLUMN_NAME": "id",
|
||||||
|
"COLUMN_COMMENT": "唯一标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"COLUMN_NAME": "userId",
|
||||||
|
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"COLUMN_NAME": "clientId",
|
||||||
|
"COLUMN_COMMENT": "OAuth 客户端标识;个人令牌为空"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"COLUMN_NAME": "parameters",
|
||||||
|
"COLUMN_COMMENT": "具体操作参数;禁止保存密码及令牌"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"COLUMN_NAME": "codeDigest",
|
||||||
|
"COLUMN_COMMENT": "一次性授权码 SHA-256 摘要"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"COLUMN_NAME": "expiresAt",
|
||||||
|
"COLUMN_COMMENT": "到期时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentauthorization",
|
||||||
|
"COLUMN_NAME": "status",
|
||||||
|
"COLUMN_COMMENT": "当前处理状态"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentcall",
|
||||||
|
"COLUMN_NAME": "id",
|
||||||
|
"COLUMN_COMMENT": "审计标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentcall",
|
||||||
|
"COLUMN_NAME": "userId",
|
||||||
|
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentcall",
|
||||||
|
"COLUMN_NAME": "grantId",
|
||||||
|
"COLUMN_COMMENT": "调用所属连接标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentcall",
|
||||||
|
"COLUMN_NAME": "tool",
|
||||||
|
"COLUMN_COMMENT": "调用的工具名称"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentcall",
|
||||||
|
"COLUMN_NAME": "status",
|
||||||
|
"COLUMN_COMMENT": "结果状态:success 或 error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentcall",
|
||||||
|
"COLUMN_NAME": "createdAt",
|
||||||
|
"COLUMN_COMMENT": "调用完成时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentclient",
|
||||||
|
"COLUMN_NAME": "id",
|
||||||
|
"COLUMN_COMMENT": "唯一标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentclient",
|
||||||
|
"COLUMN_NAME": "metadata",
|
||||||
|
"COLUMN_COMMENT": "公开客户端注册元数据,不保存用户凭据"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentclient",
|
||||||
|
"COLUMN_NAME": "createdAt",
|
||||||
|
"COLUMN_COMMENT": "创建时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "id",
|
||||||
|
"COLUMN_COMMENT": "唯一标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "userId",
|
||||||
|
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "clientId",
|
||||||
|
"COLUMN_COMMENT": "OAuth 客户端标识;个人令牌为空"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "name",
|
||||||
|
"COLUMN_COMMENT": "用户可见连接名称"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "scopes",
|
||||||
|
"COLUMN_COMMENT": "权限列表:read、draft、write、sensitive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "resource",
|
||||||
|
"COLUMN_COMMENT": "令牌适用的规范 MCP 资源地址"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "accessDigest",
|
||||||
|
"COLUMN_COMMENT": "访问令牌 SHA-256 摘要;完整值仅颁发时返回"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "refreshDigest",
|
||||||
|
"COLUMN_COMMENT": "刷新令牌 SHA-256 摘要,使用后轮换"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "expiresAt",
|
||||||
|
"COLUMN_COMMENT": "到期时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "refreshExpiresAt",
|
||||||
|
"COLUMN_COMMENT": "刷新授权到期时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "revokedAt",
|
||||||
|
"COLUMN_COMMENT": "撤销时间,UTC;空表示未撤销"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "sessionId",
|
||||||
|
"COLUMN_COMMENT": "本连接的独立业务授权会话标识,不作为 MCP 凭证"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentgrant",
|
||||||
|
"COLUMN_NAME": "createdAt",
|
||||||
|
"COLUMN_COMMENT": "创建时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "id",
|
||||||
|
"COLUMN_COMMENT": "唯一标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "userId",
|
||||||
|
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "grantId",
|
||||||
|
"COLUMN_COMMENT": "发起操作的授权连接标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "key",
|
||||||
|
"COLUMN_COMMENT": "同用户唯一的客户端幂等键"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "hash",
|
||||||
|
"COLUMN_COMMENT": "工具和参数的规范摘要;拒绝同键不同参数"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "tool",
|
||||||
|
"COLUMN_COMMENT": "业务工具名称"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "parameters",
|
||||||
|
"COLUMN_COMMENT": "具体操作参数;禁止保存密码及令牌"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "snapshot",
|
||||||
|
"COLUMN_COMMENT": "确认前的账目版本摘要,防止覆盖并发修改"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "status",
|
||||||
|
"COLUMN_COMMENT": "当前处理状态"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "expiresAt",
|
||||||
|
"COLUMN_COMMENT": "到期时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "result",
|
||||||
|
"COLUMN_COMMENT": "结构化操作结果或最近提交失败原因"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "createdAt",
|
||||||
|
"COLUMN_COMMENT": "创建时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentoperation",
|
||||||
|
"COLUMN_NAME": "completedAt",
|
||||||
|
"COLUMN_COMMENT": "完成或取消时间,UTC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentpolicy",
|
||||||
|
"COLUMN_NAME": "userId",
|
||||||
|
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "agentpolicy",
|
||||||
|
"COLUMN_NAME": "mode",
|
||||||
|
"COLUMN_COMMENT": "写入策略:readonly 只读、draft 草稿、direct 普通直接写入"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "exchangerate",
|
"TABLE_NAME": "exchangerate",
|
||||||
"COLUMN_NAME": "id",
|
"COLUMN_NAME": "id",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 现有功能 → MCP 覆盖与验证矩阵
|
||||||
|
|
||||||
|
盘点来源:App.tsx、Calendar、SchedulePanel、MetalPanel、IconLibrary、TransferForm、DebtPaymentForm、GroupOrderList 及所有 API Controller 路由。架构沿用 NestJS/Express/Prisma/MySQL/Zod,不新增消费账本或 AI 框架。
|
||||||
|
|
||||||
|
验证记号:**M** `test/mcp.test.ts` 第一组真实官方 SDK/MySQL;**C** 第二组并发/图标/密码修改;**O** 第三组 OAuth;**R** 原 17 组真实 REST/MySQL 回归;**U** 原单元计算/行情/ZIP 测试。均有实际业务断言。M 中公共刷新验证无需要更新资产时的真实调用;外部行情响应和失败回退由 U 验证,未声称行情供应商稳定可用。
|
||||||
|
|
||||||
|
普通写入需要 draft 或 write,且受用户 readonly/draft/direct 策略约束;敏感操作额外要求 sensitive 并强制网页确认。所有写入工具要求幂等键和 expectedState。每项都先检查可信用户归属;annotations 不替代权限。
|
||||||
|
|
||||||
|
| 现有页面/API 与功能 | MCP 操作路径 | 权限 | 确认方式 | 验证 |
|
||||||
|
| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------- | --------------------------------------------------------- |
|
||||||
|
| 账户/独立资产/债务列表 GET positions | positions_list:kind、名称、归档、方向、币种、分组、排序、offset/limit | read | 无 | M:查询、重名、分页和外用户隔离 |
|
||||||
|
| 项目详情 GET positions/:id | position_get | read | 隐藏项目须连接独立网页解锁 | M:当前余额、隐藏/外用户拒绝 |
|
||||||
|
| 创建项目 POST positions | position_create,保留账户/资产/债务初始绝对余额语义 | draft/write | 策略决定草稿或直接 | M/C:3 类对象、重复调用、无效金额、并发 |
|
||||||
|
| 元数据、分类、分组、图标、归档/恢复、隐藏、计入开关 PATCH positions/:id | position_update,完整 metadata;币种与方向固定 | draft/write | 策略 | M:分组、归档禁止金额更新与恢复;R:隐藏和开关 |
|
||||||
|
| 分组拖拽排序 PATCH settings | settings_update.accountGroupOrder | draft/write | 策略 | M/R:持久化、排序、备份 |
|
||||||
|
| 类别选项、名称与分组配置 | position_create/update;positions_list 读取现有值,Schema/描述提供规则 | read + draft/write | 策略 | M/R;当前没有独立分类/分组实体 CRUD |
|
||||||
|
| 新增余额、资产估值、负债余额及单边还款 POST revisions | balance_record,reason 区分;amount 为变更后余额 | draft/write | 策略 | M:精确余额;R/U:负债、估值、业务时间 |
|
||||||
|
| 历史全局/单项目 GET history、positions/:id/history | history_list.positionId、cursor、from/to | read | 无 | M/R:页大小、游标、前序余额、日期范围 |
|
||||||
|
| 更正普通历史 PUT revisions | history_update | draft/write | 策略 | M/R:重放、精度、日历联动 |
|
||||||
|
| 删除/撤销历史 DELETE revisions | history_delete;配对记录删除完整双边 | draft/write | 策略,destructive annotation | M/R:余额重算及双边撤销 |
|
||||||
|
| 债务关联账户/资产 PUT links | debt_links_set.targetIds | draft/write | 策略 | M:合法关联、隐藏目标拒绝;R:外用户拒绝 |
|
||||||
|
| 资金往来列表 GET transfers | movements_list:cursor、日期过滤 | read | 无 | M/R |
|
||||||
|
| 按历史找双边记录 GET transfers/revision/:revisionId | movement_by_revision | read | 无 | M/R |
|
||||||
|
| 转账、借入、借出、收款、还款 POST transfers | movement_create.operation:transfer/borrow/lend/collect/repay | draft/write | 策略,双边事务 | M:转账、repay、信用卡溢缴;R:全部 5 种语义;C:错误回滚 |
|
||||||
|
| 修改配对记录 PUT transfers/:id | movement_update | draft/write | 策略,后续历史重放 | M/R |
|
||||||
|
| 删除/撤销配对记录 DELETE transfers/:id | movement_delete | draft/write | 策略,双边撤销 | M/R |
|
||||||
|
| 净资产、总额、结构、变化归因 GET overview | overview_get,明细分页、保留全局总额 | read | 隐藏项目按本连接可见范围 | M/R/U:总额、开关、外币缺失 |
|
||||||
|
| 轨迹、日期与统计粒度 GET trend | trend_get:day/week/month 及 from/to | read | 无 | M/R/U:范围、首日、期末、归因 |
|
||||||
|
| 本位币、币种、实际汇率与更新状态 GET settings | settings_get,固定币种枚举且金额无浮点计算 | read | 无 | M/R/U |
|
||||||
|
| 本位币切换、显示菜单、备注、闲置退出、登录时长、总览卡片、独立资产计入 PATCH settings | settings_update | draft/write | 策略 | M/R |
|
||||||
|
| 是否查看隐藏资产需密码 PATCH settings | settings_update.requireHiddenPassword | sensitive | 强制网页密码确认 | M:draft 权限无法改变;R:会话锁定规则 |
|
||||||
|
| 自动/重试日汇率 POST rates/refresh | rates_refresh;已有后台定时更新保留 | draft/write | 策略 | M:真实工具调用;U:成功解析、手工优先、失败保留 |
|
||||||
|
| 金银报价与状态 GET metals | metals_prices,最近 100 条 | read | 无;查询 MCP 不触发隐式自动更新 | M/U/R |
|
||||||
|
| 金银报价刷新 POST metals/refresh | metals_refresh | draft/write | 策略,沿用自动估值 | M:无配置资产调用;U:外部响应和失败回退 |
|
||||||
|
| 每克报价手动回退 POST metals/prices | metal_price_set,Decimal 价格 | draft/write | 策略 | M/R/U:手动优先、精确估值 |
|
||||||
|
| 克数、品种、纯度、自动估价 PUT metals/:id | metal_configure | draft/write | 策略 | M/R/U:估值历史和去重 |
|
||||||
|
| 应用估价 POST metals/:id/value | metal_value | draft/write | 策略 | M/R/U |
|
||||||
|
| 收支月日历 GET calendar | calendar_month,YYYY-MM | read | 无 | M/R/U |
|
||||||
|
| 单日日历明细 GET calendar/day | calendar_day,业务日和分页 | read | 无 | M/R/U:历史修改、转账排除、Decimal |
|
||||||
|
| 计划列表/状态 GET schedules | schedules_list,按到期排序分页;history_list 查看执行历史 | read | 无 | M/R |
|
||||||
|
| 新建/修改支出或转账计划 POST/PUT schedules | schedule_create/update | draft/write | 策略 | M/R:真实执行及编辑限制 |
|
||||||
|
| 启用/停用 PATCH schedules/:id | schedule_toggle | draft/write | 策略 | M/R |
|
||||||
|
| 删除计划 DELETE schedules/:id | schedule_delete,保留执行历史 | draft/write | 策略 | M/R |
|
||||||
|
| 按需运行到期计划 POST schedules/run | schedules_run,最多 20 项、hasMore;MCP 失败整批回滚 | draft/write | 策略,幂等事务 | M/C/R/U |
|
||||||
|
| 登录个人资料 GET auth/me | settings_get;connection_info 提供 Agent 授权资料 | read | 无 | M/C |
|
||||||
|
| 修改用户名和密码 PATCH auth/credentials | credentials_change_request → 网页 → operation_get | sensitive | 网站验证当前密码、输入新值,Agent 不收到密码 | C/R:真实密码登录、原会话轮换、Agent 降权结果查询 |
|
||||||
|
| 隐藏资产密码核验 POST auth/reveal | hidden_unlock_request → 网页 → operation_get | sensitive | 网页当前密码,当前连接 5 分钟 | M/R:锁定、隔离、失效规则 |
|
||||||
|
| 锁定隐藏资产 POST auth/lock | hidden_lock | draft/write | 策略;也可网页锁定 | M/R |
|
||||||
|
| 退出登录 POST auth/logout | connection_revoke 退出当前 MCP 连接;网页退出仍在原页面 | 当前连接认证 | 撤销是即时缩小授权,不受写入策略阻碍 | M/O/R:后续 401 |
|
||||||
|
| 注册/登录、网页 activity | OAuth 网页入口复用原注册/登录,Agent 令牌不依赖网页 Cookie;activity 为网页会话维护 | 网页认证/OAuth | 用户登录或注册及授权 | O/R、浏览器验证管理入口 |
|
||||||
|
| 图标搜索 GET icons | icons_list,60/页、q | read | 无 | M/C/R |
|
||||||
|
| 图标图片 GET icons/:id/image | icon_image → 同连接 Bearer GET | read | 无 | C/R:PNG 和归属 |
|
||||||
|
| 上传图标 POST icons/upload | file_upload_request(kind=icon) → POST file → icon_publish | draft/write;共享须 sensitive | 私有按策略;共享网页确认及中文名 | C/R:私有和共享实际保存 |
|
||||||
|
| 导出备份 GET backup | backup_export → 网页 → operation_get → Bearer 下载 | sensitive | 网页密码,短期授权入口 | M/R:完整 ZIP、隐藏数据、令牌排除、跨用户及未登录拒绝 |
|
||||||
|
| 上传备份 POST backup/upload | file_upload_request(kind=backup) → Bearer multipart → file_status | draft/write | 用户控制上传;不提交账目 | M/R:真实 ZIP 传输 |
|
||||||
|
| 预检 POST backup/preview | import_preview(token),上传同时预检 | read(上传需要 draft/write) | 不修改账目 | M/R/U:结构/校验/重复/冲突 |
|
||||||
|
| 追加恢复 POST backup/import、import-file | backup_import(token) → 网页 → operation_get | sensitive | 强制网页密码及数量/冲突预览 | M/R:真实恢复、重复冲突不改变数据 |
|
||||||
|
| 清空资格 GET backup/clear-status | data_clear_request 网页确认时复用现有备份资格及指纹 | sensitive | 网站先下载最新备份 | M/R |
|
||||||
|
| 清空财务数据 POST backup/clear | data_clear_request → 网页 → operation_get | sensitive | 密码、备份指纹及“确定清空” | M/R:未下载拒绝、真实清空、保留账号 |
|
||||||
|
| 新增接入管理、权限、PAT、撤销、调用日志和待确认页 | /api/agent 管理;connection_info、operation_get、state_get;OAuth 标准路径 | 网站身份/本连接 Bearer | PAT/策略验证密码;操作归属校验 | M/C/O、真实浏览器管理页 |
|
||||||
|
|
||||||
|
不存在的业务不列作“已实现”:单个持仓永久删除、账户注销、覆盖导入、批量删除、独立分类/分组对象 CRUD 均不在当前页面/API 中。历史删除、资金往来撤销、计划删除、归档及备份保护清空全部已有路径均已覆盖。
|
||||||
|
|
||||||
|
共享服务:PortfolioBusinessService、TransfersBusinessService、SchedulesBusinessService、CalendarBusinessService、SettingsBusinessService、MetalsBusinessService、IconsBusinessService、BackupBusinessService、AuthBusinessService。REST Controller 仅转发并保留原参数、文件拦截器、认证和路由;SDK Catalogue 调用同一服务,不复制金额或重放。
|
||||||
|
|
||||||
|
金额状态、OAuth、scope、Origin/Host、文件生命周期和客户端限制见 [接入文档](mcp.md)。当前官方客户端验证通过;未验证其他产品配置和生产 HTTPS 反向代理。
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
# WorthPath 远程 MCP 接入
|
||||||
|
|
||||||
|
## 开始使用
|
||||||
|
|
||||||
|
网站「设置与备份 → 连接 Agent」显示实际 MCP 地址、写入策略、OAuth 连接、个人令牌、待确认操作和最近调用。默认写入策略为草稿。现有网页登录、REST 和数据计算规则保留。
|
||||||
|
|
||||||
|
1. 支持远程 OAuth 的客户端填写页面显示的 MCP 地址,选择 Streamable HTTP。
|
||||||
|
2. 客户端通过 `401 WWW-Authenticate` 或 `/.well-known/oauth-protected-resource/mcp` 发现授权服务。支持 OAuth 2.1 授权码、S256 PKCE、RFC 8707 resource、动态注册的公开客户端。
|
||||||
|
3. 浏览器进入 WorthPath,登录或注册,核对客户端名称、回调地址、资源和权限后授权。网站会回到注册的精确回调地址并保留 state。
|
||||||
|
4. 只请求需要的权限:`read` 查询;`draft` 创建草稿;`write` 普通写入;`sensitive` 发起敏感流程。所有授权包含 read。敏感权限仍要求网页验证当前密码,不能用工具参数确认。
|
||||||
|
5. 查询用稳定 UUID。写入先调用 `state_get`,把返回的 state 作为 `expectedState`,同时提供同用户唯一 `idempotencyKey`。状态变化后重新查询并使用新键;网络重试必须保持同键、同参数、同 expectedState。
|
||||||
|
6. 返回 pending 时打开 `confirmationUrl`,用户审核影响并提交或取消。Agent 使用 `operation_get(operationId)` 查询 completed、cancelled、expired 及最终结果。提交失败保留草稿并返回最近失败原因,用户可以重试或取消。
|
||||||
|
|
||||||
|
密码只在网站填写。账号或密码修改后其他连接撤销,当前发起连接只保留 5 分钟 read 权限以读取完成结果,不可刷新;需要重新授权才能继续写入。隐藏资产解锁仅持续 5 分钟并限定当前连接,网站的解锁不自动授予 Agent。
|
||||||
|
|
||||||
|
## 开发启动与环境
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\WorthPath
|
||||||
|
.\scripts\pnpm.ps1 install
|
||||||
|
# 保留已有 apps/api/.env,按 .env.example 增加下面三项配置。
|
||||||
|
.\scripts\pnpm.ps1 db:generate
|
||||||
|
.\scripts\pnpm.ps1 db:migrate
|
||||||
|
.\scripts\pnpm.ps1 dev
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 Prisma 前暂停 API,Windows 会锁定其引擎 DLL。迁移仅 deploy,不 reset。
|
||||||
|
|
||||||
|
| 变量 | 本地示例 | 用途 |
|
||||||
|
| ------------------- | ------------------------- | ---------------------------------------------------- |
|
||||||
|
| MCP_PUBLIC_URL | http://localhost:3100/mcp | 固定的 OAuth resource;生产必须 HTTPS,路径必须 /mcp |
|
||||||
|
| MCP_WEB_URL | http://localhost:5173 | 网页授权和操作确认入口;生产必须 HTTPS |
|
||||||
|
| MCP_ALLOWED_ORIGINS | http://localhost:5173 | 精确浏览器 Origin 列表,逗号分隔,不支持通配符 |
|
||||||
|
|
||||||
|
生产继续要求 COOKIE_SECURE=true、准确的 WEB_ORIGIN。MCP 请求以独立 Bearer 验证,不接受网页登录 Cookie 作为授权,不把会话 ID 当凭证。未带 Origin 的非浏览器客户端允许接入;带 Origin 的请求必须匹配列表。Host 必须匹配规范资源地址。反向代理应传递规范的 Host,不以任意转发头构造资源 URL。
|
||||||
|
|
||||||
|
## 已验证的客户端配置
|
||||||
|
|
||||||
|
实际验证:官方 `@modelcontextprotocol/sdk@1.31.0`,StreamableHTTPClientTransport;Bearer PAT 和 OAuth 动态注册/发现/PKCE/刷新/撤销均经真实本地服务测试。服务协商 SDK 1.31.0 支持的协议,默认最新 `2025-11-25`。选择维护中的 v1 API 并精确锁定版本,未混用 v2 示例。[官方 SDK](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x)、[对应授权规范](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)。
|
||||||
|
|
||||||
|
下面是测试使用的官方 SDK 构造形式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
const client = new Client({ name: 'WorthPath client', version: '1.0.0' });
|
||||||
|
await client.connect(
|
||||||
|
new StreamableHTTPClientTransport(new URL('http://localhost:3100/mcp'), {
|
||||||
|
requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(await client.listTools());
|
||||||
|
await client.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
本项目提供只读协议诊断客户端,完整个人令牌在网站创建时仅展示一次:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MCP_SERVER_URL='http://localhost:3100/mcp'
|
||||||
|
# 在本机终端设置 MCP_ACCESS_TOKEN;不要写进仓库或聊天。
|
||||||
|
pnpm --filter @worthpath/api mcp:probe
|
||||||
|
Remove-Item Env:MCP_ACCESS_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
OAuth 客户端使用 `authProvider` 代替手工 Bearer 头;实现 SDK 的 OAuthClientProvider,在 `redirectToAuthorization` 打开浏览器,在回调后调用 transport.finishAuth(code) 再连接。真实的 provider、发现和交换实现见 `apps/api/test/mcp.test.ts` 第三组测试。生产客户端还应独立校验 state,安全持久化 verifier、客户端注册和令牌,禁止跨用户共享。WorthPath 返回原 state,但客户端必须自行验证。
|
||||||
|
|
||||||
|
没有验证 MCP Inspector、Claude、ChatGPT、Cursor、Codex 或其他产品的实际 OAuth/config 格式,不提供猜测配置或普遍兼容承诺。HTTPS 代理环境也未实际部署验证。
|
||||||
|
|
||||||
|
## 写入、权限和一致性
|
||||||
|
|
||||||
|
| 用户策略 | draft 权限 | write 权限 | sensitive 权限 |
|
||||||
|
| ------------- | ------------- | ------------- | -------------------------------------------------- |
|
||||||
|
| readonly | 拒绝写入/上传 | 拒绝写入/上传 | 仅允许网页确认的完整备份导出和隐藏解锁,不修改账目 |
|
||||||
|
| draft(默认) | 保存草稿 | 保存草稿 | 强制网页确认 |
|
||||||
|
| direct | 保存草稿 | 执行普通写入 | 强制网页确认 |
|
||||||
|
|
||||||
|
草稿 10 分钟到期,保存用户、连接、具体参数、账目摘要和状态。提交重新检查连接过期/撤销、scope、当前策略、账目状态、数据归属及业务规则。`confirmed=true` 被严格 Schema 拒绝。修改隐藏密码要求的设置、发布共享图标也升级为敏感确认。
|
||||||
|
|
||||||
|
幂等记录和业务变更在同一 Serializable 数据库事务提交,用户唯一键保证重试不重复入账。同键不同参数或不同连接明确拒绝。所有普通直接写入和草稿提交通过状态摘要防止覆盖 REST 或其他 Agent 的并发修改。状态包括全部用户账目、历史、计划、汇率、金属报价及相关设置,只返回摘要,不返回隐藏数据。
|
||||||
|
|
||||||
|
复用现有 Decimal、余额计算、转账和历史重放。AsyncLocalStorage 仅保存当前数据库事务,认证信息来自每请求的 Bearer 上下文。现有服务中的嵌套事务加入 MCP 外层事务;MCP 计划执行任一失败会回滚整批,REST 仍保留逐计划执行和失败列表语义。
|
||||||
|
|
||||||
|
金额为十进制字符串,现有 DECIMAL(24,8) 和报价/汇率 DECIMAL(24,12) 保留。`balance_record.amount` 为绝对余额;转账本金/到账/手续费是增量。负债账户数据库正数为欠款,负数为溢缴存款;网站显示相反符号。独立资产和借贷本金非负。业务日期 `YYYY-MM-DD` 或 `YYYY-MM-DDTHH:mm` 为 UTC+8;返回时间戳为 UTC ISO。计划 nextAt 可在未来,余额记录日期不可在未来。
|
||||||
|
|
||||||
|
查询页 limit 最多 100;图标固定 60;历史和资金往来使用 cursor,列表使用 offset。净资产轨迹沿用范围上限:日 366 天、周 3 年、月 10 年。金属和汇率返回最近 100 条。大备份通过文件入口传输。
|
||||||
|
|
||||||
|
## 备份、图标和敏感操作
|
||||||
|
|
||||||
|
备份追加:`file_upload_request(kind=backup)` → 使用返回 URL 和当前 Bearer 以 multipart/form-data 的 file 字段上传 ZIP/旧 JSON → 获取 token 或 `file_status` → `import_preview` → `backup_import` → 网页核对影响并验证密码 → `operation_get`。文件上传上限 512 MiB,ZIP 解压上限沿用现有 1 GiB;实际预检与追加恢复沿用现有严格校验和事务,任何冲突不修改账目。
|
||||||
|
|
||||||
|
完整导出:`backup_export` → 网页密码确认 → `operation_get` 获得 10 分钟有效的 GET URL → 同一连接 Bearer 下载 ZIP。URL 单独不可下载,其他用户/连接不可下载,账目变化必须重新确认。备份包含隐藏项目,但不含密码、Cookie 或 MCP 令牌。Agent 授权、草稿和调用日志不属于财务备份。
|
||||||
|
|
||||||
|
图标:`file_upload_request(kind=icon)` 上传不超过 2 MiB 的图像 → `icon_publish` 保存私有图标;shared=true 需敏感权限、中文名称、网页明确确认 → `icon_image` 获取受 Bearer 保护的 PNG URL。
|
||||||
|
|
||||||
|
清空数据:`data_clear_request` → 网页展示项目/历史/计划数量 → 先在网页下载最新备份 → 验证当前密码并输入“确定清空” → 提交 → `operation_get`。已有备份指纹与 10 分钟有效期规则保留,任何账目变化都要求重新备份。登录账号和共享图标保留。
|
||||||
|
|
||||||
|
当前产品不存在账户注销、任意单个持仓删除、覆盖导入、批量删除和另外的分类实体;因此不新增此类业务。已有删除路径是历史、资金往来、计划删除及备份保护的全量清空,完整支持。名称、分组、分类、图标、归档和统计开关通过 position_update/settings_update 设置。完整覆盖和逐项证据见 [覆盖矩阵](mcp-coverage.md)。
|
||||||
|
|
||||||
|
## 服务和代理
|
||||||
|
|
||||||
|
采用无状态 Streamable HTTP:每个 POST 新建 server/transport,独立 Bearer 认证;关闭响应时释放 transport。响应为 SDK 标准 JSON MCP 结果,支持初始化、协商、工具发现和调用。GET/DELETE 返回 405,没有长期 SSE 会话或会话凭证。当前没有订阅通知需求。
|
||||||
|
|
||||||
|
现有备份上传及 MCP 文件票据在进程内,10–15 分钟过期;进程重启或切换节点后需重新上传/生成入口。**当前部署必须单 API 实例**,不能直接在多节点间轮询文件请求;OAuth、令牌、草稿、幂等和审计本身已持久化。日志不保存工具参数、密码、令牌或财务内容,管理页只展示用户自己的最近 100 条。
|
||||||
|
|
||||||
|
反向代理参考(示例未经实际部署,修改为自己的 HTTPS 域名及上游):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location = /mcp {
|
||||||
|
proxy_pass http://127.0.0.1:3100;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 330s;
|
||||||
|
proxy_send_timeout 330s;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3100;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
client_max_body_size 512m;
|
||||||
|
proxy_read_timeout 330s;
|
||||||
|
}
|
||||||
|
# 同样代理 /.well-known/、/authorize、/token、/register、/revoke。
|
||||||
|
# 前端静态站点与 MCP_WEB_URL 应位于已配置的 HTTPS origin。
|
||||||
|
```
|
||||||
|
|
||||||
|
用户认证数据不进入全局共享变量。SDK OAuth 路由包含进程内限流,现有登录限流也保留,已认证动作按用户及来源 IP 限流。生产单实例应另外配置代理级流量限制和日志保留策略;不得在代理访问日志中记录 Authorization、上传正文或敏感查询参数。数据库账号需要现有 DDL 迁移权限及正常业务 DML 权限。
|
||||||
|
|
||||||
|
## 实际验证与交付
|
||||||
|
|
||||||
|
迁移:`20261003090000_agent_access` 新增授权/令牌/策略/操作 5 表;`20261003100000_agent_audit` 新增无参数调用审计;`20261003110000_agent_comments` 补齐 6 张新表字段的 MySQL 注释。原有财务表与记录保留。已经 migrate deploy,未执行 reset。
|
||||||
|
|
||||||
|
主要修改:原 8 个业务模块及 auth 抽取 BusinessService;database 增加事务上下文;新增 `src/mcp/{oauth,catalogue,operations,files,management,transport}.ts`;设置页新增 AgentConnections;SDK 精确版本和 pnpm lock;新增真实 MCP 测试及只读 probe。
|
||||||
|
|
||||||
|
实际通过:前后端 typecheck/build、35 项原单元检查、17 组真实 REST/MySQL 回归、3 组真实官方 MCP SDK 端到端检查,以及 mcp:probe(发现 48 个工具)。新迁移 deploy 成功,实际 MySQL 16 张应用表、150 个字段注释完整。测试真实连接 MySQL,使用随机临时用户并清理,未替代现有 REST 回归。端到端文件传输、事务回滚、归属、金额精度、幂等、并发、失效/撤销和 OAuth 均在测试中检查;外部行情源的正常和失败路径由原业务单元测试验证,不保证外部报价服务实时可用。
|
||||||
|
|
||||||
|
部署未执行;未推送代码;其他客户端及实际 HTTPS/代理兼容性尚未验证。
|
||||||
Generated
+197
@@ -14,6 +14,9 @@ importers:
|
|||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@modelcontextprotocol/sdk':
|
||||||
|
specifier: 1.31.0
|
||||||
|
version: 1.31.0(zod@4.6.5)
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -378,6 +381,12 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@hono/node-server@2.1.3':
|
||||||
|
resolution: {integrity: sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
peerDependencies:
|
||||||
|
hono: ^4
|
||||||
|
|
||||||
'@img/colour@1.1.0':
|
'@img/colour@1.1.0':
|
||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -563,6 +572,16 @@ packages:
|
|||||||
'@microsoft/tsdoc@0.16.0':
|
'@microsoft/tsdoc@0.16.0':
|
||||||
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||||
|
|
||||||
|
'@modelcontextprotocol/sdk@1.31.0':
|
||||||
|
resolution: {integrity: sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@cfworker/json-schema': ^4.1.1
|
||||||
|
zod: ^3.25 || ^4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@cfworker/json-schema':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
@@ -905,6 +924,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
ajv-formats@3.0.1:
|
||||||
|
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||||
|
peerDependencies:
|
||||||
|
ajv: ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ajv:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||||
|
|
||||||
append-field@1.0.0:
|
append-field@1.0.0:
|
||||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||||
|
|
||||||
@@ -1100,6 +1130,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
|
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
@@ -1201,6 +1235,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
engines: {node: '>=0.8.x'}
|
engines: {node: '>=0.8.x'}
|
||||||
|
|
||||||
|
eventsource-parser@3.1.1:
|
||||||
|
resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
eventsource@3.0.7:
|
||||||
|
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
express-rate-limit@8.7.0:
|
||||||
|
resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
peerDependencies:
|
||||||
|
express: '>= 4.11'
|
||||||
|
|
||||||
express@5.1.0:
|
express@5.1.0:
|
||||||
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@@ -1216,12 +1264,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3:
|
||||||
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
fast-fifo@1.3.2:
|
fast-fifo@1.3.2:
|
||||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||||
|
|
||||||
fast-safe-stringify@2.1.1:
|
fast-safe-stringify@2.1.1:
|
||||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||||
|
|
||||||
|
fast-uri@3.1.8:
|
||||||
|
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1290,6 +1344,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
|
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
hono@4.13.12:
|
||||||
|
resolution: {integrity: sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==}
|
||||||
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1304,6 +1362,10 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ip-address@10.7.3:
|
||||||
|
resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -1321,6 +1383,9 @@ packages:
|
|||||||
isarray@1.0.0:
|
isarray@1.0.0:
|
||||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
|
isexe@2.0.0:
|
||||||
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
iterare@1.2.1:
|
iterare@1.2.1:
|
||||||
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
|
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1329,6 +1394,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.2.12:
|
||||||
|
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -1341,6 +1409,12 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0:
|
||||||
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
|
json-schema-typed@8.0.2:
|
||||||
|
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||||
|
|
||||||
json5@2.2.3:
|
json5@2.2.3:
|
||||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1475,6 +1549,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
path-key@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
path-to-regexp@8.3.0:
|
path-to-regexp@8.3.0:
|
||||||
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
||||||
|
|
||||||
@@ -1497,6 +1575,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pkce-challenge@5.0.1:
|
||||||
|
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
|
||||||
pkg-types@2.3.3:
|
pkg-types@2.3.3:
|
||||||
resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==}
|
resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==}
|
||||||
|
|
||||||
@@ -1579,6 +1661,10 @@ packages:
|
|||||||
reflect-metadata@0.2.2:
|
reflect-metadata@0.2.2:
|
||||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
|
require-from-string@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
rollup@4.63.5:
|
rollup@4.63.5:
|
||||||
resolution: {integrity: sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==}
|
resolution: {integrity: sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
@@ -1632,6 +1718,14 @@ packages:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
shebang-regex@3.0.0:
|
||||||
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
side-channel-list@1.0.1:
|
side-channel-list@1.0.1:
|
||||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1794,6 +1888,11 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
@@ -1808,6 +1907,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
|
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
zod-to-json-schema@3.25.2:
|
||||||
|
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.28 || ^4
|
||||||
|
|
||||||
zod@4.6.5:
|
zod@4.6.5:
|
||||||
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||||
|
|
||||||
@@ -2010,6 +2114,10 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.28.2':
|
'@esbuild/win32-x64@0.28.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@hono/node-server@2.1.3(hono@4.13.12)':
|
||||||
|
dependencies:
|
||||||
|
hono: 4.13.12
|
||||||
|
|
||||||
'@img/colour@1.1.0': {}
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.35.5':
|
'@img/sharp-darwin-arm64@0.35.5':
|
||||||
@@ -2139,6 +2247,28 @@ snapshots:
|
|||||||
|
|
||||||
'@microsoft/tsdoc@0.16.0': {}
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
|
'@modelcontextprotocol/sdk@1.31.0(zod@4.6.5)':
|
||||||
|
dependencies:
|
||||||
|
'@hono/node-server': 2.1.3(hono@4.13.12)
|
||||||
|
ajv: 8.20.0
|
||||||
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
|
content-type: 1.0.5
|
||||||
|
cors: 2.8.6
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
eventsource: 3.0.7
|
||||||
|
eventsource-parser: 3.1.1
|
||||||
|
express: 5.2.1
|
||||||
|
express-rate-limit: 8.7.0(express@5.2.1)
|
||||||
|
hono: 4.13.12
|
||||||
|
jose: 6.2.12
|
||||||
|
json-schema-typed: 8.0.2
|
||||||
|
pkce-challenge: 5.0.1
|
||||||
|
raw-body: 3.0.2
|
||||||
|
zod: 4.6.5
|
||||||
|
zod-to-json-schema: 3.25.2(zod@4.6.5)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2435,6 +2565,17 @@ snapshots:
|
|||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
negotiator: 1.1.0
|
negotiator: 1.1.0
|
||||||
|
|
||||||
|
ajv-formats@3.0.1(ajv@8.20.0):
|
||||||
|
optionalDependencies:
|
||||||
|
ajv: 8.20.0
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-uri: 3.1.8
|
||||||
|
json-schema-traverse: 1.0.0
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
append-field@1.0.0: {}
|
append-field@1.0.0: {}
|
||||||
|
|
||||||
archiver@8.0.0:
|
archiver@8.0.0:
|
||||||
@@ -2621,6 +2762,12 @@ snapshots:
|
|||||||
crc-32: 1.2.2
|
crc-32: 1.2.2
|
||||||
readable-stream: 4.7.0
|
readable-stream: 4.7.0
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
path-key: 3.1.1
|
||||||
|
shebang-command: 2.0.0
|
||||||
|
which: 2.0.2
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
@@ -2715,6 +2862,20 @@ snapshots:
|
|||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
||||||
|
eventsource-parser@3.1.1: {}
|
||||||
|
|
||||||
|
eventsource@3.0.7:
|
||||||
|
dependencies:
|
||||||
|
eventsource-parser: 3.1.1
|
||||||
|
|
||||||
|
express-rate-limit@8.7.0(express@5.2.1):
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
express: 5.2.1
|
||||||
|
ip-address: 10.7.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
express@5.1.0:
|
express@5.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
accepts: 2.0.0
|
accepts: 2.0.0
|
||||||
@@ -2786,10 +2947,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
pure-rand: 6.1.0
|
pure-rand: 6.1.0
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-fifo@1.3.2: {}
|
fast-fifo@1.3.2: {}
|
||||||
|
|
||||||
fast-safe-stringify@2.1.1: {}
|
fast-safe-stringify@2.1.1: {}
|
||||||
|
|
||||||
|
fast-uri@3.1.8: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.7):
|
fdir@6.5.0(picomatch@4.0.7):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
@@ -2866,6 +3031,8 @@ snapshots:
|
|||||||
|
|
||||||
helmet@8.3.0: {}
|
helmet@8.3.0: {}
|
||||||
|
|
||||||
|
hono@4.13.12: {}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -2882,6 +3049,8 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ip-address@10.7.3: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-promise@4.0.0: {}
|
is-promise@4.0.0: {}
|
||||||
@@ -2892,10 +3061,14 @@ snapshots:
|
|||||||
|
|
||||||
isarray@1.0.0: {}
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
iterare@1.2.1: {}
|
iterare@1.2.1: {}
|
||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jose@6.2.12: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -2904,6 +3077,10 @@ snapshots:
|
|||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0: {}
|
||||||
|
|
||||||
|
json-schema-typed@8.0.2: {}
|
||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
lazystream@1.0.1:
|
lazystream@1.0.1:
|
||||||
@@ -3009,6 +3186,8 @@ snapshots:
|
|||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
path-to-regexp@8.3.0: {}
|
path-to-regexp@8.3.0: {}
|
||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
@@ -3023,6 +3202,8 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.7: {}
|
picomatch@4.0.7: {}
|
||||||
|
|
||||||
|
pkce-challenge@5.0.1: {}
|
||||||
|
|
||||||
pkg-types@2.3.3:
|
pkg-types@2.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
confbox: 0.3.1
|
confbox: 0.3.1
|
||||||
@@ -3111,6 +3292,8 @@ snapshots:
|
|||||||
|
|
||||||
reflect-metadata@0.2.2: {}
|
reflect-metadata@0.2.2: {}
|
||||||
|
|
||||||
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
rollup@4.63.5:
|
rollup@4.63.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -3229,6 +3412,12 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64': 0.35.5
|
'@img/sharp-win32-x64': 0.35.5
|
||||||
'@types/node': 24.19.0
|
'@types/node': 24.19.0
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
shebang-regex: 3.0.0
|
||||||
|
|
||||||
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
side-channel-list@1.0.1:
|
side-channel-list@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -3384,6 +3573,10 @@ snapshots:
|
|||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.23.15
|
tsx: 4.23.15
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
isexe: 2.0.0
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
@@ -3398,4 +3591,8 @@ snapshots:
|
|||||||
normalize-path: 3.0.0
|
normalize-path: 3.0.0
|
||||||
readable-stream: 4.7.0
|
readable-stream: 4.7.0
|
||||||
|
|
||||||
|
zod-to-json-schema@3.25.2(zod@4.6.5):
|
||||||
|
dependencies:
|
||||||
|
zod: 4.6.5
|
||||||
|
|
||||||
zod@4.6.5: {}
|
zod@4.6.5: {}
|
||||||
Reference in new issue
Block a user