diff --git a/README.md b/README.md index 9a7b92a..003fe15 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的 设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。 -备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons、transfers 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。 +备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons、transfers 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;仅接受当前 ZIP v9,所有 JSON 备份和旧 ZIP 均不支持。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。 内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。 @@ -55,7 +55,7 @@ ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对 账户页面和账户详情提供“账户间转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。 -债务分为借入(应付负债)和借出(应收资产),支持借入到账、借出付款、收回应收和偿还应付。账户与债务在同一事务中记账并自动关联;双方详情和往来记录可互相导航。已有债务可录入剩余余额,新发生借贷可先建零余额债务再使用联动操作。ZIP v6 保留操作类型,兼容旧 v3/v4/v5 ZIP 和 v1/v2 JSON。 +债务分为借入(应付负债)和借出(应收资产),支持借入到账、借出付款、收回应收和偿还应付。账户与债务在同一事务中记账并自动关联;双方详情和往来记录可互相导航。已有债务可录入剩余余额,新发生借贷可先建零余额债务再使用联动操作。当前 ZIP v9 保留操作类型,不保留任何旧备份兼容。 支持简中、English、繁中,在登录页或顶部切换,语言保存在当前浏览器。用户名称和备注保留原文。账户/资产/债务按页查询当前余额,历史和往来使用最多 100 条的游标分页;趋势默认最近 90 天,支持日、周、月。查询实现、迁移步骤、实测性能和验证边界见 [性能与联动验收](docs/performance.md)。 diff --git a/apps/api/prisma/migrations/20261004090000_agent_permanent_tokens/migration.sql b/apps/api/prisma/migrations/20261004090000_agent_permanent_tokens/migration.sql new file mode 100644 index 0000000..f6a7c64 --- /dev/null +++ b/apps/api/prisma/migrations/20261004090000_agent_permanent_tokens/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `AgentGrant` MODIFY `expiresAt` DATETIME(3) NULL COMMENT '访问令牌到期时间,UTC;空表示可撤销的永久个人令牌'; diff --git a/apps/api/prisma/migrations/20261004103000_agent_connection_permissions/migration.sql b/apps/api/prisma/migrations/20261004103000_agent_connection_permissions/migration.sql new file mode 100644 index 0000000..a18a812 --- /dev/null +++ b/apps/api/prisma/migrations/20261004103000_agent_connection_permissions/migration.sql @@ -0,0 +1,3 @@ +-- Metadata only. Preserve existing policy rows; the application no longer reads them. +ALTER TABLE `AgentPolicy` COMMENT = '已停用的历史 Agent 策略数据,保留原数据但不参与权限判定'; +ALTER TABLE `AgentGrant` MODIFY `scopes` JSON NOT NULL COMMENT '权限列表:read、draft 或 write;隐藏账户附加权限 hidden_read、hidden_write'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9b90188..16cadbd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -68,7 +68,7 @@ model AgentCall { @@index([userId,createdAt]) } -/// 用户的 Agent 写入策略 +/// 已停用的历史 Agent 策略数据,保留原数据但不参与权限判定 model AgentPolicy { /// 所属用户标识,用于数据隔离 userId String @id @db.Char(36) @@ -117,7 +117,7 @@ model AgentGrant { clientId String? @db.Char(36) /// 用户可见连接名称 name String @db.VarChar(100) - /// 权限列表:read、draft、write、sensitive + /// 权限列表:read、draft 或 write;隐藏账户附加权限 hidden_read、hidden_write scopes Json /// 令牌适用的规范 MCP 资源地址 resource String @db.VarChar(500) @@ -125,8 +125,8 @@ model AgentGrant { accessDigest String @unique @db.Char(64) /// 刷新令牌 SHA-256 摘要,使用后轮换 refreshDigest String? @unique @db.Char(64) - /// 到期时间,UTC - expiresAt DateTime + /// 访问令牌到期时间,UTC;空表示可撤销的永久个人令牌 + expiresAt DateTime? /// 刷新授权到期时间,UTC refreshExpiresAt DateTime? /// 撤销时间,UTC;空表示未撤销 diff --git a/apps/api/src/backup-format.ts b/apps/api/src/backup-format.ts new file mode 100644 index 0000000..cd2ee42 --- /dev/null +++ b/apps/api/src/backup-format.ts @@ -0,0 +1,2 @@ +// Only the current ZIP container format is supported. +export const BACKUP_ZIP_VERSION = 9; diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 506b9d2..e5ccec6 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -1,5 +1,6 @@ +import { BACKUP_ZIP_VERSION } from './backup-format'; import { Injectable } from '@nestjs/common'; -import { metalConfig, metalPriceInput } from './metals'; +import { metalConfig, metalPriceInput, storedMetalPurity } from './metals'; import { scheduleInput } from './schedules'; import { movementDeltas } from './movement'; import { pairedReasons } from './validation'; @@ -21,7 +22,7 @@ import { Response } from 'express'; import { FileInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import { tmpdir } from 'node:os'; -import { unlink, open, readFile, readdir, stat } from 'node:fs/promises'; +import { unlink, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { archiveBackup, readBackupZip, MAX_UPLOAD_BYTES } from './zip'; @@ -56,22 +57,31 @@ const timestamp = z.iso ); const record = positionMeta .extend({ - metalType: z.enum(['gold', 'silver']).nullable().optional(), - metalGrams: amount.nullable().optional(), - metalCostPerGram: metalConfig.shape.metalCostPerGram, - metalPurity: metalConfig.shape.metalPurity.optional(), - autoValuation: z.boolean().optional(), + groupName: z.string().max(60), + included: z.boolean(), + iconId: z.string().uuid().nullable(), + notes: z.string().max(2000), + archived: z.boolean(), + hidden: z.boolean(), + metalType: z.enum(['gold', 'silver']).nullable(), + metalGrams: amount.nullable(), + metalCostPerGram: metalConfig.shape.metalCostPerGram.nonoptional(), + metalPurity: storedMetalPurity, + autoValuation: z.boolean(), kind: z.enum(['account', 'asset', 'debt']), side: z.enum(['asset', 'liability']), currency, id: z.string().uuid(), - importedFromId: z.string().uuid().nullable().optional(), + importedFromId: z.string().uuid().nullable(), createdAt: timestamp, updatedAt: timestamp, revisions: z.array( revisionInput.extend({ id: z.string().uuid(), - sequence: z.number().int().positive().max(2147483647).optional(), + notes: z.string().max(2000), + reason: revisionInput.shape.reason.unwrap(), + date: revisionInput.shape.date.refine((s) => s.length === 16, '备份业务时间必须精确到分钟'), + sequence: z.number().int().positive().max(2147483647), createdAt: timestamp, updatedAt: timestamp, }), @@ -81,68 +91,65 @@ const record = positionMeta const backupSchema = z .object({ format: z.literal('worthpath'), - version: z.union([z.literal(1), z.literal(2)]), + version: z.literal(BACKUP_ZIP_VERSION, { error: '备份数据必须来自当前 ZIP v9 格式' }), exportedAt: z.iso.datetime(), baseCurrency: currency, currencies: z.array(currency).max(10), preferences: z .object({ - showSidebar: z.boolean().optional(), - hiddenMenus: hiddenMenus.optional(), - accountGroupOrder: accountGroupOrder.optional(), - sessionHours: sessionHours.optional(), - requireHiddenPassword: z.boolean().optional(), - overviewCards: overviewCards.optional(), - includeIndependentAssets: z.boolean().optional(), - showNotes: z.boolean().optional(), + hiddenMenus: hiddenMenus, + accountGroupOrder: accountGroupOrder, + sessionHours: sessionHours, + requireHiddenPassword: z.boolean(), + overviewCards: overviewCards, + includeIndependentAssets: z.boolean(), + showNotes: z.boolean(), idleMinutes: z.number().int().min(0).max(1440), }) - .strict() - .optional(), - icons: z - .array( - z - .object({ - id: z.string().uuid(), - name: iconName, - shared: z.boolean(), - image: z.string().max(3 * 1024 * 1024), - hash: z.string().regex(/^[a-f0-9]{64}$/), - }) - .strict(), - ) - .optional(), - transfers: z - .array( - transferInput.safeExtend({ + .strict(), + icons: z.array( + z + .object({ id: z.string().uuid(), - importedFromId: z.string().uuid().nullable().optional(), - sourceRevisionId: z.string().uuid(), - targetRevisionId: z.string().uuid(), - sourceCurrency: currency, - targetCurrency: currency, - createdAt: timestamp, - }), - ) - .optional(), - schedules: z - .array( - scheduleInput.safeExtend({ - id: z.string().uuid(), - importedFromId: z.string().uuid().nullable().optional(), - enabled: z.boolean(), - completed: z.boolean().default(false), - }), - ) - .optional(), - metalPrices: z - .array( - metalPriceInput.extend({ - source: z.enum(['manual', 'goldapi']), - quotedAt: timestamp, - }), - ) - .optional(), + name: iconName, + shared: z.boolean(), + image: z.string().max(3 * 1024 * 1024), + hash: z.string().regex(/^[a-f0-9]{64}$/), + }) + .strict(), + ), + transfers: z.array( + transferInput.safeExtend({ + id: z.string().uuid(), + importedFromId: z.string().uuid().nullable(), + operation: transferInput.shape.operation.unwrap(), + fee: transferInput.shape.fee.unwrap(), + notes: z.string().max(2000), + date: transferInput.shape.date.refine((s) => s.length === 16), + sourceRevisionId: z.string().uuid(), + targetRevisionId: z.string().uuid(), + sourceCurrency: currency, + targetCurrency: currency, + createdAt: timestamp, + }), + ), + schedules: z.array( + scheduleInput.safeExtend({ + id: z.string().uuid(), + importedFromId: z.string().uuid().nullable(), + enabled: z.boolean(), + completed: z.boolean(), + targetId: z.string().uuid().nullable(), + received: amount, + notes: z.string().max(2000), + }), + ), + metalPrices: z.array( + metalPriceInput.extend({ + source: z.enum(['manual', 'goldapi']), + quotedAt: timestamp, + }), + ), positions: z.array(record), links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()), rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })), @@ -155,11 +162,11 @@ export function validateBackup(raw: unknown) { if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID'); const origins = b.positions.map((p) => p.importedFromId || p.id); if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目'); - const quoteKeys = (b.metalPrices || []).map((q) => q.metalType + q.currency + q.date); + const quoteKeys = b.metalPrices.map((q) => q.metalType + q.currency + q.date); if (new Set(quoteKeys).size !== quoteKeys.length) throw new BadRequestException('重复贵金属报价'); - const iconIds = new Set((b.icons || []).map((i) => i.id)); + const iconIds = new Set(b.icons.map((i) => i.id)); if ( - iconIds.size !== (b.icons || []).length || + iconIds.size !== b.icons.length || b.positions.some((p) => p.iconId && !iconIds.has(p.iconId)) ) throw new BadRequestException('图标关联无效'); @@ -172,8 +179,7 @@ export function validateBackup(raw: unknown) { metalType: p.metalType, metalGrams: p.metalGrams, metalCostPerGram: p.metalCostPerGram, - metalPurity: p.metalPurity || '1', - autoValuation: p.autoValuation || false, + autoValuation: p.autoValuation, }); } positionInput.parse({ @@ -203,7 +209,7 @@ export function validateBackup(raw: unknown) { if (p.kind !== 'account' && p.revisions.some((r) => r.amount.startsWith('-'))) throw new BadRequestException('仅账户支持负余额'); const planIds = new Set(); - for (const plan of b.schedules || []) { + for (const plan of b.schedules) { const source = ids.get(plan.sourceId), target = plan.targetId ? ids.get(plan.targetId) : null; if ( @@ -222,7 +228,7 @@ export function validateBackup(raw: unknown) { } const transferIds = new Set(), usedRevisions = new Set(); - for (const t of b.transfers || []) { + for (const t of b.transfers) { const source = ids.get(t.sourceId), target = ids.get(t.targetId); const origin = t.importedFromId || t.id; @@ -328,16 +334,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { await this.prune(true); } private async uploadedData(path: string) { - const handle = await open(path, 'r'); - const prefix = Buffer.alloc(2); - try { - await handle.read(prefix, 0, 2, 0); - } finally { - await handle.close(); - } - return prefix.toString() === 'PK' - ? readBackupZip(path) - : JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, '')); + return readBackupZip(path); } constructor(private db: Database) {} async snapshot(userId: string) { @@ -429,7 +426,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { const schedules = await client.schedule.findMany({ where: { userId } }); return backupSchema.parse({ format: 'worthpath', - version: 2, + version: BACKUP_ZIP_VERSION, exportedAt: new Date().toISOString(), baseCurrency: user.baseCurrency, preferences: { @@ -569,13 +566,13 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { data.rates.sort((a, b) => (a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date), ); - data.metalPrices?.sort((a, b) => + data.metalPrices.sort((a, b) => (a.metalType + a.currency + a.date).localeCompare(b.metalType + b.currency + b.date), ); data.currencies.sort(); - data.transfers?.sort((a, b) => a.id.localeCompare(b.id)); - data.schedules?.sort((a, b) => a.id.localeCompare(b.id)); - data.icons?.sort((a, b) => a.id.localeCompare(b.id)); + data.transfers.sort((a, b) => a.id.localeCompare(b.id)); + data.schedules.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'); } async clearStatus(r: UserRequest) { @@ -611,15 +608,15 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { async preview(r: UserRequest, raw: unknown) { const b = validateBackup(raw), 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); this.conflicts(b, existing); return { positions: b.positions.length, revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0), rates: b.rates.length, - icons: (b.icons || []).length, - transfers: (b.transfers || []).length, - schedules: (b.schedules || []).length, + icons: b.icons.length, + transfers: b.transfers.length, + schedules: b.schedules.length, baseCurrency: b.baseCurrency, currentBaseCurrency: existing.baseCurrency, message: @@ -650,7 +647,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { .parse(raw), b = validateBackup(backup); const iconData = new Map(); - for (const i of b.icons || []) iconData.set(i.id, await validateStoredIcon(i.image, i.hash)); + for (const i of b.icons) iconData.set(i.id, await validateStoredIcon(i.image, i.hash)); return this.db.$transaction( async (tx) => { const ps = await tx.position.findMany({ @@ -671,7 +668,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { } as unknown as Backup; this.conflicts(b, existing); const iconMapping = new Map(); - for (const i of b.icons || []) { + for (const i of b.icons) { const row = await tx.icon.upsert({ where: { ownerId_hash_shared: { ownerId: r.userId, hash: i.hash, shared: false } }, create: { @@ -742,7 +739,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { }); originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id)); } - for (const q of b.metalPrices || []) { + for (const q of b.metalPrices) { const key = { userId: r.userId, metalType: q.metalType, @@ -759,7 +756,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { data: { ...key, price: q.price, source: q.source, quotedAt: new Date(q.quotedAt) }, }); } - for (const t of b.transfers || []) + for (const t of b.transfers) await tx.transfer.create({ data: { userId: r.userId, @@ -779,7 +776,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { createdAt: new Date(t.createdAt), }, }); - for (const plan of b.schedules || []) { + for (const plan of b.schedules) { const { id, importedFromId, ...v } = plan; await tx.schedule.create({ data: { @@ -814,17 +811,17 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { where: { id: r.userId }, data: { baseCurrency: b.baseCurrency, - idleMinutes: b.preferences?.idleMinutes, - hiddenMenus: b.preferences?.hiddenMenus?.join(','), - showNotes: b.preferences?.showNotes, - accountGroupOrder: b.preferences?.accountGroupOrder, - sessionHours: b.preferences?.sessionHours, - requireHiddenPassword: b.preferences?.requireHiddenPassword, - overviewCards: b.preferences?.overviewCards, - includeIndependentAssets: b.preferences?.includeIndependentAssets, + idleMinutes: b.preferences.idleMinutes, + hiddenMenus: b.preferences.hiddenMenus.join(','), + showNotes: b.preferences.showNotes, + accountGroupOrder: b.preferences.accountGroupOrder, + sessionHours: b.preferences.sessionHours, + requireHiddenPassword: b.preferences.requireHiddenPassword, + overviewCards: b.preferences.overviewCards, + includeIndependentAssets: b.preferences.includeIndependentAssets, }, }); - if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined) + if (!ps.length && !rs.length) await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } }); return { ok: true, positions: b.positions.length }; }, @@ -861,10 +858,4 @@ export class BackupController { @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); - } } diff --git a/apps/api/src/mcp/catalogue.ts b/apps/api/src/mcp/catalogue.ts index 8a41cfa..6865b87 100644 --- a/apps/api/src/mcp/catalogue.ts +++ b/apps/api/src/mcp/catalogue.ts @@ -6,9 +6,8 @@ import { TransfersBusinessService } from '../transfers'; import { SchedulesBusinessService, scheduleInput } from '../schedules'; import { CalendarBusinessService } from '../calendar'; import { SettingsBusinessService } from '../rates'; -import { MetalsBusinessService, metalConfig, metalHoldingInput, metalPriceInput } from '../metals'; +import { MetalsBusinessService, metalConfig, metalHoldingInput } from '../metals'; import { IconsBusinessService } from '../icons'; -import { BackupBusinessService } from '../backup'; import { positionInput, positionMeta, @@ -49,9 +48,8 @@ export type ToolDefinition = { name: string; description: string; schema: z.ZodObject; - scope: 'read' | 'write' | 'sensitive'; + scope: 'read' | 'write'; destructive?: boolean; - web?: 'credentials' | 'reveal' | 'clear'; run?: (r: UserRequest, p: any) => Promise; }; @Injectable() @@ -65,7 +63,6 @@ export class AgentCatalogue { settings: SettingsBusinessService, metals: MetalsBusinessService, icons: IconsBusinessService, - backup: BackupBusinessService, ) { const read = ( name: string, @@ -282,13 +279,13 @@ export class AgentCatalogue { ), write( 'settings_update', - '修改个人设置、本位币、分组排序、登录时长及纳入统计配置。隐私设置变更需网页确认。', - settingsInput, + '修改个人设置、本位币、分组排序、登录时长及纳入统计配置。安全设置仅在网站修改。', + settingsInput.safeExtend({ + requireHiddenPassword: z.never().optional(), + sessionHours: z.never().optional(), + }), (r, p) => settings.update(r, p, undefined as any), ), - write('rates_refresh', '重试公共日汇率更新,失败保留原币和历史汇率。', empty, (r) => - settings.refresh(r), - ), write( 'metal_holding_create', '按克数创建金银资产,无需市场价格;买入每克成本可选,缺少报价时待估值。日期为 UTC+8 业务时间。', @@ -296,18 +293,9 @@ export class AgentCatalogue { (r, p) => metals.create(r, p), ), 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), ), @@ -328,46 +316,6 @@ export class AgentCatalogue { .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) { diff --git a/apps/api/src/mcp/files.ts b/apps/api/src/mcp/files.ts index fc15a1d..573cc0b 100644 --- a/apps/api/src/mcp/files.ts +++ b/apps/api/src/mcp/files.ts @@ -8,28 +8,20 @@ import { } 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 multer, { memoryStorage } from 'multer'; 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; + kind: 'icon' | 'image'; iconId?: string; buffer?: Buffer; name?: string; - preview?: unknown; - token?: string; }; @Injectable() export class AgentFiles implements OnModuleDestroy { @@ -39,9 +31,7 @@ export class AgentFiles implements OnModuleDestroy { }, 60000).unref(); constructor( private oauth: AgentOAuth, - private backup: BackupBusinessService, private icons: IconsBusinessService, - private db: Database, ) {} onModuleDestroy() { clearInterval(this.timer); @@ -60,19 +50,15 @@ export class AgentFiles implements OnModuleDestroy { 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', + method: kind === 'image' ? 'GET' : 'POST', headers: { Authorization: 'Bearer ' }, 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', + maxBytes: 2 * 1024 * 1024, + format: kind === 'image' ? 'image/png' : 'multipart/form-data; field file; image', }; } private ticket(r: UserRequest, grantId: string, id: string) { @@ -94,9 +80,7 @@ export class AgentFiles implements OnModuleDestroy { const t = this.ticket(r, grantId, id); return { fileId: id, - uploaded: !!t.buffer || !!t.token, - token: t.token, - preview: t.preview, + uploaded: !!t.buffer, expiresAt: new Date(t.expires).toISOString(), }; } @@ -117,13 +101,6 @@ export class AgentFiles implements OnModuleDestroy { }; } 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 }, @@ -136,38 +113,23 @@ export class AgentFiles implements OnModuleDestroy { res.status(405).end(); return; } - if ((req.method === 'POST') !== (t.kind === 'backup' || t.kind === 'icon')) { + if ((req.method === 'POST') !== (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); + 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((resolve, reject) => - (t.kind === 'backup' ? disk : memory)(req, res, (e) => (e ? reject(e) : resolve())), + 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; - } + t.buffer = req.file.buffer; + res.json(await this.inspect(r, grant.id, String(req.params.id))); } catch (e) { if (!res.headersSent) res diff --git a/apps/api/src/mcp/information.ts b/apps/api/src/mcp/information.ts new file mode 100644 index 0000000..1c51214 --- /dev/null +++ b/apps/api/src/mcp/information.ts @@ -0,0 +1,27 @@ +// Protocol utility tools registered by AgentTransport, also exposed in the management catalogue. +export const protocolTools = [ + { + name: 'state_get', + description: '读取当前账目并发版本,作为下一次写入的 expectedState。', + scope: 'read', + }, + { + name: 'operation_get', + description: '查询本连接发起的草稿、确认状态和最终结果。', + scope: 'read', + }, + { + name: 'file_upload_request', + description: '请求受保护的私有图标上传入口,不直接修改账目。', + scope: 'write', + }, + { name: 'file_status', description: '查询本连接上传文件状态和导入预检。', scope: 'read' }, + { name: 'icon_image', description: '请求受保护的图标图片读取入口。', scope: 'read' }, + { name: 'connection_info', description: '查看本连接权限、期限和隐藏账户授权。', scope: 'read' }, + { + name: 'connection_revoke', + description: '撤销本连接;只读权限下也可主动撤销自身。', + scope: 'read', + }, +] as const; +export const tokenDays = [1, 3, 7, 30, 365] as const; diff --git a/apps/api/src/mcp/management.ts b/apps/api/src/mcp/management.ts index 38b4552..8269395 100644 --- a/apps/api/src/mcp/management.ts +++ b/apps/api/src/mcp/management.ts @@ -2,7 +2,6 @@ import { Controller, Get, Post, - Put, Delete, Req, Param, @@ -18,6 +17,7 @@ import { Database } from '../database'; import { AuthService, UserRequest } from '../auth'; import { AgentOAuth, urls, scopeInput } from './oauth'; import { AgentOperations } from './operations'; +import { protocolTools, tokenDays } from './information'; @Controller('api/agent') export class AgentManagementController { constructor( @@ -62,34 +62,26 @@ export class AgentManagementController { }); return { mcpUrl: urls().resource.toString(), - mode: - (await this.db.agentPolicy.findUnique({ where: { userId: r.userId } }))?.mode || 'draft', + capabilities: [ + ...this.operations.tools.map((t) => ({ + name: t.name, + description: t.description, + scope: t.scope, + destructive: !!t.destructive, + })), + ...protocolTools, + ], 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), + days: z.union([z.literal(null), ...tokenDays.map((d) => z.literal(d))]), password: z.string().max(72), }) .strict() @@ -118,8 +110,11 @@ export class AgentManagementController { @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); + const { approve, scopes } = z + .object({ approve: z.boolean(), scopes: scopeInput.optional() }) + .strict() + .parse(raw); + return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve, scopes); } @Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) { return this.operations.preview(r.userId, z.string().uuid().parse(id)); diff --git a/apps/api/src/mcp/oauth.ts b/apps/api/src/mcp/oauth.ts index 5810953..1ae9684 100644 --- a/apps/api/src/mcp/oauth.ts +++ b/apps/api/src/mcp/oauth.ts @@ -20,12 +20,19 @@ import { InvalidTargetError, } from '@modelcontextprotocol/sdk/server/auth/errors.js'; -export const scopes = ['read', 'draft', 'write', 'sensitive'] as const; +export const scopes = ['read', 'draft', 'write', 'hidden_read', 'hidden_write'] 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); + .refine( + (v) => + v.includes('read') && + new Set(v).size === v.length && + !(v.includes('draft') && v.includes('write')) && + (!v.includes('hidden_write') || + (v.includes('hidden_read') && (v.includes('draft') || v.includes('write')))), + ); export const digest = (s: string) => createHash('sha256').update(s).digest('hex'); const secret = () => randomBytes(32).toString('base64url'); export function urls() { @@ -115,7 +122,15 @@ export class AgentOAuth implements OAuthServerProvider { 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'); + if ( + !z + .array(z.enum(scopes)) + .min(1) + .max(scopes.length) + .refine((v) => v.includes('read') && new Set(v).size === v.length) + .safeParse(selected).success + ) + throw new InvalidScopeError('Unsupported scope'); const row = await this.db.agentAuthorization.create({ data: { clientId: client.client_id, @@ -141,10 +156,15 @@ export class AgentOAuth implements OAuthServerProvider { resource: p.resource, }; } - async consent(userId: string, id: string, approved: boolean) { + async consent(userId: string, id: string, approved: boolean, selected?: string[]) { return this.db.atomic(async () => { await this.pending(id); const row = await this.db.agentAuthorization.findUniqueOrThrow({ where: { id } }); + const parameters = row.parameters as any; + const allowed = selected || ['read']; + scopeInput.parse(allowed); + if (allowed.some((scope: string) => !parameters.scopes.includes(scope))) + throw new BadRequestException('不能授予客户端未请求的权限'); const code = secret(); const changed = await this.db.agentAuthorization.updateMany({ where: { id, status: 'pending', expiresAt: { gt: new Date() } }, @@ -152,6 +172,7 @@ export class AgentOAuth implements OAuthServerProvider { userId, status: approved ? 'approved' : 'denied', codeDigest: approved ? digest(code) : null, + parameters: { ...parameters, scopes: allowed }, }, }); if (!changed.count) throw new BadRequestException('授权请求已处理'); @@ -175,14 +196,25 @@ export class AgentOAuth implements OAuthServerProvider { 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) { + async issue( + userId: string, + name: string, + selected: string[], + days: number | null, + clientId?: string, + ) { + if (clientId && days === null) throw new BadRequestException('OAuth 连接必须有期限'); const access = secret(), refresh = clientId ? secret() : undefined, sessionId = digest(secret()); - const expiresAt = new Date(Date.now() + days * 86400000), + const expiresAt = days === null ? null : 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 }, + data: { + id: sessionId, + userId, + expiresAt: refreshExpiresAt || expiresAt || new Date(Date.now() + 86400000), + }, }); const grant = await this.db.agentGrant.create({ data: { @@ -203,7 +235,7 @@ export class AgentOAuth implements OAuthServerProvider { tokens: { access_token: access, token_type: 'Bearer', - expires_in: Math.floor(days * 86400), + ...(days === null ? {} : { expires_in: Math.floor(days * 86400) }), scope: selected.join(' '), ...(refresh ? { refresh_token: refresh } : {}), } as OAuthTokens, @@ -296,7 +328,7 @@ export class AgentOAuth implements OAuthServerProvider { if ( !row || row.revokedAt || - row.expiresAt <= new Date() || + (row.expiresAt ? row.expiresAt <= new Date() : !!row.clientId) || row.resource !== urls().resource.toString() ) throw new InvalidTokenError('Expired, revoked or invalid resource token'); @@ -304,7 +336,9 @@ export class AgentOAuth implements OAuthServerProvider { token, clientId: row.clientId || row.id, scopes: row.scopes as string[], - expiresAt: Math.floor(+row.expiresAt / 1000), + // SDK bearer middleware requires a finite verified-authentication expiry. + // A permanent PAT stays expiry-free in storage; every request rechecks revocation. + expiresAt: Math.floor((row.expiresAt?.getTime() ?? Date.now() + 3600000) / 1000), resource: new URL(row.resource), extra: { grantId: row.id, userId: row.userId }, }; @@ -320,7 +354,12 @@ export class AgentOAuth implements OAuthServerProvider { } async grant(id: string, userId?: string) { const row = await this.db.agentGrant.findFirst({ - where: { id, ...(userId ? { userId } : {}), revokedAt: null, expiresAt: { gt: new Date() } }, + where: { + id, + ...(userId ? { userId } : {}), + revokedAt: null, + OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null, clientId: null }], + }, }); if (!row) throw new ForbiddenException('Agent 连接已过期或撤销'); return row; diff --git a/apps/api/src/mcp/operations.ts b/apps/api/src/mcp/operations.ts index 0f9cbbe..6e9137e 100644 --- a/apps/api/src/mcp/operations.ts +++ b/apps/api/src/mcp/operations.ts @@ -6,12 +6,10 @@ import { 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 { UserRequest } from '../auth'; import { AgentOAuth, digest, webLink } from './oauth'; import { AgentCatalogue, ToolDefinition, empty } from './catalogue'; import { AgentFiles } from './files'; @@ -46,41 +44,23 @@ export class AgentOperations { 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)。', + description: '保存已上传私有图标,不允许发布共享图标。先 file_upload_request(kind=icon)。', schema: z .object({ fileId: z.string().uuid(), name: z.string().min(1).max(100), - shared: z.boolean().default(false), + shared: z.literal(false).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) { @@ -88,13 +68,32 @@ export class AgentOperations { if (!t) throw new BadRequestException('未知工具'); return t; } - async context(grant: AgentGrant) { - const s = await this.db.session.findUnique({ where: { id: grant.sessionId } }); + async context(grant: AgentGrant, writing = false) { + let s = await this.db.session.findUnique({ where: { id: grant.sessionId } }); + // A permanent PAT is the credential. Its internal business session has a bounded + // lifetime and may be recreated after the normal expired-session cleanup. + if (!grant.expiresAt && !grant.clientId && (!s || s.expiresAt <= new Date())) { + await this.oauth.grant(grant.id, grant.userId); + s = await this.db.session.upsert({ + where: { id: grant.sessionId }, + create: { + id: grant.sessionId, + userId: grant.userId, + expiresAt: new Date(Date.now() + 86400000), + }, + update: { + expiresAt: new Date(Date.now() + 86400000), + revealUntil: null, + backupDigest: null, + backupExpiresAt: null, + }, + }); + } if (!s || s.expiresAt <= new Date()) throw new ForbiddenException('连接会话失效,请重新授权'); return { userId: grant.userId, sessionId: grant.sessionId, - revealed: !!s.revealUntil && +s.revealUntil > Date.now(), + revealed: (grant.scopes as string[]).includes(writing ? 'hidden_write' : 'hidden_read'), agent: true, agentGrantId: grant.id, cookies: {}, @@ -134,35 +133,26 @@ export class AgentOperations { ]); 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'; + const selected = grant.scopes as string[]; 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 }; + const mode = selected.includes('write') + ? 'direct' + : selected.includes('draft') + ? 'draft' + : 'readonly'; + if (t.scope === 'read') return { mode }; + if (mode === 'readonly') throw new ForbiddenException('本连接只有只读权限'); + if (!selected.includes('hidden_write') && (p.hidden === true || p.data?.hidden === true)) + throw new ForbiddenException('缺少隐藏账户修改权限'); + return { mode }; } 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); + const grant = await this.oauth.grant(grantId); + 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 () => { @@ -192,11 +182,7 @@ export class AgentOperations { expiresAt: new Date(Date.now() + 600000), }, }); - if ( - access.sensitive || - access.mode === 'draft' || - !(fresh.scopes as string[]).includes('write') - ) + if (access.mode === 'draft' || !(fresh.scopes as string[]).includes('write')) return this.view(row); const result = await this.execute(t, fresh, p); return this.view( @@ -208,7 +194,7 @@ export class AgentOperations { }); } private async execute(t: ToolDefinition, grant: AgentGrant, p: any) { - return t.run!(await this.context(grant), p); + return t.run!(await this.context(grant, true), p); } private view(row: any) { return { @@ -234,22 +220,9 @@ export class AgentOperations { 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, }; } @@ -257,10 +230,6 @@ export class AgentOperations { 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); @@ -280,44 +249,10 @@ export class AgentOperations { 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('请验证当前密码'); - } + await this.permission(grant, t, p); 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); + const result = await this.execute(t, grant, p); return this.view( await this.db.agentOperation.update({ where: { id }, @@ -326,16 +261,11 @@ export class AgentOperations { ); }, 300000); } - async uploadRequest(grantId: string, kind: 'backup' | 'icon') { + async uploadRequest(grantId: string, kind: '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) { diff --git a/apps/api/src/mcp/transport.ts b/apps/api/src/mcp/transport.ts index ff7d84e..8231b09 100644 --- a/apps/api/src/mcp/transport.ts +++ b/apps/api/src/mcp/transport.ts @@ -183,13 +183,13 @@ export class AgentTransport { register( 'file_upload_request', '创建受 Bearer 保护的短期文件上传入口;multipart/form-data 的 file 字段。', - z.object({ kind: z.enum(['backup', 'icon']) }).strict(), + z.object({ kind: z.enum(['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), ); @@ -201,7 +201,7 @@ export class AgentTransport { ); register( 'connection_info', - '查询本连接权限、到期时间、资源和用户写入策略;不返回任何令牌。', + '查询本连接权限、到期时间、资源及隐藏账户权限;不返回任何令牌。', z.object({}).strict(), async () => { const g = await this.oauth.grant(grantId); @@ -210,9 +210,13 @@ export class AgentTransport { scopes: g.scopes, expiresAt: g.expiresAt, resource: g.resource, - writePolicy: - (await this.db.agentPolicy.findUnique({ where: { userId: g.userId } }))?.mode || - 'draft', + permission: (g.scopes as string[]).includes('write') + ? 'write' + : (g.scopes as string[]).includes('draft') + ? 'draft' + : 'read', + readHidden: (g.scopes as string[]).includes('hidden_read'), + writeHidden: (g.scopes as string[]).includes('hidden_write'), }; }, ); diff --git a/apps/api/src/metals.ts b/apps/api/src/metals.ts index 9b9ea55..fd3822b 100644 --- a/apps/api/src/metals.ts +++ b/apps/api/src/metals.ts @@ -30,14 +30,15 @@ import { } from './validation'; import { businessTime, businessDay } from './calculation'; export const metalType = z.enum(['gold', 'silver']); +// Historical stored ratios remain part of valuation and ZIP data, not user input. +export const storedMetalPurity = z + .string() + .regex(/^(0|1)(\.\d{1,8})?$/) + .refine((v) => new Decimal(v).gt(0) && new Decimal(v).lte(1), '纯度应大于 0 且不超过 1'); export const metalConfig = z .object({ metalType, metalGrams: amount.refine((v) => new Decimal(v).gt(0), '重量必须大于零'), - metalPurity: z - .string() - .regex(/^(0|1)(\.\d{1,8})?$/) - .refine((v) => new Decimal(v).gt(0) && new Decimal(v).lte(1), '纯度应大于 0 且不超过 1'), metalCostPerGram: rateValue .nullable() .optional() @@ -152,8 +153,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy { }, }); if (!p) throw new NotFoundException('贵金属资产不存在或已归档'); - if (!p.metalType || !p.metalGrams) - throw new BadRequestException('请先设置贵金属品种、重量和纯度'); + if (!p.metalType || !p.metalGrams) throw new BadRequestException('请先设置贵金属品种和重量'); const quote = await tx.metalPrice.findFirst({ where: { userId, @@ -163,7 +163,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy { }, orderBy: { date: 'desc' }, }); - if (!quote) throw new BadRequestException('缺少该币种的贵金属价格,请刷新或手动录价'); + if (!quote) throw new BadRequestException('缺少该币种的贵金属价格,请刷新报价后重试'); const value = metalValue( p.metalGrams.toString(), p.metalPurity.toString(), @@ -286,17 +286,17 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy { if (p.autoValuation) await this.apply(tx, userId, p.id, true); } }); - const message = '贵金属参考价已更新;同日手动价格已保留,已开启的自动估价已记入历史'; + const message = '贵金属参考价已更新,已开启的自动估价已记入历史'; this.attempts.set(userId, today()); this.outcomes.set(userId, { state: 'ok', message, attemptedAt: new Date().toISOString() }); return { message }; } catch { this.outcomes.set(userId, { state: 'error', - message: '贵金属价格更新失败,已有价格与估值已保留,可手动录价', + message: '贵金属价格更新失败,已有价格与估值已保留,请稍后重试', attemptedAt: new Date().toISOString(), }); - throw new BadGatewayException('贵金属价格更新失败,已有价格与估值已保留,可手动录价'); + throw new BadGatewayException('贵金属价格更新失败,已有价格与估值已保留,请稍后重试'); } finally { this.running.delete(userId); } @@ -323,37 +323,6 @@ export class MetalsBusinessService { refresh(r: UserRequest) { return this.metals.refresh(r.userId); } - async manual(r: UserRequest, body: unknown) { - const v = metalPriceInput.parse(body); - const key = { - userId: r.userId, - metalType: v.metalType, - currency: v.currency, - date: new Date(v.date), - }; - return this.db.serial(async (tx) => { - await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`); - await tx.metalPrice.upsert({ - where: { userId_metalType_currency_date: key }, - create: { ...key, price: v.price, source: 'manual', quotedAt: new Date() }, - update: { price: v.price, source: 'manual', quotedAt: new Date() }, - }); - const ps = await tx.position.findMany({ - where: { - userId: r.userId, - kind: 'asset', - category: 'gold', - metalType: v.metalType, - currency: v.currency, - archived: false, - autoValuation: true, - ...(r.revealed ? {} : { hidden: false }), - }, - }); - for (const p of ps) await this.metals.apply(tx, r.userId, p.id, r.revealed); - return { message: '贵金属价格已保存' }; - }); - } async create(r: UserRequest, body: unknown) { const v = metalHoldingInput.parse(body); const when = toBusinessDate(v.date); @@ -436,9 +405,6 @@ export class MetalsController { @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, diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 3ffabd6..7cb0256 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { metalConfig, metalPriceInput } from './metals'; +import { metalConfig, metalHoldingInput } from './metals'; import { scheduleInput } from './schedules'; import { z } from 'zod'; import { @@ -47,7 +47,7 @@ export function setupOpenApi(app: INestApplication) { 'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(), 'PATCH /api/settings': settingsInput, 'PUT /api/metals/{id}': metalConfig, - 'POST /api/metals/prices': metalPriceInput, + 'POST /api/metals/holdings': metalHoldingInput, 'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }), 'POST /api/backup/import-file': z.object({ token: z.string().uuid(), @@ -155,14 +155,6 @@ export function setupOpenApi(app: INestApplication) { }, }; } - if (['/api/backup/preview', '/api/backup/import'].includes(path)) - operation.requestBody = { - required: true, - description: '旧版 JSON 兼容入口;新版请使用 upload + import-file(支持完整 ZIP 备份)', - content: { - 'application/json': { schema: { type: 'object', additionalProperties: true } }, - }, - }; } } SwaggerModule.setup('api/docs', app, document, { diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index 219bf3b..192d915 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -3,8 +3,8 @@ import * as yauzl from 'yauzl'; import { createHash } from 'node:crypto'; import { BadRequestException } from '@nestjs/common'; import { z } from 'zod'; +import { BACKUP_ZIP_VERSION } from './backup-format'; import type { Backup } from './backup'; -import { accountGroupOrder, sessionHours, overviewCards } from './validation'; export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024; const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024; const files = [ @@ -38,9 +38,9 @@ export function packBackup(b: Backup) { ), 'links.json': b.links, 'rates.json': b.rates, - 'icons.json': b.icons || [], - 'transfers.json': b.transfers || [], - 'schedules.json': b.schedules || [], + 'icons.json': b.icons, + 'transfers.json': b.transfers, + 'schedules.json': b.schedules, }; const contents = Object.fromEntries( files.map((name) => [name, JSON.stringify(data[name], null, 2)]), @@ -48,7 +48,7 @@ export function packBackup(b: Backup) { contents['manifest.json'] = JSON.stringify( { format: 'worthpath', - version: 8, + version: BACKUP_ZIP_VERSION, exportedAt: b.exportedAt, files: files.map((name) => ({ name, sha256: sha(contents[name]) })), }, @@ -123,30 +123,17 @@ export async function readBackupZip(input: string | Buffer): Promise { const manifest = z .object({ format: z.literal('worthpath'), - version: z.union([ - z.literal(3), - z.literal(4), - z.literal(5), - z.literal(6), - z.literal(7), - z.literal(8), - ]), + version: z.literal(BACKUP_ZIP_VERSION), exportedAt: z.iso.datetime(), files: z .array( z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(), ) - .min(files.length - 3) - .max(files.length), + .length(files.length), }) .strict() .parse(parse('manifest.json')); - const expected = files.filter( - (f) => - (manifest.version >= 4 || f !== 'icons.json') && - (manifest.version >= 5 || f !== 'transfers.json') && - (manifest.version >= 7 || f !== 'schedules.json'), - ); + const expected = files; if ( contents.size !== expected.length + 1 || new Set(manifest.files.map((f) => f.name)).size !== expected.length || @@ -157,21 +144,8 @@ export async function readBackupZip(input: string | Buffer): Promise { const settings = z .object({ baseCurrency: z.string(), - metalPrices: z.array(z.record(z.string(), z.unknown())).optional(), - preferences: z - .object({ - showSidebar: z.boolean().optional(), - hiddenMenus: z.array(z.string()).optional(), - accountGroupOrder: accountGroupOrder.optional(), - sessionHours: sessionHours.optional(), - requireHiddenPassword: z.boolean().optional(), - overviewCards: overviewCards.optional(), - includeIndependentAssets: z.boolean().optional(), - showNotes: z.boolean().optional(), - idleMinutes: z.number().int().min(0).max(1440), - }) - .strict() - .optional(), + metalPrices: z.array(z.record(z.string(), z.unknown())), + preferences: z.record(z.string(), z.unknown()), }) .strict() .parse(parse('settings.json')); @@ -194,20 +168,20 @@ export async function readBackupZip(input: string | Buffer): Promise { } return { format: 'worthpath', - version: 2, + version: BACKUP_ZIP_VERSION, exportedAt: manifest.exportedAt, ...settings, currencies: parse('currencies.json'), positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })), links: parse('links.json'), rates: parse('rates.json'), - ...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}), - ...(manifest.version >= 7 ? { schedules: parse('schedules.json') } : {}), - ...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}), + transfers: parse('transfers.json'), + schedules: parse('schedules.json'), + icons: parse('icons.json'), }; } catch { throw new BadRequestException( - '备份 ZIP 无效:请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)', + '备份 ZIP 无效:仅接受当前 v9 格式,不支持旧备份。请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)', ); } finally { zip.close(); diff --git a/apps/api/test/backup-fixture.ts b/apps/api/test/backup-fixture.ts new file mode 100644 index 0000000..7184507 --- /dev/null +++ b/apps/api/test/backup-fixture.ts @@ -0,0 +1,34 @@ +import { archiveBackup } from '../src/zip'; +// Test-only aliases build ZIP fixtures and exercise the public upload/confirmation flow. +export async function fixtureFetch(url: string, init?: RequestInit): Promise { + if (!url.endsWith('/backup/restore-fixture') && !url.endsWith('/backup/preview-fixture')) + return fetch(url, init); + const restore = url.endsWith('/backup/restore-fixture'); + const input = JSON.parse(String(init?.body)); + const chunks: Buffer[] = []; + const archive = archiveBackup(restore ? input.backup : input); + const completed = new Promise((resolve, reject) => { + archive.on('data', (chunk) => chunks.push(chunk)); + archive.on('end', () => resolve(Buffer.concat(chunks))); + archive.on('error', reject); + }); + await archive.finalize(); + const form = new FormData(); + form.set( + 'file', + new Blob([new Uint8Array(await completed)], { type: 'application/zip' }), + 'fixture.zip', + ); + const headers = new Headers(init?.headers); + headers.delete('Content-Type'); + const base = url.slice(0, url.lastIndexOf('/backup/')); + const uploaded = await fetch(base + '/backup/upload', { method: 'POST', headers, body: form }); + if (!uploaded.ok || !restore) return uploaded; + const preview = await uploaded.json(); + headers.set('Content-Type', 'application/json'); + return fetch(base + '/backup/import-file', { + method: 'POST', + headers, + body: JSON.stringify({ token: preview.token, confirmed: input.confirmed }), + }); +} diff --git a/apps/api/test/calculation.test.ts b/apps/api/test/calculation.test.ts index ccb6290..c4868ac 100644 --- a/apps/api/test/calculation.test.ts +++ b/apps/api/test/calculation.test.ts @@ -144,7 +144,21 @@ test('reject invalid dates, negative values and credit card assets', () => { test('backup rejects auth data and broken relations', () => { const b = { format: 'worthpath', - version: 1, + version: 9, + preferences: { + hiddenMenus: [], + showNotes: true, + idleMinutes: 0, + accountGroupOrder: [], + sessionHours: 168, + requireHiddenPassword: true, + overviewCards: ['net'], + includeIndependentAssets: true, + }, + icons: [], + transfers: [], + schedules: [], + metalPrices: [], exportedAt: new Date().toISOString(), baseCurrency: 'CNY', currencies: ['CNY'], diff --git a/apps/api/test/credit-balance.test.ts b/apps/api/test/credit-balance.test.ts index aab1f19..fa6a581 100644 --- a/apps/api/test/credit-balance.test.ts +++ b/apps/api/test/credit-balance.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -25,7 +26,7 @@ test('liability account credit balances survive revisions, replay, calendar, pri return { id: u.id, cookie: 'wp_session=' + token }; } async function call(path: string, cookie: string, method = 'GET', body?: unknown) { - const res = await fetch(base + path, { + const res = await fixtureFetch(base + path, { method, headers: { Cookie: cookie, @@ -163,7 +164,7 @@ test('liability account credit balances survive revisions, replay, calendar, pri assert.equal(zip.status, 200); const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer())); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status, 201, ); const restored = (await call('/overview', b.cookie)).data; diff --git a/apps/api/test/debts.test.ts b/apps/api/test/debts.test.ts index 0ba5218..fcbd7a0 100644 --- a/apps/api/test/debts.test.ts +++ b/apps/api/test/debts.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -25,7 +26,7 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency return { id: row.id, token }; } async function call(token: string, path: string, method = 'GET', body?: unknown) { - const response = await fetch(base + path, { + const response = await fixtureFetch(base + path, { method, headers: { Cookie: 'wp_session=' + token, @@ -151,11 +152,11 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency 200, ); const backup = (await call(a.token, '/backup')).data; - assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 8); + assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 9); assert.equal(backup.transfers.length, 5); assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2')); assert.equal( - (await call(b.token, '/backup/import', 'POST', { confirmed: true, backup })).status, + (await call(b.token, '/backup/restore-fixture', 'POST', { confirmed: true, backup })).status, 201, ); assert.equal( diff --git a/apps/api/test/group-order.test.ts b/apps/api/test/group-order.test.ts index 03439df..0f04706 100644 --- a/apps/api/test/group-order.test.ts +++ b/apps/api/test/group-order.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -5,7 +6,7 @@ import { randomUUID, randomBytes, createHash } from 'node:crypto'; import { PrismaClient } from '@prisma/client'; import { readBackupZip } from '../src/zip'; const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api'; -test('group order persists per user, validates input and survives ZIP and legacy imports', async () => { +test('group order persists per user, validates input and survives ZIP and reject incomplete imports', async () => { const db = new PrismaClient(), ids: string[] = []; const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; @@ -25,7 +26,7 @@ test('group order persists per user, validates input and survives ZIP and legacy return { id: u.id, cookie: 'wp_session=' + token }; } async function call(path: string, cookie: string, method = 'GET', body?: unknown) { - const res = await fetch(base + path, { + const res = await fixtureFetch(base + path, { method, headers: { Cookie: cookie, @@ -80,20 +81,20 @@ test('group order persists per user, validates input and survives ZIP and legacy const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer())); assert.deepEqual(backup.preferences.accountGroupOrder, order); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status, 201, ); assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order); await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] }); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status, 409, ); assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']); delete backup.preferences.accountGroupOrder; assert.equal( - (await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status, - 201, + (await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status, + 400, ); assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []); const comments: any[] = await db.$queryRawUnsafe( diff --git a/apps/api/test/icons.test.ts b/apps/api/test/icons.test.ts index a66b7e2..0a834da 100644 --- a/apps/api/test/icons.test.ts +++ b/apps/api/test/icons.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -11,7 +12,7 @@ test('private and shared icons, account reuse and complete ZIP restoration prese names: string[] = [], publicIds: string[] = []; async function call(path: string, cookie = '', method = 'GET', data?: unknown) { - const res = await fetch(base + path, { + const res = await fixtureFetch(base + path, { method, headers: { Cookie: cookie, @@ -153,16 +154,21 @@ test('private and shared icons, account reuse and complete ZIP restoration prese assert.doesNotMatch(JSON.stringify(backup.data), /passwordHash|sessionId|ownerId|token/i); const broken = structuredClone(backup.data); broken.icons[0].image = 'invalid'; - assert.equal((await call('/backup/preview', b.cookie, 'POST', broken)).status, 400); + assert.equal((await call('/backup/preview-fixture', b.cookie, 'POST', broken)).status, 400); const before = await db.icon.count(); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup: broken })) + .status, 400, ); assert.equal(await db.icon.count(), before); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: backup.data })) - .status, + ( + await call('/backup/restore-fixture', b.cookie, 'POST', { + confirmed: true, + backup: backup.data, + }) + ).status, 201, ); const imported = await db.position.findMany({ diff --git a/apps/api/test/inclusion-metals.test.ts b/apps/api/test/inclusion-metals.test.ts index 5b015b4..3245e3c 100644 --- a/apps/api/test/inclusion-metals.test.ts +++ b/apps/api/test/inclusion-metals.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import { request } from 'node:http'; @@ -28,6 +29,14 @@ async function fixture() { // Give this fixture suite its own loopback source address so independent auth // scenarios do not consume the existing suite's per-IP production rate limit. async function call(path: string, cookie: string, method = 'GET', body?: unknown) { + if (path.endsWith('-fixture')) { + const response = await fixtureFetch(base + path, { + method, + headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: response.status, data: await response.json(), cookie: null }; + } const data = body === undefined ? undefined : JSON.stringify(body); return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => { const req = request( @@ -200,7 +209,6 @@ test('inclusion preferences and precious metal settings, valuation and quotes re await call('/metals/' + cash, a.cookie, 'PUT', { metalType: 'gold', metalGrams: '10', - metalPurity: '0.999', autoValuation: true, }) ).status, @@ -211,7 +219,6 @@ test('inclusion preferences and precious metal settings, valuation and quotes re await call('/metals/' + metal, b.cookie, 'PUT', { metalType: 'gold', metalGrams: '10', - metalPurity: '0.999', autoValuation: true, }) ).status, @@ -222,24 +229,26 @@ test('inclusion preferences and precious metal settings, valuation and quotes re await call('/metals/' + metal, a.cookie, 'PUT', { metalType: 'gold', metalGrams: '10.86420978', - metalPurity: '0.999', autoValuation: true, }) ).status, 200, ); + await db.position.update({ where: { id: metal }, data: { metalPurity: '0.999' } }); // Existing stored valuations are preserved, not an editable setting. const d = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10); - assert.equal( - ( - await call('/metals/prices', a.cookie, 'POST', { - metalType: 'gold', - currency: 'CNY', - price: '700.864209789012', - date: d, - }) - ).status, - 201, - ); + assert.equal((await call('/metals/prices', a.cookie, 'POST', {})).status, 404); + await db.metalPrice.create({ + data: { + userId: a.id, + metalType: 'gold', + currency: 'CNY', + price: '700.864209789012', + date: new Date(d), + source: 'manual', + quotedAt: new Date(), + }, + }); + await call('/metals/' + metal + '/value', a.cookie, 'POST', {}); const expected = (await import('../src/metals')).metalValue( '10.86420978', '0.999', @@ -250,12 +259,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re expected.replace(/0+$/, '').replace(/\.$/, ''), ); const count = await db.revision.count({ where: { positionId: metal } }); - await call('/metals/prices', a.cookie, 'POST', { - metalType: 'gold', - currency: 'CNY', - price: '700.864209789012', - date: d, - }); + await call('/metals/' + metal + '/value', a.cookie, 'POST', {}); assert.equal(await db.revision.count({ where: { positionId: metal } }), count); const controller = new (await import('../src/backup')).BackupBusinessService(db as any); const backup = await (controller as any).data(a.id); @@ -267,8 +271,12 @@ test('inclusion preferences and precious metal settings, valuation and quotes re await done; const restoredBackup = await readBackupZip(Buffer.concat(chunks)); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: restoredBackup })) - .status, + ( + await call('/backup/restore-fixture', b.cookie, 'POST', { + confirmed: true, + backup: restoredBackup, + }) + ).status, 201, ); assert.equal((await call('/auth/me', b.cookie)).data.includeIndependentAssets, false); @@ -294,7 +302,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re await db.$disconnect(); } }); -test('gram-only creation, optional exact purchase cost, pending totals and backward-compatible backups', async () => { +test('gram-only creation, optional exact purchase cost, pending totals and strict current backups', async () => { const a = await fixture(), b = await fixture(); try { @@ -303,13 +311,22 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw currency: 'CNY', metalType: 'gold', metalGrams: '2.5', - metalPurity: '0.8', autoValuation: true, date: '2026-10-01T12:00', metalCostPerGram: '12.8', }; + assert.equal( + (await call('/metals/holdings', a.cookie, 'POST', { ...input, metalPurity: '0.8' })).status, + 400, + ); const created = await call('/metals/holdings', a.cookie, 'POST', input); assert.equal(created.status, 201, JSON.stringify(created.data)); + assert.equal( + ( + await db.position.findUniqueOrThrow({ where: { id: created.data.id } }) + ).metalPurity.toString(), + '1', + ); assert.equal(created.data.valuationAvailable, false); const id = created.data.id; let p = (await call('/positions/' + id, a.cookie)).data; @@ -326,36 +343,40 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw await call('/metals/' + id, b.cookie, 'PUT', { metalType: 'gold', metalGrams: '2.5', - metalPurity: '0.8', autoValuation: true, }) ).status, 404, ); - const price = await call('/metals/prices', a.cookie, 'POST', { - metalType: 'gold', - currency: 'CNY', - price: '20', - date: '2026-10-01', + await db.metalPrice.create({ + data: { + userId: a.id, + metalType: 'gold', + currency: 'CNY', + price: '20', + date: new Date('2026-10-01'), + source: 'goldapi', + quotedAt: new Date(), + }, }); - assert.equal(price.status, 201, JSON.stringify(price.data)); + assert.equal((await call('/metals/' + id + '/value', a.cookie, 'POST', {})).status, 201); p = (await call('/positions/' + id, a.cookie)).data; - assert.equal(p.amount, '40'); - assert.equal(p.metalProfit, '8.00000000'); + assert.equal(p.amount, '50'); + assert.equal(p.metalProfit, '18.00000000'); assert.equal(p.valuationAvailable, true); - const cfg = { metalType: 'gold', metalGrams: '2.5', metalPurity: '0.8', autoValuation: true }; + const cfg = { metalType: 'gold', metalGrams: '2.5', autoValuation: true }; assert.equal((await call('/metals/' + id, a.cookie, 'PUT', cfg)).status, 200); assert.equal((await call('/positions/' + id, a.cookie)).data.metalCostPerGram, '12.8'); const controller = new (await import('../src/backup')).BackupBusinessService(db as any); const backup = await (controller as any).data(a.id); assert.equal(backup.positions[0].metalCostPerGram, '12.8'); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status, 201, ); const restored = (await call('/positions?kind=asset', b.cookie)).data[0]; assert.equal(restored.metalCostPerGram, '12.8'); - assert.equal(restored.metalProfit, '8.00000000'); + assert.equal(restored.metalProfit, '18.00000000'); const invalidCost = structuredClone(backup); Object.assign(invalidCost.positions[0], { metalType: null, @@ -367,7 +388,7 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw ); const old = structuredClone(backup); delete old.positions[0].metalCostPerGram; - assert.doesNotThrow(() => + assert.throws(() => (require('../src/backup') as typeof import('../src/backup')).validateBackup(old), ); assert.equal( @@ -382,7 +403,7 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw }); assert.equal(next.status, 201); assert.equal(next.data.valuationAvailable, true); - assert.equal((await call('/positions/' + next.data.id, a.cookie)).data.amount, '40'); + assert.equal((await call('/positions/' + next.data.id, a.cookie)).data.amount, '50'); assert.equal( (await call('/metals/holdings', a.cookie, 'POST', { ...input, metalGrams: '0' })).status, 400, diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index 637811e..404fa0b 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -11,7 +12,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures const db = new PrismaClient(), created: { id: string; username: string }[] = []; async function call(path: string, method = 'GET', body?: unknown, cookie = '') { - const res = await fetch(base + path, { + const res = await fixtureFetch(base + path, { method, headers: { Origin: origin, @@ -281,16 +282,17 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures JSON.stringify(backup), /"(?:password|passwordHash|token|userId|session|sessionId|sessions|cookie)"\s*:/i, ); - assert.equal((await call('/backup/preview', 'POST', backup, a.cookie)).status, 409); - assert.equal((await call('/backup/preview', 'POST', backup, b.cookie)).status, 201); + assert.equal((await call('/backup/preview-fixture', 'POST', backup, a.cookie)).status, 409); + assert.equal((await call('/backup/preview-fixture', 'POST', backup, b.cookie)).status, 201); assert.equal( - (await call('/backup/import', 'POST', { confirmed: false, backup }, b.cookie)).status, + (await call('/backup/restore-fixture', 'POST', { confirmed: false, backup }, b.cookie)) + .status, 400, ); assert.equal( ( await call( - '/backup/import', + '/backup/restore-fixture', 'POST', { confirmed: true, @@ -303,7 +305,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures ); assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 0); assert.equal( - (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + (await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status, 201, ); assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, o.net); @@ -326,7 +328,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures sameDay.map((h: { after: string }) => h.after), ); assert.equal( - (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + (await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status, 409, ); assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 4); @@ -346,21 +348,21 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures b.cookie, ); assert.equal( - (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + (await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status, 409, ); const c = await account(); const racing = await Promise.all([ - call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie), - call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie), + call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, c.cookie), + call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, c.cookie), ]); - assert.deepEqual(racing.map((r) => r.status).sort(), [201, 409]); + assert.deepEqual(racing.map((r) => r.status).sort(), [201, 400]); assert.equal((await call('/positions', 'GET', undefined, c.cookie)).data.length, 4); assert.equal((await call('/auth/logout', 'POST', undefined, a.cookie)).status, 201); assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401); assert.equal((await call('/backup', 'GET', undefined, a.cookie)).status, 401); assert.equal( - (await call('/backup/import', 'POST', { confirmed: true, backup }, a.cookie)).status, + (await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, a.cookie)).status, 401, ); const login = await call('/auth/login', 'POST', { username: a.username, password: a.password }); diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index 5928501..89916cc 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -13,7 +13,7 @@ const root = process.env.TEST_API_URL?.replace(/\/api$/, '') || 'http://127.0.0. 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 () => { +test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isolation, hidden permissions and removed capabilities', async () => { const db = new PrismaClient(), users: string[] = [], clients: Client[] = []; @@ -33,15 +33,22 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol cookie: response.headers.get('set-cookie')?.split(';')[0] || cookie, }; } - async function fixture(mode = 'direct', selected = ['read', 'draft', 'write', 'sensitive']) { + async function fixture( + mode = 'direct', + selected = mode === 'direct' + ? ['read', 'write'] + : mode === 'draft' + ? ['read', 'draft'] + : ['read'], + ) { 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); + await db.agentPolicy.create({ data: { userId: user.id, mode: 'readonly' } }); // Historical policy must not override connection permissions. 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, @@ -82,7 +89,6 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol 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)); @@ -113,11 +119,10 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol 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.equal(discovered.tools.length, 49); + assert.equal(discovered.tools.length, 39); + assert.ok(!discovered.tools.some((t) => t.name === 'metal_price_set')); assert.equal( discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint, true, @@ -310,15 +315,20 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol 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 db.metalPrice.create({ + data: { + userId: a.id, + metalType: 'gold', + currency: 'CNY', + price: '10.876543210987', + date: new Date(day), + source: 'goldapi', + quotedAt: new Date(), + }, }); await write(a, 'metal_configure', { id: metal, - data: { metalType: 'gold', metalGrams: '2', metalPurity: '1', autoValuation: true }, + data: { metalType: 'gold', metalGrams: '2', autoValuation: true }, }); assert.equal((await call(a, 'position_get', { id: metal })).amount, '21.75308642'); await write(a, 'metal_value', { id: metal }); @@ -328,7 +338,6 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol currency: 'CNY', metalType: 'gold', metalGrams: '2', - metalPurity: '1', autoValuation: true, metalCostPerGram: '9', date: day, @@ -362,9 +371,8 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol 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; + const hidden = (await write(a, 'position_create', { ...position, name: 'hidden' })).result.id; + await db.position.update({ where: { id: hidden }, data: { hidden: true } }); await fail(a, 'position_get', { id: hidden }); await fail(a, 'debt_links_set', { id: debt, @@ -377,80 +385,45 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol expectedState: (await call(d, 'state_get')).state, idempotencyKey: randomUUID(), }); - const unlock = await write(a, 'hidden_unlock_request'); - await confirm(a, unlock); + for (const removed of [ + 'rates_refresh', + 'metals_refresh', + 'metal_price_set', + 'backup_export', + 'backup_import', + 'credentials_change_request', + 'hidden_unlock_request', + 'hidden_lock', + 'data_clear_request', + 'import_preview', + ]) { + assert.ok(!discovered.tools.some((t) => t.name === removed)); + await fail(a, removed); + } + await db.agentGrant.update({ + where: { id: a.grantId }, + data: { scopes: ['read', 'write', 'hidden_read'] }, + }); 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, + await fail(a, 'position_update', { + id: hidden, + data: { name: 'forbidden', category: 'cash', hidden: true }, + expectedState: (await call(a, 'state_get')).state, + idempotencyKey: randomUUID(), + }); + await db.agentGrant.update({ + where: { id: a.grantId }, + data: { scopes: ['read', 'write', 'hidden_read', 'hidden_write'] }, + }); + await write(a, 'position_update', { + id: hidden, + data: { name: 'authorized hidden', category: 'cash', hidden: true }, }); - 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 db.position.findUniqueOrThrow({ where: { id: hidden } })).name, + 'authorized hidden', ); + await db.agentGrant.update({ where: { id: a.grantId }, data: { scopes: ['read'] } }); await fail(a, 'position_create', { ...position, expectedState: (await call(a, 'state_get')).state, @@ -495,7 +468,7 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol } }); -test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback and credentials completion', async () => { +test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback', async () => { const db = new PrismaClient(); const username = 'mcp_extra_' + randomUUID().slice(0, 10), password = randomBytes(20).toString('hex'); @@ -534,12 +507,11 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb 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'], + scopes: ['read', 'write'], password, }) ).data; @@ -602,18 +574,15 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb 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', { + const forbiddenShared = await tool(c, 'icon_publish', { fileId: upload.fileId, name: '测试共享图标', shared: true, + expectedState: (await call(c, 'state_get')).state, + idempotencyKey: randomUUID(), }); - 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); + assert.equal(forbiddenShared.isError, true); + assert.equal(await db.icon.count({ where: { ownerId: userId, shared: true } }), 0); // A failed paired transfer leaves neither side changed, including inside outer // idempotency transaction and nested service savepoints. const account = one.result.id, @@ -670,36 +639,6 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb 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 } } }); @@ -726,7 +665,7 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', - scope: 'read draft write sensitive', + scope: 'read write', }, clientInformation: () => saved, saveClientInformation: (v) => { @@ -766,19 +705,22 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation 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.equal(await auth(provider, { serverUrl: resource, scope: 'read write' }), '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 deniedEscalation = await post( + '/api/agent/authorizations/' + id, + { approve: true, scopes: ['read', 'write', 'hidden_read'] }, + registered.cookie, + ); + assert.equal(deniedEscalation.status, 400); const consent = await post( '/api/agent/authorizations/' + id, - { approve: true }, + { approve: true, scopes: ['read', 'write'] }, registered.cookie, ); assert.equal(consent.status, 201); @@ -795,7 +737,7 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation await client.connect( new StreamableHTTPClientTransport(new URL(resource), { authProvider: provider }), ); - assert.ok((await client.listTools()).tools.length >= 40); + assert.ok((await client.listTools()).tools.length === 39); await client.close(); async function exchange(params: Record) { const r = await fetch(root + '/token', { @@ -888,3 +830,147 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation await db.$disconnect(); } }); +test('fixed PAT durations, permanent session renewal, expiry, isolation and revocation are real', async () => { + const db = new PrismaClient(), + users: string[] = [], + clients: Client[] = []; + const { createHash } = await import('node:crypto'), + { hash } = await import('bcryptjs'); + const password = 'Temporary-pat-test-Only!'; + async function fixture() { + const u = await db.user.create({ + data: { + username: 'pat_' + randomUUID(), + passwordHash: await hash(password, 4), + idleMinutes: 0, + }, + }); + users.push(u.id); + const token = randomBytes(32).toString('hex'); + await db.session.create({ + data: { + id: createHash('sha256').update(token).digest('hex'), + userId: u.id, + expiresAt: new Date(Date.now() + 86400000), + }, + }); + return { id: u.id, cookie: 'wp_session=' + token }; + } + async function web(u: any, path: string, method = 'GET', body?: unknown) { + const r = await fetch(root + '/api' + path, { + method, + headers: { + Origin: origin, + Cookie: u.cookie, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: r.status, data: await r.json() }; + } + try { + const a = await fixture(), + b = await fixture(); + let permanent: any; + const issuedTokens = new Map(); + for (const days of [1, 3, 7, 30, 365, null]) { + const issued = await web(a, '/agent/tokens', 'POST', { + name: 'expiry fixture', + scopes: ['read'], + days, + password, + }); + assert.equal(issued.status, 201, JSON.stringify(issued.data)); + issuedTokens.set(issued.data.id, issued.data.token); + const row = await db.agentGrant.findUniqueOrThrow({ where: { id: issued.data.id } }); + assert.equal(row.accessDigest, createHash('sha256').update(issued.data.token).digest('hex')); + if (days === null) { + assert.equal(row.expiresAt, null); + permanent = issued.data; + } else { + assert.ok(row.expiresAt); + assert.ok(Math.abs(+row.expiresAt! - Date.now() - days * 86400000) < 5000); + } + } + assert.equal( + ( + await web(a, '/agent/tokens', 'POST', { + name: 'invalid duration', + scopes: ['read'], + days: 2, + password, + }) + ).status, + 400, + ); + for (const scopes of [ + ['read', 'sensitive'], + ['read', 'draft', 'write'], + ['read', 'hidden_write'], + ]) { + assert.equal( + ( + await web(a, '/agent/tokens', 'POST', { + name: 'invalid scopes', + scopes, + days: 1, + password, + }) + ).status, + 400, + ); + } + assert.equal((await web(a, '/agent/policy', 'PUT', { mode: 'direct', password })).status, 404); + const management = await web(a, '/agent'); + assert.equal(management.data.capabilities.length, 39); + assert.ok(!management.data.capabilities.some((t: any) => t.name === 'metal_price_set')); + assert.equal((await web(b, '/agent')).data.grants.length, 0); + assert.equal((await web(b, '/agent/connections/' + permanent.id, 'DELETE')).status, 200); + assert.equal( + (await db.agentGrant.findUniqueOrThrow({ where: { id: permanent.id } })).revokedAt, + null, + ); + const row = await db.agentGrant.findUniqueOrThrow({ where: { id: permanent.id } }); + await db.session.delete({ where: { id: row.sessionId } }); + const client = new Client({ name: 'Permanent PAT test', version: '1.31.0' }); + clients.push(client); + await client.connect( + new StreamableHTTPClientTransport(new URL(resource), { + requestInit: { headers: { Authorization: 'Bearer ' + permanent.token } }, + }), + ); + const result: any = await client.callTool({ name: 'connection_info', arguments: {} }); + assert.ok(!result.isError, JSON.stringify(result)); + assert.equal(result.structuredContent.data.expiresAt, null); + const business: any = await client.callTool({ + name: 'positions_list', + arguments: { limit: 1 }, + }); + assert.ok(!business.isError, JSON.stringify(business)); + assert.ok( + (await db.session.findUniqueOrThrow({ where: { id: row.sessionId } })).expiresAt > new Date(), + ); + assert.equal((await web(a, '/agent/connections/' + permanent.id, 'DELETE')).status, 200); + await assert.rejects(() => client.listTools()); + const finite = management.data.grants.find((g: any) => g.expiresAt); + await db.agentGrant.update({ where: { id: finite.id }, data: { expiresAt: new Date(0) } }); + const expiredClient = new Client({ name: 'Expired PAT test', version: '1.31.0' }); + clients.push(expiredClient); + await assert.rejects(() => + expiredClient.connect( + new StreamableHTTPClientTransport(new URL(resource), { + requestInit: { headers: { Authorization: 'Bearer ' + issuedTokens.get(finite.id) } }, + }), + ), + ); + const expired = await web(a, '/agent'); + assert.equal( + expired.data.grants.find((g: any) => g.id === finite.id).expiresAt, + '1970-01-01T00:00:00.000Z', + ); + } finally { + for (const c of clients) await c.close().catch(() => {}); + await db.user.deleteMany({ where: { id: { in: users } } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/test/privacy.test.ts b/apps/api/test/privacy.test.ts index a01c21d..353ab5c 100644 --- a/apps/api/test/privacy.test.ts +++ b/apps/api/test/privacy.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -9,7 +10,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user const db = new PrismaClient(), names: string[] = []; async function call(path: string, method = 'GET', data?: unknown, cookie = '') { - const res = await fetch(base + path, { + const res = await fixtureFetch(base + path, { method, headers: { Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!, @@ -118,7 +119,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user ); assert.equal(history[1].delta, '15'); const backup = (await call('/backup', 'GET', undefined, a.cookie)).data; - assert.equal(backup.version, 2); + assert.equal(backup.version, 9); assert.equal(backup.positions.find((p: { id: string }) => p.id === hidden).hidden, true); assert.equal( backup.positions.find((p: { id: string }) => p.id === hidden).revisions[0].date, @@ -192,7 +193,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user ); assert.equal((await call('/overview', 'GET', undefined, c.cookie)).data.net, '20.00'); assert.equal( - (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + (await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status, 201, ); assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '27.00'); @@ -267,11 +268,17 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user assert.equal((await call('/auth/me', 'GET', undefined, otherSession.cookie)).status, 200); const login = await call('/auth/login', 'POST', { username: a.username, password: a.password }); assert.equal( - (await call('/backup/import', 'POST', { confirmed: true, backup: legacy }, login.cookie)) - .status, - 201, + ( + await call( + '/backup/restore-fixture', + 'POST', + { confirmed: true, backup: legacy }, + login.cookie, + ) + ).status, + 400, ); - assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 2); + assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 0); } finally { for (const username of names) await db.user.deleteMany({ where: { username } }); await db.$disconnect(); diff --git a/apps/api/test/queries.test.ts b/apps/api/test/queries.test.ts index bd6e67d..219c887 100644 --- a/apps/api/test/queries.test.ts +++ b/apps/api/test/queries.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -15,7 +16,9 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and sessionId = createHash('sha256').update(token).digest('hex'); let restoredUserId: string | undefined; async function call(path: string) { - const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } }); + const response = await fixtureFetch(base + path, { + headers: { Cookie: 'wp_session=' + token }, + }); return { status: response.status, data: await response.json() }; } try { @@ -142,7 +145,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and date: '2026-09-03T00:01', }; const post = async (path: string, body: unknown, cookie = token) => { - const response = await fetch(base + path, { + const response = await fixtureFetch(base + path, { method: 'POST', headers: { Cookie: 'wp_session=' + cookie, @@ -179,7 +182,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and }, }); assert.equal( - (await post('/backup/import', { confirmed: true, backup }, restoredToken)).status, + (await post('/backup/restore-fixture', { confirmed: true, backup }, restoredToken)).status, 201, ); const restoredTransfer = await db.transfer.findFirstOrThrow({ where: { userId: restored.id } }); diff --git a/apps/api/test/security-backup.test.ts b/apps/api/test/security-backup.test.ts index d8cdd1d..c6934be 100644 --- a/apps/api/test/security-backup.test.ts +++ b/apps/api/test/security-backup.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -11,7 +12,7 @@ test('credentials rotate sessions; deleting paired and empty histories preserves ids: string[] = []; const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; async function call(path: string, cookie = '', method = 'GET', body?: unknown) { - const res = await fetch(base + path, { + const res = await fixtureFetch(base + path, { method, headers: { Cookie: cookie, @@ -141,6 +142,30 @@ test('credentials rotate sessions; deleting paired and empty histories preserves const backup: any = await readBackupZip(bytes); assert.equal(backup.positions.find((p: any) => p.id === emptyId).revisions.length, 0); assert.equal(backup.positions.find((p: any) => p.id === sourceId).revisions[0].amount, '-10'); + const beforeRejected = await db.position.count({ where: { userId: b.id } }); + for (const version of [1, 2, 3, 9]) { + const unsupported = new FormData(); + unsupported.set( + 'file', + new Blob([JSON.stringify({ ...backup, version })], { type: 'application/json' }), + 'backup.json', + ); + const rejected = await fetch(base + '/backup/upload', { + method: 'POST', + headers: { Cookie: b.cookie, Origin: origin }, + body: unsupported, + }); + assert.equal(rejected.status, 400); + } + for (const path of ['/backup/import', '/backup/preview']) { + const removed = await fetch(base + path, { + method: 'POST', + headers: { Cookie: b.cookie, Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirmed: true, backup }), + }); + assert.equal(removed.status, 404); + } + assert.equal(await db.position.count({ where: { userId: b.id } }), beforeRejected); const form = new FormData(); form.append('file', new Blob([bytes]), 'backup.zip'); const upload = await fetch(base + '/backup/upload', { @@ -234,10 +259,13 @@ test('credentials rotate sessions; deleting paired and empty histories preserves emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount, '-5', ); - assert.equal((await call('/backup/preview', b.cookie, 'POST', emptyAccountData)).status, 409); + assert.equal( + (await call('/backup/preview-fixture', b.cookie, 'POST', emptyAccountData)).status, + 409, + ); const fresh = await user(); assert.equal( - (await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status, + (await call('/backup/preview-fixture', fresh.cookie, 'POST', emptyAccountData)).status, 201, ); } finally { diff --git a/apps/api/test/settings-plans.test.ts b/apps/api/test/settings-plans.test.ts index e16f7b0..a0e5d7a 100644 --- a/apps/api/test/settings-plans.test.ts +++ b/apps/api/test/settings-plans.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import { request } from 'node:http'; @@ -28,6 +29,14 @@ async function fixture() { // Give this fixture suite its own loopback source address so independent auth // scenarios do not consume the existing suite's per-IP production rate limit. async function call(path: string, cookie: string, method = 'GET', body?: unknown) { + if (path.endsWith('-fixture')) { + const response = await fixtureFetch(base + path, { + method, + headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: response.status, data: await response.json(), cookie: null }; + } const data = body === undefined ? undefined : JSON.stringify(body); return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => { const req = request( @@ -127,7 +136,7 @@ test('session duration boundaries, privacy isolation and card settings survive b assert.equal(backup.preferences.sessionHours, 720); assert.equal(backup.preferences.requireHiddenPassword, false); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status, 201, ); const imported = (await call('/settings', b.cookie)).data; @@ -138,8 +147,8 @@ test('session duration boundaries, privacy isolation and card settings survive b delete backup.preferences.requireHiddenPassword; delete backup.preferences.overviewCards; assert.equal( - (await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status, - 201, + (await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status, + 400, ); const legacy = (await call('/settings', c.cookie)).data; assert.equal(legacy.sessionHours, 168); diff --git a/apps/api/test/transfers.test.ts b/apps/api/test/transfers.test.ts index 7b7ce49..002ca39 100644 --- a/apps/api/test/transfers.test.ts +++ b/apps/api/test/transfers.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -11,7 +12,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba const db = new PrismaClient(), names: string[] = []; async function call(path: string, cookie = '', method = 'GET', data?: unknown) { - const r = await fetch(base + path, { + const r = await fixtureFetch(base + path, { method, headers: { Cookie: cookie, @@ -227,12 +228,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba const broken = structuredClone(backup); broken.transfers[0].amount = '999'; assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup: broken })) + .status, 400, ); assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status, 201, ); const restored = (await call('/backup', b.cookie)).data; @@ -241,7 +243,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba assert.equal((await call('/settings', b.cookie)).data.showNotes, true); const c = await account(); assert.equal( - (await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status, 201, ); assert.equal((await call('/settings', c.cookie)).data.showNotes, false); diff --git a/apps/api/test/update-integration.test.ts b/apps/api/test/update-integration.test.ts index c9f28fc..cbc092e 100644 --- a/apps/api/test/update-integration.test.ts +++ b/apps/api/test/update-integration.test.ts @@ -1,3 +1,4 @@ +import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -12,7 +13,7 @@ test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP const minute = (delta = 0) => new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16); async function call(path: string, cookie = '', method = 'GET', body?: unknown) { - const res = await fetch(base + path, { + const res = await fixtureFetch(base + path, { method, headers: { Cookie: cookie, @@ -186,7 +187,7 @@ test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP const backup = (await call('/backup', a.cookie)).data; assert.equal(backup.schedules.length, 3); assert.equal( - (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + (await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status, 201, ); assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0); diff --git a/apps/api/test/zip.test.ts b/apps/api/test/zip.test.ts index 9f59ff1..ea98f23 100644 --- a/apps/api/test/zip.test.ts +++ b/apps/api/test/zip.test.ts @@ -7,14 +7,24 @@ import { packBackup, readBackupZip } from '../src/zip'; const empty = () => validateBackup({ format: 'worthpath', - version: 2, + version: 9, exportedAt: new Date().toISOString(), baseCurrency: 'CNY', - preferences: { hiddenMenus: ['asset'], showNotes: false, idleMinutes: 9 }, + preferences: { + hiddenMenus: ['asset'], + showNotes: false, + idleMinutes: 9, + accountGroupOrder: [], + sessionHours: 168, + requireHiddenPassword: true, + overviewCards: ['net'], + includeIndependentAssets: true, + }, currencies: ['CNY'], icons: [], transfers: [], schedules: [], + metalPrices: [], positions: [], rates: [], links: [], @@ -48,7 +58,10 @@ test('ZIP contains separate JSON files and restores settings without authenticat 'settings.json', 'transfers.json', ]); - assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i); + assert.doesNotMatch( + Object.values(contents).join('\n'), + /"(?:passwordHash|password|token|sessionId|userId|accessDigest|refreshDigest)"/i, + ); assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b); }); test('ZIP rejects incomplete files, tampering, unknown entries and invalid entry names', async () => { @@ -69,6 +82,15 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000 const position = (count: number) => ({ id: randomUUID(), name: 'count acceptance', + groupName: '', + iconId: null, + included: true, + importedFromId: null, + metalType: null, + metalGrams: null, + metalCostPerGram: null, + metalPurity: '1', + autoValuation: false, kind: 'asset', side: 'asset', category: 'other', @@ -100,102 +122,31 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000 ); }); -test('legacy v3 ZIP remains readable without icons', async () => { - const contents = packBackup(empty()); - delete contents['icons.json']; - delete contents['transfers.json']; - delete contents['schedules.json']; - const manifest = JSON.parse(contents['manifest.json']); - manifest.version = 3; - manifest.files = manifest.files.filter( - (f: { name: string }) => !['icons.json', 'transfers.json', 'schedules.json'].includes(f.name), - ); - contents['manifest.json'] = JSON.stringify(manifest); - const restored = validateBackup(await readBackupZip(await archive(contents))); - assert.equal(restored.icons, undefined); - assert.deepEqual(restored.positions, []); +test('all historical ZIP versions and JSON formats are rejected', async () => { + for (const version of [3, 4, 5, 6, 7, 8]) { + const contents = packBackup(empty()); + const manifest = JSON.parse(contents['manifest.json']); + manifest.version = version; + contents['manifest.json'] = JSON.stringify(manifest); + await assert.rejects(async () => readBackupZip(await archive(contents))); + } + for (const version of [1, 2, 3]) assert.throws(() => validateBackup({ ...empty(), version })); }); - -test('legacy v4 ZIP remains readable without transfers', async () => { - const contents = packBackup(empty()); - delete contents['transfers.json']; - delete contents['schedules.json']; - const manifest = JSON.parse(contents['manifest.json']); - manifest.version = 4; - manifest.files = manifest.files.filter( - (f: { name: string }) => !['transfers.json', 'schedules.json'].includes(f.name), - ); - contents['manifest.json'] = JSON.stringify(manifest); - const restored = validateBackup(await readBackupZip(await archive(contents))); - assert.deepEqual(restored.icons, []); - assert.equal(restored.transfers, undefined); - assert.equal(restored.preferences?.showNotes, false); - assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']); -}); - -test('ZIP v7 restores groups and schedules while v6 remains readable', async () => { - const id = randomUUID(), - stamp = new Date().toISOString(); - const b = validateBackup({ - ...empty(), - positions: [ - { - id, - name: '账户', - groupName: '日常', - kind: 'account', - side: 'asset', - category: 'bank', - currency: 'CNY', - notes: '', - archived: false, - hidden: false, - createdAt: stamp, - updatedAt: stamp, - revisions: [ - { - id: randomUUID(), - amount: '-10', - date: '2026-09-01T10:00', - notes: '', - reason: 'balance', - createdAt: stamp, - updatedAt: stamp, - }, - ], - }, - ], - schedules: [ - { - id: randomUUID(), - name: '租金', - operation: 'expense', - sourceId: id, - targetId: null, - amount: '100', - received: '0', - nextAt: '2026-11-01T10:00', - intervalDays: 30, - enabled: true, - notes: '', - }, - ], - }); - const contents = packBackup(b); - assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b); - delete contents['schedules.json']; - const manifest = JSON.parse(contents['manifest.json']); - manifest.version = 6; - manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'schedules.json'); - contents['manifest.json'] = JSON.stringify(manifest); - assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined); -}); - -test('ZIP settings retain group order while missing legacy order remains optional', async () => { +test('current backups require complete settings and metadata, with no legacy defaults', async () => { const b = empty(); - b.preferences!.accountGroupOrder = ['理财', '', '未分组', '日常']; - const restored = validateBackup(await readBackupZip(await archive(packBackup(b)))); - assert.deepEqual(restored.preferences!.accountGroupOrder, b.preferences!.accountGroupOrder); - const legacy = validateBackup(await readBackupZip(await archive(packBackup(empty())))); - assert.equal(legacy.preferences!.accountGroupOrder, undefined); + b.preferences.accountGroupOrder = ['日常', '']; + assert.deepEqual(validateBackup(await readBackupZip(await archive(packBackup(b)))), b); + for (const key of Object.keys(b.preferences)) { + const incomplete = structuredClone(b); + delete (incomplete.preferences as any)[key]; + assert.throws(() => validateBackup(incomplete)); + } + for (const key of ['icons', 'transfers', 'schedules', 'metalPrices']) { + const incomplete = structuredClone(b); + delete (incomplete as any)[key]; + assert.throws(() => validateBackup(incomplete)); + } + assert.throws(() => + validateBackup({ ...b, preferences: { ...b.preferences, showSidebar: false } }), + ); }); diff --git a/apps/web/src/AgentConnections.tsx b/apps/web/src/AgentConnections.tsx index 4e8de66..b061eaf 100644 --- a/apps/web/src/AgentConnections.tsx +++ b/apps/web/src/AgentConnections.tsx @@ -5,14 +5,14 @@ type Connection = { id: string; name: string; scopes: string[]; - expiresAt: string; + expiresAt: string | null; revokedAt: string | null; clientId: string | null; }; type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string }; type Management = { + capabilities: { name: string; description: string; scope: string; destructive?: boolean }[]; mcpUrl: string; - mode: string; grants: Connection[]; operations: Operation[]; calls: { id: string; tool: string; status: string; createdAt: string }[]; @@ -23,8 +23,6 @@ type Preview = { status: string; description: string; impact: unknown; - web?: string; - sensitive: boolean; result?: unknown; }; type Consent = { @@ -34,6 +32,72 @@ type Consent = { redirectUri: string; resource: string; }; +const scopeDetails = [ + { + id: 'read', + title: '只读查询', + summary: '查看账目和状态,不修改金额', + detail: + '查询账户、资产、债务、关联、余额和历史、净资产趋势、日历、计划与执行记录、汇率、贵金属报价、设置和图标;读取本连接操作及文件状态。隐藏账户默认不允许读取,须额外授权。', + }, + { + id: 'draft', + title: '创建草稿', + summary: '提出普通修改,等待你在网页确认', + detail: + '可提出创建或编辑持仓、余额/估值更新、转账和借贷还款、历史更正/删除、计划管理、分组排序、设置修改及私有图标上传等普通操作。草稿明确保存参数和影响,10 分钟过期;不会直接入账。', + }, + { + id: 'write', + title: '普通写入', + summary: '普通修改直接执行', + detail: + '覆盖普通修改和创建草稿的操作。授予 write 后普通修改直接执行;draft 等待网页确认;read 拒绝修改。归档、历史删除和转账撤销也属于普通写入,请按所需范围授权。', + }, +]; +const durationOptions = [ + ['1', '1 天'], + ['3', '3 天'], + ['7', '7 天'], + ['30', '30 天'], + ['365', '1 年(365 天)'], + ['permanent', '永久(可撤销)'], +]; +const capabilityGroups = [ + { + title: '账户、资产与债务', + pattern: /^(positions_|position_|balance_|metal_holding|metal_configure|metal_value)/, + summary: '创建和管理持仓、分组、图标、归档与计入开关;贵金属按克数管理。', + }, + { + title: '资金变化与历史', + pattern: /^(transfer|loan|debt|history|revision)/, + summary: '转账、借入借出、收款还款、记录更正、删除与撤销,沿用金额重算规则。', + }, + { + title: '统计、汇率与计划', + pattern: /^(overview|trend|calendar|schedule|rates|metals_)/, + summary: '净资产与趋势、日历筛选、定时计划及执行记录、汇率和参考报价。', + }, + { + title: '设置与私有图标', + pattern: /^(settings|icons|icon_|backup|import|data_|credentials|hidden|file_)/, + summary: '非安全类用户设置、私有图库上传与读取;敏感操作仅在网站执行。', + }, +]; +function instructions(url: string) { + return [ + '请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url, + '接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;支持自定义 Bearer 头的客户端可在安全的凭据设置中填写个人令牌。不要让我把密码或令牌粘贴到对话中。', + '先 tools/list 发现工具,调用 connection_info 核对权限:read 只读,draft 创建待网页确认的草稿,write 直接普通写入。隐藏账户读写以连接附加授权为准。', + '查询:用 positions_list 搜索对象;遇到同名先让我选择稳定 ID;按 limit/offset 或 cursor 翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。', + '写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。', + '若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。', + 'MCP 不提供密码修改、清空数据、备份导出恢复、共享图标发布及汇率或报价修改;这些流程由我在网站操作。', + '我接下来会告诉你具体任务;在我提出任务前先不要修改数据。未发现的工具或未授予的权限请如实告知,不要猜测成功。', + ].join('\n\n'); +} + export function AgentConnections() { const [data, setData] = useState(null), [error, setError] = useState(''), @@ -41,7 +105,14 @@ export function AgentConnections() { [token, setToken] = useState(''), [preview, setPreview] = useState(null), [consent, setConsent] = useState(null), - [busy, setBusy] = useState(false); + [busy, setBusy] = useState(false), + [tokenPermission, setTokenPermission] = useState('read'), + [tokenHiddenRead, setTokenHiddenRead] = useState(false), + [tokenHiddenWrite, setTokenHiddenWrite] = useState(false), + [agentView, setAgentView] = useState('connect'), + [consentLevel, setConsentLevel] = useState('read'), + [consentHiddenRead, setConsentHiddenRead] = useState(false), + [consentHiddenWrite, setConsentHiddenWrite] = useState(false); const load = async () => setData(await api('/agent')); const act = async (work: () => Promise) => { if (busy) return; @@ -68,216 +139,417 @@ export function AgentConnections() { if (operation) await show(operation); }); }, []); + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setMessage(label); + } catch { + setError('复制失败,请从下方可选中文本手动复制'); + } + }; return (
-

连接 Agent

+
+
+

连接 Agent

+

管理接入、操作权限和需要你确认的修改。

+
+ {data && {data.capabilities.length} 个工具 · 按连接授权} +
{error && (

{error}

)} - {message &&

{message}

} + {message && ( +

+ {message} +

+ )} {data && ( - <> -

远程 MCP 地址

- {data.mcpUrl} - -

- 支持 Streamable HTTP。OAuth 客户端使用此地址发现授权信息,浏览器登录 WorthPath - 后审核连接名称、回调地址和权限。访问令牌每小时过期,刷新令牌最多 30 - 天并在使用时轮换。个人令牌适用于支持 Bearer 头的客户端。 -

-

- 已验证客户端:官方 TypeScript SDK 1.31.0(OAuth / Bearer)。其他 Agent - 尚未验证;不会保证任意客户端兼容。 -

-
- 官方 SDK 的已验证接入配置 -
{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n  requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}
- -

- 先在终端设置 MCP_ACCESS_TOKEN,再运行复制的命令。完整 OAuth 示例及安全存储说明见 - docs/mcp.md;此配置仅针对官方 SDK 1.31.0。 -

-
-
{ - 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('写入策略已保存'); - }); - }} - > -

写入策略

- - - -
-

OAuth 授权与个人令牌

-

- read 查询;draft 创建草稿;write 按写入策略执行普通写入;sensitive - 发起敏感操作,仍须网页验证密码。令牌到期可新建并撤销旧令牌。 -

-
{ - 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(); - }); - }} - > - - - {['read', 'draft', 'write', 'sensitive'].map((s) => ( - + ? ' · 待确认' + : ''} + ))} - - -
- {token && ( -
-

完整令牌仅显示这一次,请妥善保存。

- {token} - - -
+ + {agentView === 'connect' && ( + <> +
+

开始连接

+

远程 MCP 地址

+
+ {data.mcpUrl} + +
+

在客户端添加地址,完成网页授权,再复制教程并描述任务。

+
+ 查看连接步骤 +
    +
  1. + 配置连接:在支持远程 MCP OAuth 的客户端中填写地址,选择 + Streamable HTTP;在 WorthPath 网页登录后审核名称、回调和权限。 +
  2. +
  3. + 选择连接权限:先从 read + 或草稿模式开始,根据需要增加权限。OAuth 访问令牌 1 小时,刷新授权最多 30 + 天并轮换。 +
  4. +
  5. + 把操作说明交给 Agent + :复制下方教程,不包含任何密码或令牌,再描述具体任务。 +
  6. +
  7. + 确认并查看结果:草稿在网页确认,Agent + 可查询完成状态。随时在“已授权连接”撤销。 +
  8. +
+
+
+

可直接复制给 Agent 的教程

+ +
+
+ 预览 Agent 使用教程 +