feat: simplify agent permissions and require current ZIP backups
This commit is contained in:
1 parent
efc2c7c734
commit
91c489513a
51 files changed
+1516
-1106
No files matched your search
@@ -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`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。
|
设计见 [数据模型与结构](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` 禁止此通配符。测试结束后恢复准确的站点地址。
|
内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对
|
|||||||
|
|
||||||
账户页面和账户详情提供“账户间转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。
|
账户页面和账户详情提供“账户间转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。
|
||||||
|
|
||||||
债务分为借入(应付负债)和借出(应收资产),支持借入到账、借出付款、收回应收和偿还应付。账户与债务在同一事务中记账并自动关联;双方详情和往来记录可互相导航。已有债务可录入剩余余额,新发生借贷可先建零余额债务再使用联动操作。ZIP v6 保留操作类型,兼容旧 v3/v4/v5 ZIP 和 v1/v2 JSON。
|
债务分为借入(应付负债)和借出(应收资产),支持借入到账、借出付款、收回应收和偿还应付。账户与债务在同一事务中记账并自动关联;双方详情和往来记录可互相导航。已有债务可录入剩余余额,新发生借贷可先建零余额债务再使用联动操作。当前 ZIP v9 保留操作类型,不保留任何旧备份兼容。
|
||||||
|
|
||||||
支持简中、English、繁中,在登录页或顶部切换,语言保存在当前浏览器。用户名称和备注保留原文。账户/资产/债务按页查询当前余额,历史和往来使用最多 100 条的游标分页;趋势默认最近 90 天,支持日、周、月。查询实现、迁移步骤、实测性能和验证边界见 [性能与联动验收](docs/performance.md)。
|
支持简中、English、繁中,在登录页或顶部切换,语言保存在当前浏览器。用户名称和备注保留原文。账户/资产/债务按页查询当前余额,历史和往来使用最多 100 条的游标分页;趋势默认最近 90 天,支持日、周、月。查询实现、迁移步骤、实测性能和验证边界见 [性能与联动验收](docs/performance.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `AgentGrant` MODIFY `expiresAt` DATETIME(3) NULL COMMENT '访问令牌到期时间,UTC;空表示可撤销的永久个人令牌';
|
||||||
@@ -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';
|
||||||
@@ -68,7 +68,7 @@ model AgentCall {
|
|||||||
@@index([userId,createdAt])
|
@@index([userId,createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户的 Agent 写入策略
|
/// 已停用的历史 Agent 策略数据,保留原数据但不参与权限判定
|
||||||
model AgentPolicy {
|
model AgentPolicy {
|
||||||
/// 所属用户标识,用于数据隔离
|
/// 所属用户标识,用于数据隔离
|
||||||
userId String @id @db.Char(36)
|
userId String @id @db.Char(36)
|
||||||
@@ -117,7 +117,7 @@ model AgentGrant {
|
|||||||
clientId String? @db.Char(36)
|
clientId String? @db.Char(36)
|
||||||
/// 用户可见连接名称
|
/// 用户可见连接名称
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
/// 权限列表:read、draft、write、sensitive
|
/// 权限列表:read、draft 或 write;隐藏账户附加权限 hidden_read、hidden_write
|
||||||
scopes Json
|
scopes Json
|
||||||
/// 令牌适用的规范 MCP 资源地址
|
/// 令牌适用的规范 MCP 资源地址
|
||||||
resource String @db.VarChar(500)
|
resource String @db.VarChar(500)
|
||||||
@@ -125,8 +125,8 @@ model AgentGrant {
|
|||||||
accessDigest String @unique @db.Char(64)
|
accessDigest String @unique @db.Char(64)
|
||||||
/// 刷新令牌 SHA-256 摘要,使用后轮换
|
/// 刷新令牌 SHA-256 摘要,使用后轮换
|
||||||
refreshDigest String? @unique @db.Char(64)
|
refreshDigest String? @unique @db.Char(64)
|
||||||
/// 到期时间,UTC
|
/// 访问令牌到期时间,UTC;空表示可撤销的永久个人令牌
|
||||||
expiresAt DateTime
|
expiresAt DateTime?
|
||||||
/// 刷新授权到期时间,UTC
|
/// 刷新授权到期时间,UTC
|
||||||
refreshExpiresAt DateTime?
|
refreshExpiresAt DateTime?
|
||||||
/// 撤销时间,UTC;空表示未撤销
|
/// 撤销时间,UTC;空表示未撤销
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Only the current ZIP container format is supported.
|
||||||
|
export const BACKUP_ZIP_VERSION = 9;
|
||||||
+76
-85
@@ -1,5 +1,6 @@
|
|||||||
|
import { BACKUP_ZIP_VERSION } from './backup-format';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { metalConfig, metalPriceInput } from './metals';
|
import { metalConfig, metalPriceInput, storedMetalPurity } from './metals';
|
||||||
import { scheduleInput } from './schedules';
|
import { scheduleInput } from './schedules';
|
||||||
import { movementDeltas } from './movement';
|
import { movementDeltas } from './movement';
|
||||||
import { pairedReasons } from './validation';
|
import { pairedReasons } from './validation';
|
||||||
@@ -21,7 +22,7 @@ import { Response } from 'express';
|
|||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { tmpdir } from 'node:os';
|
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 { join } from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { archiveBackup, readBackupZip, MAX_UPLOAD_BYTES } from './zip';
|
import { archiveBackup, readBackupZip, MAX_UPLOAD_BYTES } from './zip';
|
||||||
@@ -56,22 +57,31 @@ const timestamp = z.iso
|
|||||||
);
|
);
|
||||||
const record = positionMeta
|
const record = positionMeta
|
||||||
.extend({
|
.extend({
|
||||||
metalType: z.enum(['gold', 'silver']).nullable().optional(),
|
groupName: z.string().max(60),
|
||||||
metalGrams: amount.nullable().optional(),
|
included: z.boolean(),
|
||||||
metalCostPerGram: metalConfig.shape.metalCostPerGram,
|
iconId: z.string().uuid().nullable(),
|
||||||
metalPurity: metalConfig.shape.metalPurity.optional(),
|
notes: z.string().max(2000),
|
||||||
autoValuation: z.boolean().optional(),
|
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']),
|
kind: z.enum(['account', 'asset', 'debt']),
|
||||||
side: z.enum(['asset', 'liability']),
|
side: z.enum(['asset', 'liability']),
|
||||||
currency,
|
currency,
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
importedFromId: z.string().uuid().nullable().optional(),
|
importedFromId: z.string().uuid().nullable(),
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
revisions: z.array(
|
revisions: z.array(
|
||||||
revisionInput.extend({
|
revisionInput.extend({
|
||||||
id: z.string().uuid(),
|
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,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
}),
|
}),
|
||||||
@@ -81,26 +91,23 @@ const record = positionMeta
|
|||||||
const backupSchema = z
|
const backupSchema = z
|
||||||
.object({
|
.object({
|
||||||
format: z.literal('worthpath'),
|
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(),
|
exportedAt: z.iso.datetime(),
|
||||||
baseCurrency: currency,
|
baseCurrency: currency,
|
||||||
currencies: z.array(currency).max(10),
|
currencies: z.array(currency).max(10),
|
||||||
preferences: z
|
preferences: z
|
||||||
.object({
|
.object({
|
||||||
showSidebar: z.boolean().optional(),
|
hiddenMenus: hiddenMenus,
|
||||||
hiddenMenus: hiddenMenus.optional(),
|
accountGroupOrder: accountGroupOrder,
|
||||||
accountGroupOrder: accountGroupOrder.optional(),
|
sessionHours: sessionHours,
|
||||||
sessionHours: sessionHours.optional(),
|
requireHiddenPassword: z.boolean(),
|
||||||
requireHiddenPassword: z.boolean().optional(),
|
overviewCards: overviewCards,
|
||||||
overviewCards: overviewCards.optional(),
|
includeIndependentAssets: z.boolean(),
|
||||||
includeIndependentAssets: z.boolean().optional(),
|
showNotes: z.boolean(),
|
||||||
showNotes: z.boolean().optional(),
|
|
||||||
idleMinutes: z.number().int().min(0).max(1440),
|
idleMinutes: z.number().int().min(0).max(1440),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict(),
|
||||||
.optional(),
|
icons: z.array(
|
||||||
icons: z
|
|
||||||
.array(
|
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
@@ -110,39 +117,39 @@ const backupSchema = z
|
|||||||
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
),
|
||||||
.optional(),
|
transfers: z.array(
|
||||||
transfers: z
|
|
||||||
.array(
|
|
||||||
transferInput.safeExtend({
|
transferInput.safeExtend({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
importedFromId: z.string().uuid().nullable().optional(),
|
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(),
|
sourceRevisionId: z.string().uuid(),
|
||||||
targetRevisionId: z.string().uuid(),
|
targetRevisionId: z.string().uuid(),
|
||||||
sourceCurrency: currency,
|
sourceCurrency: currency,
|
||||||
targetCurrency: currency,
|
targetCurrency: currency,
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.optional(),
|
schedules: z.array(
|
||||||
schedules: z
|
|
||||||
.array(
|
|
||||||
scheduleInput.safeExtend({
|
scheduleInput.safeExtend({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
importedFromId: z.string().uuid().nullable().optional(),
|
importedFromId: z.string().uuid().nullable(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
completed: z.boolean().default(false),
|
completed: z.boolean(),
|
||||||
|
targetId: z.string().uuid().nullable(),
|
||||||
|
received: amount,
|
||||||
|
notes: z.string().max(2000),
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.optional(),
|
metalPrices: z.array(
|
||||||
metalPrices: z
|
|
||||||
.array(
|
|
||||||
metalPriceInput.extend({
|
metalPriceInput.extend({
|
||||||
source: z.enum(['manual', 'goldapi']),
|
source: z.enum(['manual', 'goldapi']),
|
||||||
quotedAt: timestamp,
|
quotedAt: timestamp,
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.optional(),
|
|
||||||
positions: z.array(record),
|
positions: z.array(record),
|
||||||
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
||||||
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
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');
|
if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID');
|
||||||
const origins = b.positions.map((p) => p.importedFromId || p.id);
|
const origins = b.positions.map((p) => p.importedFromId || p.id);
|
||||||
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
|
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('重复贵金属报价');
|
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 (
|
if (
|
||||||
iconIds.size !== (b.icons || []).length ||
|
iconIds.size !== b.icons.length ||
|
||||||
b.positions.some((p) => p.iconId && !iconIds.has(p.iconId))
|
b.positions.some((p) => p.iconId && !iconIds.has(p.iconId))
|
||||||
)
|
)
|
||||||
throw new BadRequestException('图标关联无效');
|
throw new BadRequestException('图标关联无效');
|
||||||
@@ -172,8 +179,7 @@ export function validateBackup(raw: unknown) {
|
|||||||
metalType: p.metalType,
|
metalType: p.metalType,
|
||||||
metalGrams: p.metalGrams,
|
metalGrams: p.metalGrams,
|
||||||
metalCostPerGram: p.metalCostPerGram,
|
metalCostPerGram: p.metalCostPerGram,
|
||||||
metalPurity: p.metalPurity || '1',
|
autoValuation: p.autoValuation,
|
||||||
autoValuation: p.autoValuation || false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
positionInput.parse({
|
positionInput.parse({
|
||||||
@@ -203,7 +209,7 @@ export function validateBackup(raw: unknown) {
|
|||||||
if (p.kind !== 'account' && p.revisions.some((r) => r.amount.startsWith('-')))
|
if (p.kind !== 'account' && p.revisions.some((r) => r.amount.startsWith('-')))
|
||||||
throw new BadRequestException('仅账户支持负余额');
|
throw new BadRequestException('仅账户支持负余额');
|
||||||
const planIds = new Set<string>();
|
const planIds = new Set<string>();
|
||||||
for (const plan of b.schedules || []) {
|
for (const plan of b.schedules) {
|
||||||
const source = ids.get(plan.sourceId),
|
const source = ids.get(plan.sourceId),
|
||||||
target = plan.targetId ? ids.get(plan.targetId) : null;
|
target = plan.targetId ? ids.get(plan.targetId) : null;
|
||||||
if (
|
if (
|
||||||
@@ -222,7 +228,7 @@ export function validateBackup(raw: unknown) {
|
|||||||
}
|
}
|
||||||
const transferIds = new Set<string>(),
|
const transferIds = new Set<string>(),
|
||||||
usedRevisions = new Set<string>();
|
usedRevisions = new Set<string>();
|
||||||
for (const t of b.transfers || []) {
|
for (const t of b.transfers) {
|
||||||
const source = ids.get(t.sourceId),
|
const source = ids.get(t.sourceId),
|
||||||
target = ids.get(t.targetId);
|
target = ids.get(t.targetId);
|
||||||
const origin = t.importedFromId || t.id;
|
const origin = t.importedFromId || t.id;
|
||||||
@@ -328,16 +334,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
await this.prune(true);
|
await this.prune(true);
|
||||||
}
|
}
|
||||||
private async uploadedData(path: string) {
|
private async uploadedData(path: string) {
|
||||||
const handle = await open(path, 'r');
|
return readBackupZip(path);
|
||||||
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/, ''));
|
|
||||||
}
|
}
|
||||||
constructor(private db: Database) {}
|
constructor(private db: Database) {}
|
||||||
async snapshot(userId: string) {
|
async snapshot(userId: string) {
|
||||||
@@ -429,7 +426,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
const schedules = await client.schedule.findMany({ where: { userId } });
|
const schedules = await client.schedule.findMany({ where: { userId } });
|
||||||
return backupSchema.parse({
|
return backupSchema.parse({
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 2,
|
version: BACKUP_ZIP_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
baseCurrency: user.baseCurrency,
|
baseCurrency: user.baseCurrency,
|
||||||
preferences: {
|
preferences: {
|
||||||
@@ -569,13 +566,13 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
data.rates.sort((a, b) =>
|
data.rates.sort((a, b) =>
|
||||||
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
|
(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),
|
(a.metalType + a.currency + a.date).localeCompare(b.metalType + b.currency + b.date),
|
||||||
);
|
);
|
||||||
data.currencies.sort();
|
data.currencies.sort();
|
||||||
data.transfers?.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.schedules.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
data.icons.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||||
}
|
}
|
||||||
async clearStatus(r: UserRequest) {
|
async clearStatus(r: UserRequest) {
|
||||||
@@ -611,15 +608,15 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
async preview(r: UserRequest, raw: unknown) {
|
async preview(r: UserRequest, raw: unknown) {
|
||||||
const b = validateBackup(raw),
|
const b = validateBackup(raw),
|
||||||
existing = await this.data(r.userId);
|
existing = await this.data(r.userId);
|
||||||
for (const i of b.icons || []) await validateStoredIcon(i.image, i.hash);
|
for (const i of b.icons) await validateStoredIcon(i.image, i.hash);
|
||||||
this.conflicts(b, existing);
|
this.conflicts(b, existing);
|
||||||
return {
|
return {
|
||||||
positions: b.positions.length,
|
positions: b.positions.length,
|
||||||
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
||||||
rates: b.rates.length,
|
rates: b.rates.length,
|
||||||
icons: (b.icons || []).length,
|
icons: b.icons.length,
|
||||||
transfers: (b.transfers || []).length,
|
transfers: b.transfers.length,
|
||||||
schedules: (b.schedules || []).length,
|
schedules: b.schedules.length,
|
||||||
baseCurrency: b.baseCurrency,
|
baseCurrency: b.baseCurrency,
|
||||||
currentBaseCurrency: existing.baseCurrency,
|
currentBaseCurrency: existing.baseCurrency,
|
||||||
message:
|
message:
|
||||||
@@ -650,7 +647,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
.parse(raw),
|
.parse(raw),
|
||||||
b = validateBackup(backup);
|
b = validateBackup(backup);
|
||||||
const iconData = new Map<string, Buffer>();
|
const iconData = new Map<string, Buffer>();
|
||||||
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(
|
return this.db.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const ps = await tx.position.findMany({
|
const ps = await tx.position.findMany({
|
||||||
@@ -671,7 +668,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
} as unknown as Backup;
|
} as unknown as Backup;
|
||||||
this.conflicts(b, existing);
|
this.conflicts(b, existing);
|
||||||
const iconMapping = new Map<string, string>();
|
const iconMapping = new Map<string, string>();
|
||||||
for (const i of b.icons || []) {
|
for (const i of b.icons) {
|
||||||
const row = await tx.icon.upsert({
|
const row = await tx.icon.upsert({
|
||||||
where: { ownerId_hash_shared: { ownerId: r.userId, hash: i.hash, shared: false } },
|
where: { ownerId_hash_shared: { ownerId: r.userId, hash: i.hash, shared: false } },
|
||||||
create: {
|
create: {
|
||||||
@@ -742,7 +739,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
});
|
});
|
||||||
originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id));
|
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 = {
|
const key = {
|
||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
metalType: q.metalType,
|
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) },
|
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({
|
await tx.transfer.create({
|
||||||
data: {
|
data: {
|
||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
@@ -779,7 +776,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
createdAt: new Date(t.createdAt),
|
createdAt: new Date(t.createdAt),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
for (const plan of b.schedules || []) {
|
for (const plan of b.schedules) {
|
||||||
const { id, importedFromId, ...v } = plan;
|
const { id, importedFromId, ...v } = plan;
|
||||||
await tx.schedule.create({
|
await tx.schedule.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -814,17 +811,17 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|||||||
where: { id: r.userId },
|
where: { id: r.userId },
|
||||||
data: {
|
data: {
|
||||||
baseCurrency: b.baseCurrency,
|
baseCurrency: b.baseCurrency,
|
||||||
idleMinutes: b.preferences?.idleMinutes,
|
idleMinutes: b.preferences.idleMinutes,
|
||||||
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
|
hiddenMenus: b.preferences.hiddenMenus.join(','),
|
||||||
showNotes: b.preferences?.showNotes,
|
showNotes: b.preferences.showNotes,
|
||||||
accountGroupOrder: b.preferences?.accountGroupOrder,
|
accountGroupOrder: b.preferences.accountGroupOrder,
|
||||||
sessionHours: b.preferences?.sessionHours,
|
sessionHours: b.preferences.sessionHours,
|
||||||
requireHiddenPassword: b.preferences?.requireHiddenPassword,
|
requireHiddenPassword: b.preferences.requireHiddenPassword,
|
||||||
overviewCards: b.preferences?.overviewCards,
|
overviewCards: b.preferences.overviewCards,
|
||||||
includeIndependentAssets: b.preferences?.includeIndependentAssets,
|
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 } });
|
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
||||||
return { ok: true, positions: b.positions.length };
|
return { ok: true, positions: b.positions.length };
|
||||||
},
|
},
|
||||||
@@ -861,10 +858,4 @@ export class BackupController {
|
|||||||
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
return this.service.clear(r, raw);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,8 @@ import { TransfersBusinessService } from '../transfers';
|
|||||||
import { SchedulesBusinessService, scheduleInput } from '../schedules';
|
import { SchedulesBusinessService, scheduleInput } from '../schedules';
|
||||||
import { CalendarBusinessService } from '../calendar';
|
import { CalendarBusinessService } from '../calendar';
|
||||||
import { SettingsBusinessService } from '../rates';
|
import { SettingsBusinessService } from '../rates';
|
||||||
import { MetalsBusinessService, metalConfig, metalHoldingInput, metalPriceInput } from '../metals';
|
import { MetalsBusinessService, metalConfig, metalHoldingInput } from '../metals';
|
||||||
import { IconsBusinessService } from '../icons';
|
import { IconsBusinessService } from '../icons';
|
||||||
import { BackupBusinessService } from '../backup';
|
|
||||||
import {
|
import {
|
||||||
positionInput,
|
positionInput,
|
||||||
positionMeta,
|
positionMeta,
|
||||||
@@ -49,9 +48,8 @@ export type ToolDefinition = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
schema: z.ZodObject<any>;
|
schema: z.ZodObject<any>;
|
||||||
scope: 'read' | 'write' | 'sensitive';
|
scope: 'read' | 'write';
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
web?: 'credentials' | 'reveal' | 'clear';
|
|
||||||
run?: (r: UserRequest, p: any) => Promise<unknown>;
|
run?: (r: UserRequest, p: any) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -65,7 +63,6 @@ export class AgentCatalogue {
|
|||||||
settings: SettingsBusinessService,
|
settings: SettingsBusinessService,
|
||||||
metals: MetalsBusinessService,
|
metals: MetalsBusinessService,
|
||||||
icons: IconsBusinessService,
|
icons: IconsBusinessService,
|
||||||
backup: BackupBusinessService,
|
|
||||||
) {
|
) {
|
||||||
const read = (
|
const read = (
|
||||||
name: string,
|
name: string,
|
||||||
@@ -282,13 +279,13 @@ export class AgentCatalogue {
|
|||||||
),
|
),
|
||||||
write(
|
write(
|
||||||
'settings_update',
|
'settings_update',
|
||||||
'修改个人设置、本位币、分组排序、登录时长及纳入统计配置。隐私设置变更需网页确认。',
|
'修改个人设置、本位币、分组排序、登录时长及纳入统计配置。安全设置仅在网站修改。',
|
||||||
settingsInput,
|
settingsInput.safeExtend({
|
||||||
|
requireHiddenPassword: z.never().optional(),
|
||||||
|
sessionHours: z.never().optional(),
|
||||||
|
}),
|
||||||
(r, p) => settings.update(r, p, undefined as any),
|
(r, p) => settings.update(r, p, undefined as any),
|
||||||
),
|
),
|
||||||
write('rates_refresh', '重试公共日汇率更新,失败保留原币和历史汇率。', empty, (r) =>
|
|
||||||
settings.refresh(r),
|
|
||||||
),
|
|
||||||
write(
|
write(
|
||||||
'metal_holding_create',
|
'metal_holding_create',
|
||||||
'按克数创建金银资产,无需市场价格;买入每克成本可选,缺少报价时待估值。日期为 UTC+8 业务时间。',
|
'按克数创建金银资产,无需市场价格;买入每克成本可选,缺少报价时待估值。日期为 UTC+8 业务时间。',
|
||||||
@@ -296,18 +293,9 @@ export class AgentCatalogue {
|
|||||||
(r, p) => metals.create(r, p),
|
(r, p) => metals.create(r, p),
|
||||||
),
|
),
|
||||||
read('metals_prices', '最近 100 条金银每克报价和更新状态。', empty, (r) => metals.list(r)),
|
read('metals_prices', '最近 100 条金银每克报价和更新状态。', empty, (r) => metals.list(r)),
|
||||||
write('metals_refresh', '刷新贵金属报价并沿用现有自动估值规则。', empty, (r) =>
|
|
||||||
metals.refresh(r),
|
|
||||||
),
|
|
||||||
write(
|
|
||||||
'metal_price_set',
|
|
||||||
'设置指定日期、币种、品种每克价格;price 十进制字符串,可能触发自动估值历史。',
|
|
||||||
metalPriceInput,
|
|
||||||
(r, p) => metals.manual(r, p),
|
|
||||||
),
|
|
||||||
write(
|
write(
|
||||||
'metal_configure',
|
'metal_configure',
|
||||||
'设置金银重量、纯度和自动估值,复用现有估值规则。',
|
'设置金银重量和自动估值,复用现有估值规则。',
|
||||||
z.object({ id, data: metalConfig }).strict(),
|
z.object({ id, data: metalConfig }).strict(),
|
||||||
(r, p) => metals.configure(r, p.id, p.data),
|
(r, p) => metals.configure(r, p.id, p.data),
|
||||||
),
|
),
|
||||||
@@ -328,46 +316,6 @@ export class AgentCatalogue {
|
|||||||
.strict(),
|
.strict(),
|
||||||
(r, p) => icons.list(r, p.q, String(p.page)),
|
(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) {
|
get(name: string) {
|
||||||
|
|||||||
+10
-48
@@ -8,28 +8,20 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Request, Response, Express } from 'express';
|
import { Request, Response, Express } from 'express';
|
||||||
import multer, { diskStorage, memoryStorage } from 'multer';
|
import multer, { memoryStorage } from 'multer';
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { unlink } from 'node:fs/promises';
|
|
||||||
import { AgentOAuth, urls } from './oauth';
|
import { AgentOAuth, urls } from './oauth';
|
||||||
import { UserRequest } from '../auth';
|
import { UserRequest } from '../auth';
|
||||||
import { BackupBusinessService } from '../backup';
|
|
||||||
import { IconsBusinessService } from '../icons';
|
import { IconsBusinessService } from '../icons';
|
||||||
import { MAX_UPLOAD_BYTES } from '../zip';
|
|
||||||
import { Database } from '../database';
|
|
||||||
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||||
|
|
||||||
type Ticket = {
|
type Ticket = {
|
||||||
userId: string;
|
userId: string;
|
||||||
grantId: string;
|
grantId: string;
|
||||||
expires: number;
|
expires: number;
|
||||||
kind: 'backup' | 'icon' | 'download' | 'image';
|
kind: 'icon' | 'image';
|
||||||
snapshot?: string;
|
|
||||||
iconId?: string;
|
iconId?: string;
|
||||||
buffer?: Buffer;
|
buffer?: Buffer;
|
||||||
name?: string;
|
name?: string;
|
||||||
preview?: unknown;
|
|
||||||
token?: string;
|
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AgentFiles implements OnModuleDestroy {
|
export class AgentFiles implements OnModuleDestroy {
|
||||||
@@ -39,9 +31,7 @@ export class AgentFiles implements OnModuleDestroy {
|
|||||||
}, 60000).unref();
|
}, 60000).unref();
|
||||||
constructor(
|
constructor(
|
||||||
private oauth: AgentOAuth,
|
private oauth: AgentOAuth,
|
||||||
private backup: BackupBusinessService,
|
|
||||||
private icons: IconsBusinessService,
|
private icons: IconsBusinessService,
|
||||||
private db: Database,
|
|
||||||
) {}
|
) {}
|
||||||
onModuleDestroy() {
|
onModuleDestroy() {
|
||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
@@ -60,19 +50,15 @@ export class AgentFiles implements OnModuleDestroy {
|
|||||||
kind,
|
kind,
|
||||||
iconId,
|
iconId,
|
||||||
expires: Date.now() + 600000,
|
expires: Date.now() + 600000,
|
||||||
...(kind === 'download' ? { snapshot: await this.backup.snapshot(r.userId) } : {}),
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
fileId: id,
|
fileId: id,
|
||||||
url: new URL('/api/agent/files/' + id, urls().issuer).toString(),
|
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 <your access token>' },
|
headers: { Authorization: 'Bearer <your access token>' },
|
||||||
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||||
maxBytes: kind === 'backup' ? MAX_UPLOAD_BYTES : 2 * 1024 * 1024,
|
maxBytes: 2 * 1024 * 1024,
|
||||||
format:
|
format: kind === 'image' ? 'image/png' : 'multipart/form-data; field file; image',
|
||||||
kind === 'backup'
|
|
||||||
? 'multipart/form-data; field file; WorthPath ZIP/JSON'
|
|
||||||
: 'multipart/form-data; field file; image',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private ticket(r: UserRequest, grantId: string, id: string) {
|
private ticket(r: UserRequest, grantId: string, id: string) {
|
||||||
@@ -94,9 +80,7 @@ export class AgentFiles implements OnModuleDestroy {
|
|||||||
const t = this.ticket(r, grantId, id);
|
const t = this.ticket(r, grantId, id);
|
||||||
return {
|
return {
|
||||||
fileId: id,
|
fileId: id,
|
||||||
uploaded: !!t.buffer || !!t.token,
|
uploaded: !!t.buffer,
|
||||||
token: t.token,
|
|
||||||
preview: t.preview,
|
|
||||||
expiresAt: new Date(t.expires).toISOString(),
|
expiresAt: new Date(t.expires).toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,13 +101,6 @@ export class AgentFiles implements OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
install(app: Express) {
|
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({
|
const memory = multer({
|
||||||
storage: memoryStorage(),
|
storage: memoryStorage(),
|
||||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 },
|
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 },
|
||||||
@@ -136,38 +113,23 @@ export class AgentFiles implements OnModuleDestroy {
|
|||||||
res.status(405).end();
|
res.status(405).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((req.method === 'POST') !== (t.kind === 'backup' || t.kind === 'icon')) {
|
if ((req.method === 'POST') !== (t.kind === 'icon')) {
|
||||||
res.status(405).end();
|
res.status(405).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
if (t.kind === 'download') await this.backup.download(r, res, t.snapshot);
|
await this.icons.image(r, t.iconId!, res);
|
||||||
else await this.icons.image(r, t.iconId!, res);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selected = grant.scopes as string[];
|
const selected = grant.scopes as string[];
|
||||||
if (!selected.includes('draft') && !selected.includes('write'))
|
if (!selected.includes('draft') && !selected.includes('write'))
|
||||||
throw new ForbiddenException('上传需要 draft 或 write 权限');
|
throw new ForbiddenException('上传需要 draft 或 write 权限');
|
||||||
if (
|
|
||||||
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ===
|
|
||||||
'readonly'
|
|
||||||
)
|
|
||||||
throw new ForbiddenException('当前策略为只读');
|
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((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('请选择文件');
|
if (!req.file) throw new BadRequestException('请选择文件');
|
||||||
try {
|
t.buffer = req.file.buffer;
|
||||||
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)));
|
res.json(await this.inspect(r, grant.id, String(req.params.id)));
|
||||||
} catch (e) {
|
|
||||||
if (req.file.path) await unlink(req.file.path).catch(() => {});
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!res.headersSent)
|
if (!res.headersSent)
|
||||||
res
|
res
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
|
||||||
Delete,
|
Delete,
|
||||||
Req,
|
Req,
|
||||||
Param,
|
Param,
|
||||||
@@ -18,6 +17,7 @@ import { Database } from '../database';
|
|||||||
import { AuthService, UserRequest } from '../auth';
|
import { AuthService, UserRequest } from '../auth';
|
||||||
import { AgentOAuth, urls, scopeInput } from './oauth';
|
import { AgentOAuth, urls, scopeInput } from './oauth';
|
||||||
import { AgentOperations } from './operations';
|
import { AgentOperations } from './operations';
|
||||||
|
import { protocolTools, tokenDays } from './information';
|
||||||
@Controller('api/agent')
|
@Controller('api/agent')
|
||||||
export class AgentManagementController {
|
export class AgentManagementController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -62,34 +62,26 @@ export class AgentManagementController {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
mcpUrl: urls().resource.toString(),
|
mcpUrl: urls().resource.toString(),
|
||||||
mode:
|
capabilities: [
|
||||||
(await this.db.agentPolicy.findUnique({ where: { userId: r.userId } }))?.mode || 'draft',
|
...this.operations.tools.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
scope: t.scope,
|
||||||
|
destructive: !!t.destructive,
|
||||||
|
})),
|
||||||
|
...protocolTools,
|
||||||
|
],
|
||||||
grants,
|
grants,
|
||||||
operations,
|
operations,
|
||||||
calls,
|
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) {
|
@Post('tokens') async token(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||||
const p = z
|
const p = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
scopes: scopeInput,
|
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),
|
password: z.string().max(72),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -118,8 +110,11 @@ export class AgentManagementController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() raw: unknown,
|
@Body() raw: unknown,
|
||||||
) {
|
) {
|
||||||
const { approve } = z.object({ approve: z.boolean() }).strict().parse(raw);
|
const { approve, scopes } = z
|
||||||
return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve);
|
.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) {
|
@Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
return this.operations.preview(r.userId, z.string().uuid().parse(id));
|
return this.operations.preview(r.userId, z.string().uuid().parse(id));
|
||||||
|
|||||||
+50
-11
@@ -20,12 +20,19 @@ import {
|
|||||||
InvalidTargetError,
|
InvalidTargetError,
|
||||||
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
} 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
|
export const scopeInput = z
|
||||||
.array(z.enum(scopes))
|
.array(z.enum(scopes))
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(4)
|
.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');
|
export const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||||
const secret = () => randomBytes(32).toString('base64url');
|
const secret = () => randomBytes(32).toString('base64url');
|
||||||
export function urls() {
|
export function urls() {
|
||||||
@@ -115,7 +122,15 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response) {
|
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response) {
|
||||||
this.resource(params.resource);
|
this.resource(params.resource);
|
||||||
const selected = params.scopes?.length ? params.scopes : ['read'];
|
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({
|
const row = await this.db.agentAuthorization.create({
|
||||||
data: {
|
data: {
|
||||||
clientId: client.client_id,
|
clientId: client.client_id,
|
||||||
@@ -141,10 +156,15 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
resource: p.resource,
|
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 () => {
|
return this.db.atomic(async () => {
|
||||||
await this.pending(id);
|
await this.pending(id);
|
||||||
const row = await this.db.agentAuthorization.findUniqueOrThrow({ where: { 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 code = secret();
|
||||||
const changed = await this.db.agentAuthorization.updateMany({
|
const changed = await this.db.agentAuthorization.updateMany({
|
||||||
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
|
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
|
||||||
@@ -152,6 +172,7 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
userId,
|
userId,
|
||||||
status: approved ? 'approved' : 'denied',
|
status: approved ? 'approved' : 'denied',
|
||||||
codeDigest: approved ? digest(code) : null,
|
codeDigest: approved ? digest(code) : null,
|
||||||
|
parameters: { ...parameters, scopes: allowed },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!changed.count) throw new BadRequestException('授权请求已处理');
|
if (!changed.count) throw new BadRequestException('授权请求已处理');
|
||||||
@@ -175,14 +196,25 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
throw new InvalidGrantError('Invalid authorization code');
|
throw new InvalidGrantError('Invalid authorization code');
|
||||||
return (row.parameters as any).codeChallenge as string;
|
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(),
|
const access = secret(),
|
||||||
refresh = clientId ? secret() : undefined,
|
refresh = clientId ? secret() : undefined,
|
||||||
sessionId = digest(secret());
|
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;
|
refreshExpiresAt = clientId ? new Date(Date.now() + 30 * 86400000) : null;
|
||||||
await this.db.session.create({
|
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({
|
const grant = await this.db.agentGrant.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -203,7 +235,7 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
tokens: {
|
tokens: {
|
||||||
access_token: access,
|
access_token: access,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expires_in: Math.floor(days * 86400),
|
...(days === null ? {} : { expires_in: Math.floor(days * 86400) }),
|
||||||
scope: selected.join(' '),
|
scope: selected.join(' '),
|
||||||
...(refresh ? { refresh_token: refresh } : {}),
|
...(refresh ? { refresh_token: refresh } : {}),
|
||||||
} as OAuthTokens,
|
} as OAuthTokens,
|
||||||
@@ -296,7 +328,7 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
if (
|
if (
|
||||||
!row ||
|
!row ||
|
||||||
row.revokedAt ||
|
row.revokedAt ||
|
||||||
row.expiresAt <= new Date() ||
|
(row.expiresAt ? row.expiresAt <= new Date() : !!row.clientId) ||
|
||||||
row.resource !== urls().resource.toString()
|
row.resource !== urls().resource.toString()
|
||||||
)
|
)
|
||||||
throw new InvalidTokenError('Expired, revoked or invalid resource token');
|
throw new InvalidTokenError('Expired, revoked or invalid resource token');
|
||||||
@@ -304,7 +336,9 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
token,
|
token,
|
||||||
clientId: row.clientId || row.id,
|
clientId: row.clientId || row.id,
|
||||||
scopes: row.scopes as string[],
|
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),
|
resource: new URL(row.resource),
|
||||||
extra: { grantId: row.id, userId: row.userId },
|
extra: { grantId: row.id, userId: row.userId },
|
||||||
};
|
};
|
||||||
@@ -320,7 +354,12 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
}
|
}
|
||||||
async grant(id: string, userId?: string) {
|
async grant(id: string, userId?: string) {
|
||||||
const row = await this.db.agentGrant.findFirst({
|
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 连接已过期或撤销');
|
if (!row) throw new ForbiddenException('Agent 连接已过期或撤销');
|
||||||
return row;
|
return row;
|
||||||
|
|||||||
+43
-113
@@ -6,12 +6,10 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma, AgentGrant } from '@prisma/client';
|
import { Prisma, AgentGrant } from '@prisma/client';
|
||||||
import { compare } from 'bcryptjs';
|
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Database } from '../database';
|
import { Database } from '../database';
|
||||||
import { AuthBusinessService, UserRequest } from '../auth';
|
import { UserRequest } from '../auth';
|
||||||
import { BackupBusinessService } from '../backup';
|
|
||||||
import { AgentOAuth, digest, webLink } from './oauth';
|
import { AgentOAuth, digest, webLink } from './oauth';
|
||||||
import { AgentCatalogue, ToolDefinition, empty } from './catalogue';
|
import { AgentCatalogue, ToolDefinition, empty } from './catalogue';
|
||||||
import { AgentFiles } from './files';
|
import { AgentFiles } from './files';
|
||||||
@@ -46,41 +44,23 @@ export class AgentOperations {
|
|||||||
private db: Database,
|
private db: Database,
|
||||||
private oauth: AgentOAuth,
|
private oauth: AgentOAuth,
|
||||||
catalogue: AgentCatalogue,
|
catalogue: AgentCatalogue,
|
||||||
private auth: AuthBusinessService,
|
|
||||||
private backup: BackupBusinessService,
|
|
||||||
private files: AgentFiles,
|
private files: AgentFiles,
|
||||||
) {
|
) {
|
||||||
this.tools = [
|
this.tools = [
|
||||||
...catalogue.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',
|
name: 'icon_publish',
|
||||||
description:
|
description: '保存已上传私有图标,不允许发布共享图标。先 file_upload_request(kind=icon)。',
|
||||||
'保存已上传图标,shared=true 发布到共享库,须中文名称及网页确认。先 file_upload_request(kind=icon)。',
|
|
||||||
schema: z
|
schema: z
|
||||||
.object({
|
.object({
|
||||||
fileId: z.string().uuid(),
|
fileId: z.string().uuid(),
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
shared: z.boolean().default(false),
|
shared: z.literal(false).default(false),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
scope: 'write',
|
scope: 'write',
|
||||||
run: (r, p) => this.files.publishIcon(r, r.agentGrantId!, p.fileId, p.name, p.shared),
|
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) {
|
get(name: string) {
|
||||||
@@ -88,13 +68,32 @@ export class AgentOperations {
|
|||||||
if (!t) throw new BadRequestException('未知工具');
|
if (!t) throw new BadRequestException('未知工具');
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
async context(grant: AgentGrant) {
|
async context(grant: AgentGrant, writing = false) {
|
||||||
const s = await this.db.session.findUnique({ where: { id: grant.sessionId } });
|
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('连接会话失效,请重新授权');
|
if (!s || s.expiresAt <= new Date()) throw new ForbiddenException('连接会话失效,请重新授权');
|
||||||
return {
|
return {
|
||||||
userId: grant.userId,
|
userId: grant.userId,
|
||||||
sessionId: grant.sessionId,
|
sessionId: grant.sessionId,
|
||||||
revealed: !!s.revealUntil && +s.revealUntil > Date.now(),
|
revealed: (grant.scopes as string[]).includes(writing ? 'hidden_write' : 'hidden_read'),
|
||||||
agent: true,
|
agent: true,
|
||||||
agentGrantId: grant.id,
|
agentGrantId: grant.id,
|
||||||
cookies: {},
|
cookies: {},
|
||||||
@@ -134,35 +133,26 @@ export class AgentOperations {
|
|||||||
]);
|
]);
|
||||||
return digest(stable(plain(data)));
|
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) {
|
private async permission(grant: AgentGrant, t: ToolDefinition, p: any) {
|
||||||
const selected = grant.scopes as string[],
|
const selected = grant.scopes as string[];
|
||||||
mode =
|
|
||||||
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ||
|
|
||||||
'draft';
|
|
||||||
if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限');
|
if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限');
|
||||||
if (t.scope === 'read') return { mode, sensitive: false };
|
const mode = selected.includes('write')
|
||||||
const sensitive = this.sensitive(t, p);
|
? 'direct'
|
||||||
if (sensitive && !selected.includes('sensitive'))
|
: selected.includes('draft')
|
||||||
throw new ForbiddenException('此操作需要 sensitive 权限');
|
? 'draft'
|
||||||
if (!sensitive && !selected.includes('write') && !selected.includes('draft'))
|
: 'readonly';
|
||||||
throw new ForbiddenException('缺少 draft 或 write 权限');
|
if (t.scope === 'read') return { mode };
|
||||||
if (mode === 'readonly' && !['backup_export', 'hidden_unlock_request'].includes(t.name))
|
if (mode === 'readonly') throw new ForbiddenException('本连接只有只读权限');
|
||||||
throw new ForbiddenException('当前用户写入策略为只读');
|
if (!selected.includes('hidden_write') && (p.hidden === true || p.data?.hidden === true))
|
||||||
return { mode, sensitive };
|
throw new ForbiddenException('缺少隐藏账户修改权限');
|
||||||
|
return { mode };
|
||||||
}
|
}
|
||||||
async call(grantId: string, name: string, input: any) {
|
async call(grantId: string, name: string, input: any) {
|
||||||
const t = this.get(name);
|
const t = this.get(name);
|
||||||
const parsed = (t.scope === 'read' ? t.schema : t.schema.safeExtend(writing)).parse(input);
|
const parsed = (t.scope === 'read' ? t.schema : t.schema.safeExtend(writing)).parse(input);
|
||||||
const { idempotencyKey, expectedState, ...p } = parsed as any;
|
const { idempotencyKey, expectedState, ...p } = parsed as any;
|
||||||
const grant = await this.oauth.grant(grantId),
|
const grant = await this.oauth.grant(grantId);
|
||||||
permission = await this.permission(grant, t, p);
|
await this.permission(grant, t, p);
|
||||||
if (t.scope === 'read') return t.run!(await this.context(grant), p);
|
if (t.scope === 'read') return t.run!(await this.context(grant), p);
|
||||||
const hash = digest(stable({ tool: name, parameters: p, expectedState }));
|
const hash = digest(stable({ tool: name, parameters: p, expectedState }));
|
||||||
return this.db.atomic(async () => {
|
return this.db.atomic(async () => {
|
||||||
@@ -192,11 +182,7 @@ export class AgentOperations {
|
|||||||
expiresAt: new Date(Date.now() + 600000),
|
expiresAt: new Date(Date.now() + 600000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (
|
if (access.mode === 'draft' || !(fresh.scopes as string[]).includes('write'))
|
||||||
access.sensitive ||
|
|
||||||
access.mode === 'draft' ||
|
|
||||||
!(fresh.scopes as string[]).includes('write')
|
|
||||||
)
|
|
||||||
return this.view(row);
|
return this.view(row);
|
||||||
const result = await this.execute(t, fresh, p);
|
const result = await this.execute(t, fresh, p);
|
||||||
return this.view(
|
return this.view(
|
||||||
@@ -208,7 +194,7 @@ export class AgentOperations {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
private async execute(t: ToolDefinition, grant: AgentGrant, p: any) {
|
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) {
|
private view(row: any) {
|
||||||
return {
|
return {
|
||||||
@@ -234,22 +220,9 @@ export class AgentOperations {
|
|||||||
const t = this.get(row.tool),
|
const t = this.get(row.tool),
|
||||||
grant = await this.oauth.grant(row.grantId, userId);
|
grant = await this.oauth.grant(row.grantId, userId);
|
||||||
let impact: unknown = { parameters: row.parameters, message: t.description };
|
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 {
|
return {
|
||||||
...this.view(row),
|
...this.view(row),
|
||||||
impact,
|
impact,
|
||||||
web: t.web,
|
|
||||||
sensitive: this.sensitive(t, row.parameters),
|
|
||||||
description: t.description,
|
description: t.description,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -257,10 +230,6 @@ export class AgentOperations {
|
|||||||
const input = z
|
const input = z
|
||||||
.object({
|
.object({
|
||||||
approve: z.boolean(),
|
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()
|
.strict()
|
||||||
.parse(raw);
|
.parse(raw);
|
||||||
@@ -280,44 +249,10 @@ export class AgentOperations {
|
|||||||
const grant = await this.oauth.grant(row.grantId, r.userId),
|
const grant = await this.oauth.grant(row.grantId, r.userId),
|
||||||
t = this.get(row.tool),
|
t = this.get(row.tool),
|
||||||
p = row.parameters as any;
|
p = row.parameters as any;
|
||||||
const access = await this.permission(grant, t, p);
|
await this.permission(grant, t, p);
|
||||||
if (access.sensitive) {
|
|
||||||
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
|
||||||
if (!input.password || !(await compare(input.password, u.passwordHash)))
|
|
||||||
throw new ForbiddenException('请验证当前密码');
|
|
||||||
}
|
|
||||||
if ((await this.state(r.userId)) !== row.snapshot)
|
if ((await this.state(r.userId)) !== row.snapshot)
|
||||||
throw new ConflictException('账目已变化,请取消并重新创建操作');
|
throw new ConflictException('账目已变化,请取消并重新创建操作');
|
||||||
let result: unknown;
|
const result = await this.execute(t, grant, p);
|
||||||
if (t.web === 'credentials') {
|
|
||||||
result = await this.auth.changeCredentials(
|
|
||||||
r,
|
|
||||||
{
|
|
||||||
currentPassword: input.password,
|
|
||||||
username: input.username,
|
|
||||||
newPassword: input.newPassword,
|
|
||||||
},
|
|
||||||
res,
|
|
||||||
);
|
|
||||||
await this.db.agentGrant.updateMany({
|
|
||||||
where: { userId: r.userId, id: { not: grant.id } },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
await this.db.session.create({
|
|
||||||
data: { id: grant.sessionId, userId: r.userId, expiresAt: new Date(Date.now() + 300000) },
|
|
||||||
});
|
|
||||||
await this.db.agentGrant.update({
|
|
||||||
where: { id: grant.id },
|
|
||||||
data: { scopes: ['read'], expiresAt: new Date(Date.now() + 300000), refreshDigest: null },
|
|
||||||
});
|
|
||||||
} else if (t.web === 'reveal')
|
|
||||||
result = await this.auth.reveal(
|
|
||||||
Object.assign(Object.create(r), { sessionId: grant.sessionId }),
|
|
||||||
{ password: input.password },
|
|
||||||
);
|
|
||||||
else if (t.web === 'clear')
|
|
||||||
result = await this.backup.clear(r, { confirmation: input.confirmation });
|
|
||||||
else result = await this.execute(t, grant, p);
|
|
||||||
return this.view(
|
return this.view(
|
||||||
await this.db.agentOperation.update({
|
await this.db.agentOperation.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -326,16 +261,11 @@ export class AgentOperations {
|
|||||||
);
|
);
|
||||||
}, 300000);
|
}, 300000);
|
||||||
}
|
}
|
||||||
async uploadRequest(grantId: string, kind: 'backup' | 'icon') {
|
async uploadRequest(grantId: string, kind: 'icon') {
|
||||||
const grant = await this.oauth.grant(grantId);
|
const grant = await this.oauth.grant(grantId);
|
||||||
const selected = grant.scopes as string[];
|
const selected = grant.scopes as string[];
|
||||||
if (!selected.includes('draft') && !selected.includes('write'))
|
if (!selected.includes('draft') && !selected.includes('write'))
|
||||||
throw new ForbiddenException('上传需要 draft 或 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);
|
return this.files.issue(await this.context(grant), grantId, kind);
|
||||||
}
|
}
|
||||||
async fileStatus(grantId: string, id: string) {
|
async fileStatus(grantId: string, id: string) {
|
||||||
|
|||||||
@@ -183,13 +183,13 @@ export class AgentTransport {
|
|||||||
register(
|
register(
|
||||||
'file_upload_request',
|
'file_upload_request',
|
||||||
'创建受 Bearer 保护的短期文件上传入口;multipart/form-data 的 file 字段。',
|
'创建受 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),
|
(p) => this.operations.uploadRequest(grantId, p.kind),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
register(
|
register(
|
||||||
'file_status',
|
'file_status',
|
||||||
'查看此连接文件上传状态、预检结果和备份导入 token。',
|
'查看此连接私有图标上传状态,不返回令牌。',
|
||||||
z.object({ fileId: z.string().uuid() }).strict(),
|
z.object({ fileId: z.string().uuid() }).strict(),
|
||||||
(p) => this.operations.fileStatus(grantId, p.fileId),
|
(p) => this.operations.fileStatus(grantId, p.fileId),
|
||||||
);
|
);
|
||||||
@@ -201,7 +201,7 @@ export class AgentTransport {
|
|||||||
);
|
);
|
||||||
register(
|
register(
|
||||||
'connection_info',
|
'connection_info',
|
||||||
'查询本连接权限、到期时间、资源和用户写入策略;不返回任何令牌。',
|
'查询本连接权限、到期时间、资源及隐藏账户权限;不返回任何令牌。',
|
||||||
z.object({}).strict(),
|
z.object({}).strict(),
|
||||||
async () => {
|
async () => {
|
||||||
const g = await this.oauth.grant(grantId);
|
const g = await this.oauth.grant(grantId);
|
||||||
@@ -210,9 +210,13 @@ export class AgentTransport {
|
|||||||
scopes: g.scopes,
|
scopes: g.scopes,
|
||||||
expiresAt: g.expiresAt,
|
expiresAt: g.expiresAt,
|
||||||
resource: g.resource,
|
resource: g.resource,
|
||||||
writePolicy:
|
permission: (g.scopes as string[]).includes('write')
|
||||||
(await this.db.agentPolicy.findUnique({ where: { userId: g.userId } }))?.mode ||
|
? 'write'
|
||||||
'draft',
|
: (g.scopes as string[]).includes('draft')
|
||||||
|
? 'draft'
|
||||||
|
: 'read',
|
||||||
|
readHidden: (g.scopes as string[]).includes('hidden_read'),
|
||||||
|
writeHidden: (g.scopes as string[]).includes('hidden_write'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-44
@@ -30,14 +30,15 @@ import {
|
|||||||
} from './validation';
|
} from './validation';
|
||||||
import { businessTime, businessDay } from './calculation';
|
import { businessTime, businessDay } from './calculation';
|
||||||
export const metalType = z.enum(['gold', 'silver']);
|
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
|
export const metalConfig = z
|
||||||
.object({
|
.object({
|
||||||
metalType,
|
metalType,
|
||||||
metalGrams: amount.refine((v) => new Decimal(v).gt(0), '重量必须大于零'),
|
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
|
metalCostPerGram: rateValue
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -152,8 +153,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!p) throw new NotFoundException('贵金属资产不存在或已归档');
|
if (!p) throw new NotFoundException('贵金属资产不存在或已归档');
|
||||||
if (!p.metalType || !p.metalGrams)
|
if (!p.metalType || !p.metalGrams) throw new BadRequestException('请先设置贵金属品种和重量');
|
||||||
throw new BadRequestException('请先设置贵金属品种、重量和纯度');
|
|
||||||
const quote = await tx.metalPrice.findFirst({
|
const quote = await tx.metalPrice.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -163,7 +163,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
},
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
});
|
});
|
||||||
if (!quote) throw new BadRequestException('缺少该币种的贵金属价格,请刷新或手动录价');
|
if (!quote) throw new BadRequestException('缺少该币种的贵金属价格,请刷新报价后重试');
|
||||||
const value = metalValue(
|
const value = metalValue(
|
||||||
p.metalGrams.toString(),
|
p.metalGrams.toString(),
|
||||||
p.metalPurity.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);
|
if (p.autoValuation) await this.apply(tx, userId, p.id, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const message = '贵金属参考价已更新;同日手动价格已保留,已开启的自动估价已记入历史';
|
const message = '贵金属参考价已更新,已开启的自动估价已记入历史';
|
||||||
this.attempts.set(userId, today());
|
this.attempts.set(userId, today());
|
||||||
this.outcomes.set(userId, { state: 'ok', message, attemptedAt: new Date().toISOString() });
|
this.outcomes.set(userId, { state: 'ok', message, attemptedAt: new Date().toISOString() });
|
||||||
return { message };
|
return { message };
|
||||||
} catch {
|
} catch {
|
||||||
this.outcomes.set(userId, {
|
this.outcomes.set(userId, {
|
||||||
state: 'error',
|
state: 'error',
|
||||||
message: '贵金属价格更新失败,已有价格与估值已保留,可手动录价',
|
message: '贵金属价格更新失败,已有价格与估值已保留,请稍后重试',
|
||||||
attemptedAt: new Date().toISOString(),
|
attemptedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
throw new BadGatewayException('贵金属价格更新失败,已有价格与估值已保留,可手动录价');
|
throw new BadGatewayException('贵金属价格更新失败,已有价格与估值已保留,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
this.running.delete(userId);
|
this.running.delete(userId);
|
||||||
}
|
}
|
||||||
@@ -323,37 +323,6 @@ export class MetalsBusinessService {
|
|||||||
refresh(r: UserRequest) {
|
refresh(r: UserRequest) {
|
||||||
return this.metals.refresh(r.userId);
|
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) {
|
async create(r: UserRequest, body: unknown) {
|
||||||
const v = metalHoldingInput.parse(body);
|
const v = metalHoldingInput.parse(body);
|
||||||
const when = toBusinessDate(v.date);
|
const when = toBusinessDate(v.date);
|
||||||
@@ -436,9 +405,6 @@ export class MetalsController {
|
|||||||
@Post('refresh') refresh(@Req() r: UserRequest) {
|
@Post('refresh') refresh(@Req() r: UserRequest) {
|
||||||
return this.service.refresh(r);
|
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(
|
@Put(':id') async configure(
|
||||||
@Req() r: UserRequest,
|
@Req() r: UserRequest,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
+2
-10
@@ -1,6 +1,6 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { metalConfig, metalPriceInput } from './metals';
|
import { metalConfig, metalHoldingInput } from './metals';
|
||||||
import { scheduleInput } from './schedules';
|
import { scheduleInput } from './schedules';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +47,7 @@ export function setupOpenApi(app: INestApplication) {
|
|||||||
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
||||||
'PATCH /api/settings': settingsInput,
|
'PATCH /api/settings': settingsInput,
|
||||||
'PUT /api/metals/{id}': metalConfig,
|
'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/clear': z.object({ confirmation: z.literal('确定清空') }),
|
||||||
'POST /api/backup/import-file': z.object({
|
'POST /api/backup/import-file': z.object({
|
||||||
token: z.string().uuid(),
|
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, {
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
|||||||
+15
-41
@@ -3,8 +3,8 @@ import * as yauzl from 'yauzl';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { BACKUP_ZIP_VERSION } from './backup-format';
|
||||||
import type { Backup } from './backup';
|
import type { Backup } from './backup';
|
||||||
import { accountGroupOrder, sessionHours, overviewCards } from './validation';
|
|
||||||
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
||||||
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
|
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
|
||||||
const files = [
|
const files = [
|
||||||
@@ -38,9 +38,9 @@ export function packBackup(b: Backup) {
|
|||||||
),
|
),
|
||||||
'links.json': b.links,
|
'links.json': b.links,
|
||||||
'rates.json': b.rates,
|
'rates.json': b.rates,
|
||||||
'icons.json': b.icons || [],
|
'icons.json': b.icons,
|
||||||
'transfers.json': b.transfers || [],
|
'transfers.json': b.transfers,
|
||||||
'schedules.json': b.schedules || [],
|
'schedules.json': b.schedules,
|
||||||
};
|
};
|
||||||
const contents = Object.fromEntries(
|
const contents = Object.fromEntries(
|
||||||
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
||||||
@@ -48,7 +48,7 @@ export function packBackup(b: Backup) {
|
|||||||
contents['manifest.json'] = JSON.stringify(
|
contents['manifest.json'] = JSON.stringify(
|
||||||
{
|
{
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 8,
|
version: BACKUP_ZIP_VERSION,
|
||||||
exportedAt: b.exportedAt,
|
exportedAt: b.exportedAt,
|
||||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||||
},
|
},
|
||||||
@@ -123,30 +123,17 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
const manifest = z
|
const manifest = z
|
||||||
.object({
|
.object({
|
||||||
format: z.literal('worthpath'),
|
format: z.literal('worthpath'),
|
||||||
version: z.union([
|
version: z.literal(BACKUP_ZIP_VERSION),
|
||||||
z.literal(3),
|
|
||||||
z.literal(4),
|
|
||||||
z.literal(5),
|
|
||||||
z.literal(6),
|
|
||||||
z.literal(7),
|
|
||||||
z.literal(8),
|
|
||||||
]),
|
|
||||||
exportedAt: z.iso.datetime(),
|
exportedAt: z.iso.datetime(),
|
||||||
files: z
|
files: z
|
||||||
.array(
|
.array(
|
||||||
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
||||||
)
|
)
|
||||||
.min(files.length - 3)
|
.length(files.length),
|
||||||
.max(files.length),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.parse(parse('manifest.json'));
|
.parse(parse('manifest.json'));
|
||||||
const expected = files.filter(
|
const expected = files;
|
||||||
(f) =>
|
|
||||||
(manifest.version >= 4 || f !== 'icons.json') &&
|
|
||||||
(manifest.version >= 5 || f !== 'transfers.json') &&
|
|
||||||
(manifest.version >= 7 || f !== 'schedules.json'),
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
contents.size !== expected.length + 1 ||
|
contents.size !== expected.length + 1 ||
|
||||||
new Set(manifest.files.map((f) => f.name)).size !== expected.length ||
|
new Set(manifest.files.map((f) => f.name)).size !== expected.length ||
|
||||||
@@ -157,21 +144,8 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
const settings = z
|
const settings = z
|
||||||
.object({
|
.object({
|
||||||
baseCurrency: z.string(),
|
baseCurrency: z.string(),
|
||||||
metalPrices: z.array(z.record(z.string(), z.unknown())).optional(),
|
metalPrices: z.array(z.record(z.string(), z.unknown())),
|
||||||
preferences: z
|
preferences: z.record(z.string(), z.unknown()),
|
||||||
.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(),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.parse(parse('settings.json'));
|
.parse(parse('settings.json'));
|
||||||
@@ -194,20 +168,20 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 2,
|
version: BACKUP_ZIP_VERSION,
|
||||||
exportedAt: manifest.exportedAt,
|
exportedAt: manifest.exportedAt,
|
||||||
...settings,
|
...settings,
|
||||||
currencies: parse('currencies.json'),
|
currencies: parse('currencies.json'),
|
||||||
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
|
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
|
||||||
links: parse('links.json'),
|
links: parse('links.json'),
|
||||||
rates: parse('rates.json'),
|
rates: parse('rates.json'),
|
||||||
...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}),
|
transfers: parse('transfers.json'),
|
||||||
...(manifest.version >= 7 ? { schedules: parse('schedules.json') } : {}),
|
schedules: parse('schedules.json'),
|
||||||
...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}),
|
icons: parse('icons.json'),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'备份 ZIP 无效:请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)',
|
'备份 ZIP 无效:仅接受当前 v9 格式,不支持旧备份。请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
zip.close();
|
zip.close();
|
||||||
|
|||||||
@@ -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<Response> {
|
||||||
|
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<Buffer>((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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -144,7 +144,21 @@ test('reject invalid dates, negative values and credit card assets', () => {
|
|||||||
test('backup rejects auth data and broken relations', () => {
|
test('backup rejects auth data and broken relations', () => {
|
||||||
const b = {
|
const b = {
|
||||||
format: 'worthpath',
|
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(),
|
exportedAt: new Date().toISOString(),
|
||||||
baseCurrency: 'CNY',
|
baseCurrency: 'CNY',
|
||||||
currencies: ['CNY'],
|
currencies: ['CNY'],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 };
|
return { id: u.id, cookie: 'wp_session=' + token };
|
||||||
}
|
}
|
||||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
||||||
const res = await fetch(base + path, {
|
const res = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
@@ -163,7 +164,7 @@ test('liability account credit balances survive revisions, replay, calendar, pri
|
|||||||
assert.equal(zip.status, 200);
|
assert.equal(zip.status, 200);
|
||||||
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
|
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
const restored = (await call('/overview', b.cookie)).data;
|
const restored = (await call('/overview', b.cookie)).data;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 };
|
return { id: row.id, token };
|
||||||
}
|
}
|
||||||
async function call(token: string, path: string, method = 'GET', body?: unknown) {
|
async function call(token: string, path: string, method = 'GET', body?: unknown) {
|
||||||
const response = await fetch(base + path, {
|
const response = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: 'wp_session=' + token,
|
Cookie: 'wp_session=' + token,
|
||||||
@@ -151,11 +152,11 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
const backup = (await call(a.token, '/backup')).data;
|
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.equal(backup.transfers.length, 5);
|
||||||
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
|
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -5,7 +6,7 @@ import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { readBackupZip } from '../src/zip';
|
import { readBackupZip } from '../src/zip';
|
||||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
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(),
|
const db = new PrismaClient(),
|
||||||
ids: string[] = [];
|
ids: string[] = [];
|
||||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
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 };
|
return { id: u.id, cookie: 'wp_session=' + token };
|
||||||
}
|
}
|
||||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
||||||
const res = await fetch(base + path, {
|
const res = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: cookie,
|
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()));
|
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
|
||||||
assert.deepEqual(backup.preferences.accountGroupOrder, order);
|
assert.deepEqual(backup.preferences.accountGroupOrder, order);
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order);
|
assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order);
|
||||||
await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] });
|
await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] });
|
||||||
assert.equal(
|
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,
|
409,
|
||||||
);
|
);
|
||||||
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']);
|
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']);
|
||||||
delete backup.preferences.accountGroupOrder;
|
delete backup.preferences.accountGroupOrder;
|
||||||
assert.equal(
|
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,
|
400,
|
||||||
);
|
);
|
||||||
assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []);
|
assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []);
|
||||||
const comments: any[] = await db.$queryRawUnsafe(
|
const comments: any[] = await db.$queryRawUnsafe(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -11,7 +12,7 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
|
|||||||
names: string[] = [],
|
names: string[] = [],
|
||||||
publicIds: string[] = [];
|
publicIds: string[] = [];
|
||||||
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
|
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
|
||||||
const res = await fetch(base + path, {
|
const res = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: cookie,
|
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);
|
assert.doesNotMatch(JSON.stringify(backup.data), /passwordHash|sessionId|ownerId|token/i);
|
||||||
const broken = structuredClone(backup.data);
|
const broken = structuredClone(backup.data);
|
||||||
broken.icons[0].image = 'invalid';
|
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();
|
const before = await db.icon.count();
|
||||||
assert.equal(
|
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,
|
400,
|
||||||
);
|
);
|
||||||
assert.equal(await db.icon.count(), before);
|
assert.equal(await db.icon.count(), before);
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
const imported = await db.position.findMany({
|
const imported = await db.position.findMany({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { request } from 'node:http';
|
import { request } from 'node:http';
|
||||||
@@ -28,6 +29,14 @@ async function fixture() {
|
|||||||
// Give this fixture suite its own loopback source address so independent auth
|
// 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.
|
// scenarios do not consume the existing suite's per-IP production rate limit.
|
||||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
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);
|
const data = body === undefined ? undefined : JSON.stringify(body);
|
||||||
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
||||||
const req = request(
|
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', {
|
await call('/metals/' + cash, a.cookie, 'PUT', {
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
metalGrams: '10',
|
metalGrams: '10',
|
||||||
metalPurity: '0.999',
|
|
||||||
autoValuation: true,
|
autoValuation: true,
|
||||||
})
|
})
|
||||||
).status,
|
).status,
|
||||||
@@ -211,7 +219,6 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
|
|||||||
await call('/metals/' + metal, b.cookie, 'PUT', {
|
await call('/metals/' + metal, b.cookie, 'PUT', {
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
metalGrams: '10',
|
metalGrams: '10',
|
||||||
metalPurity: '0.999',
|
|
||||||
autoValuation: true,
|
autoValuation: true,
|
||||||
})
|
})
|
||||||
).status,
|
).status,
|
||||||
@@ -222,24 +229,26 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
|
|||||||
await call('/metals/' + metal, a.cookie, 'PUT', {
|
await call('/metals/' + metal, a.cookie, 'PUT', {
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
metalGrams: '10.86420978',
|
metalGrams: '10.86420978',
|
||||||
metalPurity: '0.999',
|
|
||||||
autoValuation: true,
|
autoValuation: true,
|
||||||
})
|
})
|
||||||
).status,
|
).status,
|
||||||
200,
|
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);
|
const d = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
|
||||||
assert.equal(
|
assert.equal((await call('/metals/prices', a.cookie, 'POST', {})).status, 404);
|
||||||
(
|
await db.metalPrice.create({
|
||||||
await call('/metals/prices', a.cookie, 'POST', {
|
data: {
|
||||||
|
userId: a.id,
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
price: '700.864209789012',
|
price: '700.864209789012',
|
||||||
date: d,
|
date: new Date(d),
|
||||||
})
|
source: 'manual',
|
||||||
).status,
|
quotedAt: new Date(),
|
||||||
201,
|
},
|
||||||
);
|
});
|
||||||
|
await call('/metals/' + metal + '/value', a.cookie, 'POST', {});
|
||||||
const expected = (await import('../src/metals')).metalValue(
|
const expected = (await import('../src/metals')).metalValue(
|
||||||
'10.86420978',
|
'10.86420978',
|
||||||
'0.999',
|
'0.999',
|
||||||
@@ -250,12 +259,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
|
|||||||
expected.replace(/0+$/, '').replace(/\.$/, ''),
|
expected.replace(/0+$/, '').replace(/\.$/, ''),
|
||||||
);
|
);
|
||||||
const count = await db.revision.count({ where: { positionId: metal } });
|
const count = await db.revision.count({ where: { positionId: metal } });
|
||||||
await call('/metals/prices', a.cookie, 'POST', {
|
await call('/metals/' + metal + '/value', a.cookie, 'POST', {});
|
||||||
metalType: 'gold',
|
|
||||||
currency: 'CNY',
|
|
||||||
price: '700.864209789012',
|
|
||||||
date: d,
|
|
||||||
});
|
|
||||||
assert.equal(await db.revision.count({ where: { positionId: metal } }), count);
|
assert.equal(await db.revision.count({ where: { positionId: metal } }), count);
|
||||||
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
|
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
|
||||||
const backup = await (controller as any).data(a.id);
|
const backup = await (controller as any).data(a.id);
|
||||||
@@ -267,8 +271,12 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
|
|||||||
await done;
|
await done;
|
||||||
const restoredBackup = await readBackupZip(Buffer.concat(chunks));
|
const restoredBackup = await readBackupZip(Buffer.concat(chunks));
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
assert.equal((await call('/auth/me', b.cookie)).data.includeIndependentAssets, false);
|
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();
|
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(),
|
const a = await fixture(),
|
||||||
b = await fixture();
|
b = await fixture();
|
||||||
try {
|
try {
|
||||||
@@ -303,13 +311,22 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
|
|||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
metalGrams: '2.5',
|
metalGrams: '2.5',
|
||||||
metalPurity: '0.8',
|
|
||||||
autoValuation: true,
|
autoValuation: true,
|
||||||
date: '2026-10-01T12:00',
|
date: '2026-10-01T12:00',
|
||||||
metalCostPerGram: '12.8',
|
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);
|
const created = await call('/metals/holdings', a.cookie, 'POST', input);
|
||||||
assert.equal(created.status, 201, JSON.stringify(created.data));
|
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);
|
assert.equal(created.data.valuationAvailable, false);
|
||||||
const id = created.data.id;
|
const id = created.data.id;
|
||||||
let p = (await call('/positions/' + id, a.cookie)).data;
|
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', {
|
await call('/metals/' + id, b.cookie, 'PUT', {
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
metalGrams: '2.5',
|
metalGrams: '2.5',
|
||||||
metalPurity: '0.8',
|
|
||||||
autoValuation: true,
|
autoValuation: true,
|
||||||
})
|
})
|
||||||
).status,
|
).status,
|
||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
const price = await call('/metals/prices', a.cookie, 'POST', {
|
await db.metalPrice.create({
|
||||||
|
data: {
|
||||||
|
userId: a.id,
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
price: '20',
|
price: '20',
|
||||||
date: '2026-10-01',
|
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;
|
p = (await call('/positions/' + id, a.cookie)).data;
|
||||||
assert.equal(p.amount, '40');
|
assert.equal(p.amount, '50');
|
||||||
assert.equal(p.metalProfit, '8.00000000');
|
assert.equal(p.metalProfit, '18.00000000');
|
||||||
assert.equal(p.valuationAvailable, true);
|
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('/metals/' + id, a.cookie, 'PUT', cfg)).status, 200);
|
||||||
assert.equal((await call('/positions/' + id, a.cookie)).data.metalCostPerGram, '12.8');
|
assert.equal((await call('/positions/' + id, a.cookie)).data.metalCostPerGram, '12.8');
|
||||||
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
|
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
|
||||||
const backup = await (controller as any).data(a.id);
|
const backup = await (controller as any).data(a.id);
|
||||||
assert.equal(backup.positions[0].metalCostPerGram, '12.8');
|
assert.equal(backup.positions[0].metalCostPerGram, '12.8');
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
const restored = (await call('/positions?kind=asset', b.cookie)).data[0];
|
const restored = (await call('/positions?kind=asset', b.cookie)).data[0];
|
||||||
assert.equal(restored.metalCostPerGram, '12.8');
|
assert.equal(restored.metalCostPerGram, '12.8');
|
||||||
assert.equal(restored.metalProfit, '8.00000000');
|
assert.equal(restored.metalProfit, '18.00000000');
|
||||||
const invalidCost = structuredClone(backup);
|
const invalidCost = structuredClone(backup);
|
||||||
Object.assign(invalidCost.positions[0], {
|
Object.assign(invalidCost.positions[0], {
|
||||||
metalType: null,
|
metalType: null,
|
||||||
@@ -367,7 +388,7 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
|
|||||||
);
|
);
|
||||||
const old = structuredClone(backup);
|
const old = structuredClone(backup);
|
||||||
delete old.positions[0].metalCostPerGram;
|
delete old.positions[0].metalCostPerGram;
|
||||||
assert.doesNotThrow(() =>
|
assert.throws(() =>
|
||||||
(require('../src/backup') as typeof import('../src/backup')).validateBackup(old),
|
(require('../src/backup') as typeof import('../src/backup')).validateBackup(old),
|
||||||
);
|
);
|
||||||
assert.equal(
|
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.status, 201);
|
||||||
assert.equal(next.data.valuationAvailable, true);
|
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(
|
assert.equal(
|
||||||
(await call('/metals/holdings', a.cookie, 'POST', { ...input, metalGrams: '0' })).status,
|
(await call('/metals/holdings', a.cookie, 'POST', { ...input, metalGrams: '0' })).status,
|
||||||
400,
|
400,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -11,7 +12,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
|||||||
const db = new PrismaClient(),
|
const db = new PrismaClient(),
|
||||||
created: { id: string; username: string }[] = [];
|
created: { id: string; username: string }[] = [];
|
||||||
async function call(path: string, method = 'GET', body?: unknown, cookie = '') {
|
async function call(path: string, method = 'GET', body?: unknown, cookie = '') {
|
||||||
const res = await fetch(base + path, {
|
const res = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
@@ -281,16 +282,17 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
|||||||
JSON.stringify(backup),
|
JSON.stringify(backup),
|
||||||
/"(?:password|passwordHash|token|userId|session|sessionId|sessions|cookie)"\s*:/i,
|
/"(?: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-fixture', '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, b.cookie)).status, 201);
|
||||||
assert.equal(
|
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,
|
400,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
await call(
|
await call(
|
||||||
'/backup/import',
|
'/backup/restore-fixture',
|
||||||
'POST',
|
'POST',
|
||||||
{
|
{
|
||||||
confirmed: true,
|
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('/positions', 'GET', undefined, b.cookie)).data.length, 0);
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, o.net);
|
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),
|
sameDay.map((h: { after: string }) => h.after),
|
||||||
);
|
);
|
||||||
assert.equal(
|
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,
|
409,
|
||||||
);
|
);
|
||||||
assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 4);
|
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,
|
b.cookie,
|
||||||
);
|
);
|
||||||
assert.equal(
|
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,
|
409,
|
||||||
);
|
);
|
||||||
const c = await account();
|
const c = await account();
|
||||||
const racing = await Promise.all([
|
const racing = await Promise.all([
|
||||||
call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie),
|
call('/backup/restore-fixture', '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),
|
||||||
]);
|
]);
|
||||||
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('/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/logout', 'POST', undefined, a.cookie)).status, 201);
|
||||||
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401);
|
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', 'GET', undefined, a.cookie)).status, 401);
|
||||||
assert.equal(
|
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,
|
401,
|
||||||
);
|
);
|
||||||
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
|
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
|
||||||
|
|||||||
+218
-132
@@ -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 resource = process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp';
|
||||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
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(),
|
const db = new PrismaClient(),
|
||||||
users: string[] = [],
|
users: string[] = [],
|
||||||
clients: Client[] = [];
|
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,
|
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),
|
const username = 'mcp_test_' + randomUUID().slice(0, 12),
|
||||||
password = randomBytes(20).toString('hex');
|
password = randomBytes(20).toString('hex');
|
||||||
const registered = await web('', '/auth/register', 'POST', { username, password });
|
const registered = await web('', '/auth/register', 'POST', { username, password });
|
||||||
assert.equal(registered.status, 201);
|
assert.equal(registered.status, 201);
|
||||||
const user = await db.user.findUniqueOrThrow({ where: { username } });
|
const user = await db.user.findUniqueOrThrow({ where: { username } });
|
||||||
users.push(user.id);
|
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;
|
const cookie = registered.cookie;
|
||||||
assert.equal((await web(cookie, '/agent/policy', 'PUT', { mode, password })).status, 200);
|
|
||||||
const token = await web(cookie, '/agent/tokens', 'POST', {
|
const token = await web(cookie, '/agent/tokens', 'POST', {
|
||||||
name: 'Official SDK integration',
|
name: 'Official SDK integration',
|
||||||
days: 1,
|
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 = {}) {
|
async function confirm(a: any, operation: any, extra: any = {}) {
|
||||||
const v = await web(a.cookie, '/agent/operations/' + operation.operationId, 'POST', {
|
const v = await web(a.cookie, '/agent/operations/' + operation.operationId, 'POST', {
|
||||||
approve: true,
|
approve: true,
|
||||||
password: a.password,
|
|
||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
assert.equal(v.status, 201, JSON.stringify(v.data));
|
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(),
|
const a = await fixture(),
|
||||||
b = await fixture(),
|
b = await fixture(),
|
||||||
d = await fixture('draft', ['read', 'draft']);
|
d = await fixture('draft', ['read', 'draft']);
|
||||||
await write(a, 'rates_refresh');
|
|
||||||
await write(a, 'metals_refresh');
|
|
||||||
await call(a, 'connection_info');
|
await call(a, 'connection_info');
|
||||||
const discovered = await a.client.listTools();
|
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(
|
assert.equal(
|
||||||
discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint,
|
discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint,
|
||||||
true,
|
true,
|
||||||
@@ -310,15 +315,20 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
|
|||||||
id: metal,
|
id: metal,
|
||||||
data: { name: 'gold', category: 'gold', notes: '', archived: false, hidden: false },
|
data: { name: 'gold', category: 'gold', notes: '', archived: false, hidden: false },
|
||||||
});
|
});
|
||||||
await write(a, 'metal_price_set', {
|
await db.metalPrice.create({
|
||||||
|
data: {
|
||||||
|
userId: a.id,
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
price: '10.876543210987',
|
price: '10.876543210987',
|
||||||
date: day,
|
date: new Date(day),
|
||||||
|
source: 'goldapi',
|
||||||
|
quotedAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await write(a, 'metal_configure', {
|
await write(a, 'metal_configure', {
|
||||||
id: metal,
|
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');
|
assert.equal((await call(a, 'position_get', { id: metal })).amount, '21.75308642');
|
||||||
await write(a, 'metal_value', { id: metal });
|
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',
|
currency: 'CNY',
|
||||||
metalType: 'gold',
|
metalType: 'gold',
|
||||||
metalGrams: '2',
|
metalGrams: '2',
|
||||||
metalPurity: '1',
|
|
||||||
autoValuation: true,
|
autoValuation: true,
|
||||||
metalCostPerGram: '9',
|
metalCostPerGram: '9',
|
||||||
date: day,
|
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.notEqual((await call(a, 'position_get', { id: cash })).amount, before);
|
||||||
assert.equal((await write(a, 'schedules_run')).result.executed, 0);
|
assert.equal((await write(a, 'schedules_run')).result.executed, 0);
|
||||||
await write(a, 'schedule_delete', { id: plan });
|
await write(a, 'schedule_delete', { id: plan });
|
||||||
const hidden = (
|
const hidden = (await write(a, 'position_create', { ...position, name: 'hidden' })).result.id;
|
||||||
await write(a, 'position_create', { ...position, name: 'hidden', hidden: true })
|
await db.position.update({ where: { id: hidden }, data: { hidden: true } });
|
||||||
).result.id;
|
|
||||||
await fail(a, 'position_get', { id: hidden });
|
await fail(a, 'position_get', { id: hidden });
|
||||||
await fail(a, 'debt_links_set', {
|
await fail(a, 'debt_links_set', {
|
||||||
id: debt,
|
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,
|
expectedState: (await call(d, 'state_get')).state,
|
||||||
idempotencyKey: randomUUID(),
|
idempotencyKey: randomUUID(),
|
||||||
});
|
});
|
||||||
const unlock = await write(a, 'hidden_unlock_request');
|
for (const removed of [
|
||||||
await confirm(a, unlock);
|
'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);
|
assert.equal((await call(a, 'position_get', { id: hidden })).id, hidden);
|
||||||
await write(a, 'hidden_lock');
|
await fail(a, 'position_update', {
|
||||||
await fail(a, 'position_get', { id: hidden });
|
id: hidden,
|
||||||
const exported = await write(a, 'backup_export');
|
data: { name: 'forbidden', category: 'cash', hidden: true },
|
||||||
assert.equal(exported.status, 'pending');
|
expectedState: (await call(a, 'state_get')).state,
|
||||||
const out = (await confirm(a, exported)).result;
|
idempotencyKey: randomUUID(),
|
||||||
assert.equal((await fetch(out.url)).status, 401);
|
});
|
||||||
assert.equal(
|
await db.agentGrant.update({
|
||||||
(await fetch(out.url, { headers: { Authorization: 'Bearer ' + b.token } })).status,
|
where: { id: a.grantId },
|
||||||
403,
|
data: { scopes: ['read', 'write', 'hidden_read', 'hidden_write'] },
|
||||||
);
|
});
|
||||||
const download = await fetch(out.url, { headers: { Authorization: 'Bearer ' + a.token } });
|
await write(a, 'position_update', {
|
||||||
assert.equal(download.status, 200);
|
id: hidden,
|
||||||
const zipped = Buffer.from(await download.arrayBuffer());
|
data: { name: 'authorized hidden', category: 'cash', hidden: true },
|
||||||
const backup: any = await readBackupZip(zipped);
|
|
||||||
assert.ok(backup.positions.find((p: any) => p.id === hidden));
|
|
||||||
assert.equal(JSON.stringify(backup).includes(a.token), false);
|
|
||||||
const target = await fixture('draft'),
|
|
||||||
upload = await call(target, 'file_upload_request', { kind: 'backup' }),
|
|
||||||
form = new FormData();
|
|
||||||
form.append('file', new Blob([zipped]), 'backup.zip');
|
|
||||||
const uploaded = await fetch(upload.url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: 'Bearer ' + target.token },
|
|
||||||
body: form,
|
|
||||||
});
|
});
|
||||||
assert.equal(uploaded.status, 200);
|
|
||||||
const info = await uploaded.json();
|
|
||||||
assert.ok(info.token);
|
|
||||||
await call(target, 'file_status', { fileId: upload.fileId });
|
|
||||||
await call(target, 'import_preview', { token: info.token });
|
|
||||||
const imported = await write(target, 'backup_import', { token: info.token });
|
|
||||||
await confirm(target, imported);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await db.position.count({ where: { userId: target.id } }),
|
(await db.position.findUniqueOrThrow({ where: { id: hidden } })).name,
|
||||||
backup.positions.length,
|
'authorized hidden',
|
||||||
);
|
|
||||||
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.agentGrant.update({ where: { id: a.grantId }, data: { scopes: ['read'] } });
|
||||||
await fail(a, 'position_create', {
|
await fail(a, 'position_create', {
|
||||||
...position,
|
...position,
|
||||||
expectedState: (await call(a, 'state_get')).state,
|
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 db = new PrismaClient();
|
||||||
const username = 'mcp_extra_' + randomUUID().slice(0, 10),
|
const username = 'mcp_extra_' + randomUUID().slice(0, 10),
|
||||||
password = randomBytes(20).toString('hex');
|
password = randomBytes(20).toString('hex');
|
||||||
@@ -534,12 +507,11 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
|
|||||||
try {
|
try {
|
||||||
assert.equal((await web('/auth/register', 'POST', { username, password })).status, 201);
|
assert.equal((await web('/auth/register', 'POST', { username, password })).status, 201);
|
||||||
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
|
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
|
||||||
await web('/agent/policy', 'PUT', { mode: 'direct', password });
|
|
||||||
const grant = (
|
const grant = (
|
||||||
await web('/agent/tokens', 'POST', {
|
await web('/agent/tokens', 'POST', {
|
||||||
name: 'extra',
|
name: 'extra',
|
||||||
days: 1,
|
days: 1,
|
||||||
scopes: ['read', 'draft', 'write', 'sensitive'],
|
scopes: ['read', 'write'],
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
).data;
|
).data;
|
||||||
@@ -602,18 +574,15 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
|
|||||||
iconIds.push(published.id);
|
iconIds.push(published.id);
|
||||||
const image = await call(c, 'icon_image', { id: published.id });
|
const image = await call(c, 'icon_image', { id: published.id });
|
||||||
assert.equal((await fetch(image.url, { headers })).status, 200);
|
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,
|
fileId: upload.fileId,
|
||||||
name: '测试共享图标',
|
name: '测试共享图标',
|
||||||
shared: true,
|
shared: true,
|
||||||
|
expectedState: (await call(c, 'state_get')).state,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
});
|
});
|
||||||
assert.equal(shared.status, 'pending');
|
assert.equal(forbiddenShared.isError, true);
|
||||||
assert.equal(
|
assert.equal(await db.icon.count({ where: { ownerId: userId, shared: true } }), 0);
|
||||||
(await web('/agent/operations/' + shared.operationId, 'POST', { approve: true, password }))
|
|
||||||
.status,
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
iconIds.push((await call(c, 'operation_get', { operationId: shared.operationId })).result.id);
|
|
||||||
// A failed paired transfer leaves neither side changed, including inside outer
|
// A failed paired transfer leaves neither side changed, including inside outer
|
||||||
// idempotency transaction and nested service savepoints.
|
// idempotency transaction and nested service savepoints.
|
||||||
const account = one.result.id,
|
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;
|
const management = (await web('/agent')).data;
|
||||||
assert.ok(management.calls.some((v: any) => v.status === 'error'));
|
assert.ok(management.calls.some((v: any) => v.status === 'error'));
|
||||||
assert.equal(JSON.stringify(management).includes(grant.token), false);
|
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 {
|
} finally {
|
||||||
for (const c of clients) await c.close().catch(() => {});
|
for (const c of clients) await c.close().catch(() => {});
|
||||||
await db.icon.deleteMany({ where: { id: { in: iconIds } } });
|
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'],
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
token_endpoint_auth_method: 'none',
|
token_endpoint_auth_method: 'none',
|
||||||
scope: 'read draft write sensitive',
|
scope: 'read write',
|
||||||
},
|
},
|
||||||
clientInformation: () => saved,
|
clientInformation: () => saved,
|
||||||
saveClientInformation: (v) => {
|
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 });
|
const registered = await post('/api/auth/register', { username, password });
|
||||||
assert.equal(registered.status, 201);
|
assert.equal(registered.status, 201);
|
||||||
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
|
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
|
||||||
assert.equal(
|
assert.equal(await auth(provider, { serverUrl: resource, scope: 'read write' }), 'REDIRECT');
|
||||||
await auth(provider, { serverUrl: resource, scope: 'read draft write sensitive' }),
|
|
||||||
'REDIRECT',
|
|
||||||
);
|
|
||||||
assert.ok(authorization);
|
assert.ok(authorization);
|
||||||
const redirected = await fetch(authorization!, { redirect: 'manual' });
|
const redirected = await fetch(authorization!, { redirect: 'manual' });
|
||||||
assert.equal(redirected.status, 302);
|
assert.equal(redirected.status, 302);
|
||||||
const location = new URL(redirected.headers.get('location')!);
|
const location = new URL(redirected.headers.get('location')!);
|
||||||
const id = location.searchParams.get('agent_authorization');
|
const id = location.searchParams.get('agent_authorization');
|
||||||
assert.ok(id);
|
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(
|
const consent = await post(
|
||||||
'/api/agent/authorizations/' + id,
|
'/api/agent/authorizations/' + id,
|
||||||
{ approve: true },
|
{ approve: true, scopes: ['read', 'write'] },
|
||||||
registered.cookie,
|
registered.cookie,
|
||||||
);
|
);
|
||||||
assert.equal(consent.status, 201);
|
assert.equal(consent.status, 201);
|
||||||
@@ -795,7 +737,7 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
|
|||||||
await client.connect(
|
await client.connect(
|
||||||
new StreamableHTTPClientTransport(new URL(resource), { authProvider: provider }),
|
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();
|
await client.close();
|
||||||
async function exchange(params: Record<string, string>) {
|
async function exchange(params: Record<string, string>) {
|
||||||
const r = await fetch(root + '/token', {
|
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();
|
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<string, string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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(),
|
const db = new PrismaClient(),
|
||||||
names: string[] = [];
|
names: string[] = [];
|
||||||
async function call(path: string, method = 'GET', data?: unknown, cookie = '') {
|
async function call(path: string, method = 'GET', data?: unknown, cookie = '') {
|
||||||
const res = await fetch(base + path, {
|
const res = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
|
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');
|
assert.equal(history[1].delta, '15');
|
||||||
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
|
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).hidden, true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
backup.positions.find((p: { id: string }) => p.id === hidden).revisions[0].date,
|
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('/overview', 'GET', undefined, c.cookie)).data.net, '20.00');
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '27.00');
|
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);
|
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 });
|
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await call('/backup/import', 'POST', { confirmed: true, backup: legacy }, login.cookie))
|
(
|
||||||
.status,
|
await call(
|
||||||
201,
|
'/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 {
|
} finally {
|
||||||
for (const username of names) await db.user.deleteMany({ where: { username } });
|
for (const username of names) await db.user.deleteMany({ where: { username } });
|
||||||
await db.$disconnect();
|
await db.$disconnect();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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');
|
sessionId = createHash('sha256').update(token).digest('hex');
|
||||||
let restoredUserId: string | undefined;
|
let restoredUserId: string | undefined;
|
||||||
async function call(path: string) {
|
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() };
|
return { status: response.status, data: await response.json() };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -142,7 +145,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
|
|||||||
date: '2026-09-03T00:01',
|
date: '2026-09-03T00:01',
|
||||||
};
|
};
|
||||||
const post = async (path: string, body: unknown, cookie = token) => {
|
const post = async (path: string, body: unknown, cookie = token) => {
|
||||||
const response = await fetch(base + path, {
|
const response = await fixtureFetch(base + path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: 'wp_session=' + cookie,
|
Cookie: 'wp_session=' + cookie,
|
||||||
@@ -179,7 +182,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await post('/backup/import', { confirmed: true, backup }, restoredToken)).status,
|
(await post('/backup/restore-fixture', { confirmed: true, backup }, restoredToken)).status,
|
||||||
201,
|
201,
|
||||||
);
|
);
|
||||||
const restoredTransfer = await db.transfer.findFirstOrThrow({ where: { userId: restored.id } });
|
const restoredTransfer = await db.transfer.findFirstOrThrow({ where: { userId: restored.id } });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -11,7 +12,7 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
|
|||||||
ids: string[] = [];
|
ids: string[] = [];
|
||||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||||
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
|
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
|
||||||
const res = await fetch(base + path, {
|
const res = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
@@ -141,6 +142,30 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
|
|||||||
const backup: any = await readBackupZip(bytes);
|
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 === emptyId).revisions.length, 0);
|
||||||
assert.equal(backup.positions.find((p: any) => p.id === sourceId).revisions[0].amount, '-10');
|
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();
|
const form = new FormData();
|
||||||
form.append('file', new Blob([bytes]), 'backup.zip');
|
form.append('file', new Blob([bytes]), 'backup.zip');
|
||||||
const upload = await fetch(base + '/backup/upload', {
|
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,
|
emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount,
|
||||||
'-5',
|
'-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();
|
const fresh = await user();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status,
|
(await call('/backup/preview-fixture', fresh.cookie, 'POST', emptyAccountData)).status,
|
||||||
201,
|
201,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { request } from 'node:http';
|
import { request } from 'node:http';
|
||||||
@@ -28,6 +29,14 @@ async function fixture() {
|
|||||||
// Give this fixture suite its own loopback source address so independent auth
|
// 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.
|
// scenarios do not consume the existing suite's per-IP production rate limit.
|
||||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
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);
|
const data = body === undefined ? undefined : JSON.stringify(body);
|
||||||
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
||||||
const req = request(
|
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.sessionHours, 720);
|
||||||
assert.equal(backup.preferences.requireHiddenPassword, false);
|
assert.equal(backup.preferences.requireHiddenPassword, false);
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
const imported = (await call('/settings', b.cookie)).data;
|
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.requireHiddenPassword;
|
||||||
delete backup.preferences.overviewCards;
|
delete backup.preferences.overviewCards;
|
||||||
assert.equal(
|
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,
|
400,
|
||||||
);
|
);
|
||||||
const legacy = (await call('/settings', c.cookie)).data;
|
const legacy = (await call('/settings', c.cookie)).data;
|
||||||
assert.equal(legacy.sessionHours, 168);
|
assert.equal(legacy.sessionHours, 168);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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(),
|
const db = new PrismaClient(),
|
||||||
names: string[] = [];
|
names: string[] = [];
|
||||||
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
|
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
|
||||||
const r = await fetch(base + path, {
|
const r = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
@@ -227,12 +228,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
|||||||
const broken = structuredClone(backup);
|
const broken = structuredClone(backup);
|
||||||
broken.transfers[0].amount = '999';
|
broken.transfers[0].amount = '999';
|
||||||
assert.equal(
|
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,
|
400,
|
||||||
);
|
);
|
||||||
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0);
|
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0);
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
const restored = (await call('/backup', b.cookie)).data;
|
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);
|
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
|
||||||
const c = await account();
|
const c = await account();
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
assert.equal((await call('/settings', c.cookie)).data.showNotes, false);
|
assert.equal((await call('/settings', c.cookie)).data.showNotes, false);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureFetch } from './backup-fixture';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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) =>
|
const minute = (delta = 0) =>
|
||||||
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
|
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
|
||||||
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
|
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
|
||||||
const res = await fetch(base + path, {
|
const res = await fixtureFetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: cookie,
|
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;
|
const backup = (await call('/backup', a.cookie)).data;
|
||||||
assert.equal(backup.schedules.length, 3);
|
assert.equal(backup.schedules.length, 3);
|
||||||
assert.equal(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0);
|
assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0);
|
||||||
|
|||||||
+47
-96
@@ -7,14 +7,24 @@ import { packBackup, readBackupZip } from '../src/zip';
|
|||||||
const empty = () =>
|
const empty = () =>
|
||||||
validateBackup({
|
validateBackup({
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 2,
|
version: 9,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
baseCurrency: 'CNY',
|
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'],
|
currencies: ['CNY'],
|
||||||
icons: [],
|
icons: [],
|
||||||
transfers: [],
|
transfers: [],
|
||||||
schedules: [],
|
schedules: [],
|
||||||
|
metalPrices: [],
|
||||||
positions: [],
|
positions: [],
|
||||||
rates: [],
|
rates: [],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -48,7 +58,10 @@ test('ZIP contains separate JSON files and restores settings without authenticat
|
|||||||
'settings.json',
|
'settings.json',
|
||||||
'transfers.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);
|
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
|
||||||
});
|
});
|
||||||
test('ZIP rejects incomplete files, tampering, unknown entries and invalid entry names', async () => {
|
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) => ({
|
const position = (count: number) => ({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
name: 'count acceptance',
|
name: 'count acceptance',
|
||||||
|
groupName: '',
|
||||||
|
iconId: null,
|
||||||
|
included: true,
|
||||||
|
importedFromId: null,
|
||||||
|
metalType: null,
|
||||||
|
metalGrams: null,
|
||||||
|
metalCostPerGram: null,
|
||||||
|
metalPurity: '1',
|
||||||
|
autoValuation: false,
|
||||||
kind: 'asset',
|
kind: 'asset',
|
||||||
side: 'asset',
|
side: 'asset',
|
||||||
category: 'other',
|
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 () => {
|
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 contents = packBackup(empty());
|
||||||
delete contents['icons.json'];
|
|
||||||
delete contents['transfers.json'];
|
|
||||||
delete contents['schedules.json'];
|
|
||||||
const manifest = JSON.parse(contents['manifest.json']);
|
const manifest = JSON.parse(contents['manifest.json']);
|
||||||
manifest.version = 3;
|
manifest.version = version;
|
||||||
manifest.files = manifest.files.filter(
|
|
||||||
(f: { name: string }) => !['icons.json', 'transfers.json', 'schedules.json'].includes(f.name),
|
|
||||||
);
|
|
||||||
contents['manifest.json'] = JSON.stringify(manifest);
|
contents['manifest.json'] = JSON.stringify(manifest);
|
||||||
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
await assert.rejects(async () => readBackupZip(await archive(contents)));
|
||||||
assert.equal(restored.icons, undefined);
|
}
|
||||||
assert.deepEqual(restored.positions, []);
|
for (const version of [1, 2, 3]) assert.throws(() => validateBackup({ ...empty(), version }));
|
||||||
});
|
});
|
||||||
|
test('current backups require complete settings and metadata, with no legacy defaults', async () => {
|
||||||
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 () => {
|
|
||||||
const b = empty();
|
const b = empty();
|
||||||
b.preferences!.accountGroupOrder = ['理财', '', '未分组', '日常'];
|
b.preferences.accountGroupOrder = ['日常', ''];
|
||||||
const restored = validateBackup(await readBackupZip(await archive(packBackup(b))));
|
assert.deepEqual(validateBackup(await readBackupZip(await archive(packBackup(b)))), b);
|
||||||
assert.deepEqual(restored.preferences!.accountGroupOrder, b.preferences!.accountGroupOrder);
|
for (const key of Object.keys(b.preferences)) {
|
||||||
const legacy = validateBackup(await readBackupZip(await archive(packBackup(empty()))));
|
const incomplete = structuredClone(b);
|
||||||
assert.equal(legacy.preferences!.accountGroupOrder, undefined);
|
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 } }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
+417
-132
@@ -5,14 +5,14 @@ type Connection = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
expiresAt: string;
|
expiresAt: string | null;
|
||||||
revokedAt: string | null;
|
revokedAt: string | null;
|
||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
};
|
};
|
||||||
type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string };
|
type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string };
|
||||||
type Management = {
|
type Management = {
|
||||||
|
capabilities: { name: string; description: string; scope: string; destructive?: boolean }[];
|
||||||
mcpUrl: string;
|
mcpUrl: string;
|
||||||
mode: string;
|
|
||||||
grants: Connection[];
|
grants: Connection[];
|
||||||
operations: Operation[];
|
operations: Operation[];
|
||||||
calls: { id: string; tool: string; status: string; createdAt: string }[];
|
calls: { id: string; tool: string; status: string; createdAt: string }[];
|
||||||
@@ -23,8 +23,6 @@ type Preview = {
|
|||||||
status: string;
|
status: string;
|
||||||
description: string;
|
description: string;
|
||||||
impact: unknown;
|
impact: unknown;
|
||||||
web?: string;
|
|
||||||
sensitive: boolean;
|
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
};
|
};
|
||||||
type Consent = {
|
type Consent = {
|
||||||
@@ -34,6 +32,72 @@ type Consent = {
|
|||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
resource: 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() {
|
export function AgentConnections() {
|
||||||
const [data, setData] = useState<Management | null>(null),
|
const [data, setData] = useState<Management | null>(null),
|
||||||
[error, setError] = useState(''),
|
[error, setError] = useState(''),
|
||||||
@@ -41,7 +105,14 @@ export function AgentConnections() {
|
|||||||
[token, setToken] = useState(''),
|
[token, setToken] = useState(''),
|
||||||
[preview, setPreview] = useState<Preview | null>(null),
|
[preview, setPreview] = useState<Preview | null>(null),
|
||||||
[consent, setConsent] = useState<Consent | null>(null),
|
[consent, setConsent] = useState<Consent | null>(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<Management>('/agent'));
|
const load = async () => setData(await api<Management>('/agent'));
|
||||||
const act = async (work: () => Promise<unknown>) => {
|
const act = async (work: () => Promise<unknown>) => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -68,92 +139,212 @@ export function AgentConnections() {
|
|||||||
if (operation) await show(operation);
|
if (operation) await show(operation);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
const copy = async (text: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setMessage(label);
|
||||||
|
} catch {
|
||||||
|
setError('复制失败,请从下方可选中文本手动复制');
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<section className="panel agent-panel">
|
<section className="panel agent-panel">
|
||||||
|
<div className="agent-heading">
|
||||||
|
<div>
|
||||||
<h2>连接 Agent</h2>
|
<h2>连接 Agent</h2>
|
||||||
|
<p className="muted">管理接入、操作权限和需要你确认的修改。</p>
|
||||||
|
</div>
|
||||||
|
{data && <span className="badge">{data.capabilities.length} 个工具 · 按连接授权</span>}
|
||||||
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="danger-text">
|
<p role="alert" className="danger-text">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{message && <p role="status">{message}</p>}
|
{message && (
|
||||||
|
<p role="status" className="agent-notice">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{data && (
|
{data && (
|
||||||
|
<div className="agent-layout">
|
||||||
|
<nav className="agent-tabs agent-wide" aria-label="Agent 功能分区">
|
||||||
|
{[
|
||||||
|
['connect', '开始连接'],
|
||||||
|
['tools', '权限与工具'],
|
||||||
|
['connections', '连接管理'],
|
||||||
|
['operations', '操作记录'],
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={agentView === value ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={agentView === value}
|
||||||
|
onClick={() => setAgentView(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{value === 'operations' &&
|
||||||
|
data.operations.some(
|
||||||
|
(o) => o.status === 'pending' && new Date(o.expiresAt) > new Date(),
|
||||||
|
)
|
||||||
|
? ' · 待确认'
|
||||||
|
: ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
{agentView === 'connect' && (
|
||||||
<>
|
<>
|
||||||
|
<section className="agent-card agent-wide">
|
||||||
|
<h3>开始连接</h3>
|
||||||
<p>远程 MCP 地址</p>
|
<p>远程 MCP 地址</p>
|
||||||
|
<div className="agent-copy-row">
|
||||||
<code className="agent-address">{data.mcpUrl}</code>
|
<code className="agent-address">{data.mcpUrl}</code>
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void navigator.clipboard.writeText(data.mcpUrl)}
|
onClick={() => void copy(data.mcpUrl, 'MCP 地址已复制')}
|
||||||
>
|
>
|
||||||
复制地址
|
复制地址
|
||||||
</button>
|
</button>
|
||||||
<p className="muted">
|
</div>
|
||||||
支持 Streamable HTTP。OAuth 客户端使用此地址发现授权信息,浏览器登录 WorthPath
|
<p className="muted">在客户端添加地址,完成网页授权,再复制教程并描述任务。</p>
|
||||||
后审核连接名称、回调地址和权限。访问令牌每小时过期,刷新令牌最多 30
|
<details className="agent-details">
|
||||||
天并在使用时轮换。个人令牌适用于支持 Bearer 头的客户端。
|
<summary>查看连接步骤</summary>
|
||||||
|
<ol className="agent-steps">
|
||||||
|
<li>
|
||||||
|
<strong>配置连接</strong>:在支持远程 MCP OAuth 的客户端中填写地址,选择
|
||||||
|
Streamable HTTP;在 WorthPath 网页登录后审核名称、回调和权限。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>选择连接权限</strong>:先从 read
|
||||||
|
或草稿模式开始,根据需要增加权限。OAuth 访问令牌 1 小时,刷新授权最多 30
|
||||||
|
天并轮换。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>把操作说明交给 Agent</strong>
|
||||||
|
:复制下方教程,不包含任何密码或令牌,再描述具体任务。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>确认并查看结果</strong>:草稿在网页确认,Agent
|
||||||
|
可查询完成状态。随时在“已授权连接”撤销。
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
<div className="agent-copy-row">
|
||||||
|
<h4>可直接复制给 Agent 的教程</h4>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copy(instructions(data.mcpUrl), 'Agent 使用教程已复制')}
|
||||||
|
>
|
||||||
|
复制 Agent 使用教程
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<details className="agent-details">
|
||||||
|
<summary>预览 Agent 使用教程</summary>
|
||||||
|
<textarea
|
||||||
|
className="agent-tutorial"
|
||||||
|
aria-label="Agent 使用教程"
|
||||||
|
readOnly
|
||||||
|
value={instructions(data.mcpUrl)}
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
<details className="agent-details">
|
||||||
|
<summary>支持 Bearer 头的客户端与已验证 SDK 配置</summary>
|
||||||
|
<p>
|
||||||
|
在下方创建个人令牌,把完整值填入客户端的安全凭据设置;请求头为 Authorization:
|
||||||
|
Bearer <令牌>。个人令牌只显示一次,不能作为通用 OAuth 的替代。
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">
|
|
||||||
已验证客户端:官方 TypeScript SDK 1.31.0(OAuth / Bearer)。其他 Agent
|
|
||||||
尚未验证;不会保证任意客户端兼容。
|
|
||||||
</p>
|
|
||||||
<details>
|
|
||||||
<summary>官方 SDK 的已验证接入配置</summary>
|
|
||||||
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
|
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void navigator.clipboard.writeText(
|
void copy(
|
||||||
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
|
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
|
||||||
|
'诊断命令已复制',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
复制本项目诊断客户端命令
|
复制诊断客户端命令
|
||||||
</button>
|
</button>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
先在终端设置 MCP_ACCESS_TOKEN,再运行复制的命令。完整 OAuth 示例及安全存储说明见
|
先安全设置 MCP_ACCESS_TOKEN。实际验证客户端:官方 TypeScript SDK 1.31.0(OAuth /
|
||||||
docs/mcp.md;此配置仅针对官方 SDK 1.31.0。
|
Bearer);其他 Agent 尚未验证,不提供未经验证的产品配置格式。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<form
|
</section>
|
||||||
onSubmit={(e) => {
|
</>
|
||||||
e.preventDefault();
|
)}
|
||||||
const f = new FormData(e.currentTarget),
|
{agentView === 'tools' && (
|
||||||
form = e.currentTarget;
|
<>
|
||||||
void act(async () => {
|
<section className="agent-card agent-wide">
|
||||||
await api('/agent/policy', 'PUT', {
|
<h3>Agent 可以做什么</h3>
|
||||||
mode: f.get('mode'),
|
|
||||||
password: f.get('password'),
|
|
||||||
});
|
|
||||||
form.reset();
|
|
||||||
setMessage('写入策略已保存');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3>写入策略</h3>
|
|
||||||
<select name="mode" defaultValue={data.mode} key={data.mode}>
|
|
||||||
<option value="readonly">只读</option>
|
|
||||||
<option value="draft">创建草稿,由网页确认</option>
|
|
||||||
<option value="direct">已授权 write 的连接可直接普通写入</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
placeholder="当前密码"
|
|
||||||
/>
|
|
||||||
<button className="secondary" disabled={busy}>
|
|
||||||
保存策略
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<h3>OAuth 授权与个人令牌</h3>
|
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
read 查询;draft 创建草稿;write 按写入策略执行普通写入;sensitive
|
这里列出服务实际提供的工具。能否执行还取决于连接权限、隐藏账户授权、项目归属和草稿确认。
|
||||||
发起敏感操作,仍须网页验证密码。令牌到期可新建并撤销旧令牌。
|
</p>
|
||||||
|
<div className="agent-capabilities">
|
||||||
|
{capabilityGroups.map((g) => (
|
||||||
|
<details className="agent-details" key={g.title}>
|
||||||
|
<summary>{g.title}</summary>
|
||||||
|
<p>{g.summary}</p>
|
||||||
|
<ul>
|
||||||
|
{data.capabilities
|
||||||
|
.filter((t) => g.pattern.test(t.name))
|
||||||
|
.map((t) => (
|
||||||
|
<li key={t.name}>
|
||||||
|
<code>{t.name}</code> · {t.scope}
|
||||||
|
{t.destructive ? ' · 会删除数据' : ''}
|
||||||
|
<p>{t.description}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<details className="agent-details">
|
||||||
|
<summary>查看全部 {data.capabilities.length} 个工具(含连接与状态工具)</summary>
|
||||||
|
<ul>
|
||||||
|
{data.capabilities.map((t) => (
|
||||||
|
<li key={t.name}>
|
||||||
|
<code>{t.name}</code> · {t.scope}
|
||||||
|
<p>{t.description}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section className="agent-card agent-wide">
|
||||||
|
<details className="agent-details">
|
||||||
|
<summary>权限说明:read / draft / write</summary>
|
||||||
|
<p>选择一种连接权限:只读、草稿修改或直接写入。隐藏账户可单独授权读取和修改。</p>
|
||||||
|
<div className="agent-scope-grid">
|
||||||
|
{scopeDetails.map((s) => (
|
||||||
|
<article key={s.id}>
|
||||||
|
<h4>
|
||||||
|
{s.id} · {s.title}
|
||||||
|
</h4>
|
||||||
|
<strong>{s.summary}</strong>
|
||||||
|
<p>{s.detail}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{agentView === 'connections' && (
|
||||||
|
<>
|
||||||
|
<section className="agent-card">
|
||||||
|
<details className="agent-details">
|
||||||
|
<summary>创建个人访问令牌(可选)</summary>
|
||||||
|
<p className="muted">
|
||||||
|
适用于支持 Bearer
|
||||||
|
头的客户端。固定期限到期后新建并撤销旧令牌;永久令牌不会自动过期,仍可撤销。
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
|
className="agent-form"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.currentTarget,
|
const form = e.currentTarget,
|
||||||
@@ -161,56 +352,101 @@ export function AgentConnections() {
|
|||||||
void act(async () => {
|
void act(async () => {
|
||||||
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
|
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
|
||||||
name: f.get('name'),
|
name: f.get('name'),
|
||||||
days: Number(f.get('days')),
|
days: f.get('days') === 'permanent' ? null : Number(f.get('days')),
|
||||||
password: f.get('password'),
|
password: f.get('password'),
|
||||||
scopes: f.getAll('scope'),
|
scopes: [
|
||||||
|
'read',
|
||||||
|
...(f.get('permission') !== 'read'
|
||||||
|
? [String(f.get('permission'))]
|
||||||
|
: []),
|
||||||
|
...(f.get('hiddenRead') ? ['hidden_read'] : []),
|
||||||
|
...(f.get('hiddenWrite') ? ['hidden_write'] : []),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
setToken(v.token);
|
setToken(v.token);
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setTokenPermission('read');
|
||||||
|
setTokenHiddenRead(false);
|
||||||
|
setTokenHiddenWrite(false);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input name="name" required maxLength={100} placeholder="连接名称" />
|
<label>
|
||||||
<input
|
连接名称
|
||||||
name="days"
|
<input name="name" required maxLength={100} placeholder="例如:桌面助手" />
|
||||||
type="number"
|
</label>
|
||||||
min={1}
|
<label>
|
||||||
max={90}
|
令牌有效期
|
||||||
defaultValue={30}
|
<select name="days" defaultValue="30">
|
||||||
required
|
{durationOptions.map(([v, label]) => (
|
||||||
aria-label="有效天数"
|
<option key={v} value={v}>
|
||||||
/>
|
{label}
|
||||||
{['read', 'draft', 'write', 'sensitive'].map((s) => (
|
</option>
|
||||||
<label key={s}>
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
连接权限
|
||||||
|
<select
|
||||||
|
name="permission"
|
||||||
|
value={tokenPermission}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTokenPermission(e.target.value);
|
||||||
|
if (e.target.value === 'read') setTokenHiddenWrite(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="read">只读</option>
|
||||||
|
<option value="draft">草稿修改(网页确认)</option>
|
||||||
|
<option value="write">直接写入</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset className="agent-scope-options">
|
||||||
|
<legend>隐藏账户权限(默认关闭)</legend>
|
||||||
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="scope"
|
name="hiddenRead"
|
||||||
value={s}
|
checked={tokenHiddenRead}
|
||||||
defaultChecked={s === 'read'}
|
onChange={(e) => {
|
||||||
required={s === 'read'}
|
setTokenHiddenRead(e.target.checked);
|
||||||
|
if (!e.target.checked) setTokenHiddenWrite(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{s}
|
允许读取隐藏账户
|
||||||
</label>
|
</label>
|
||||||
))}
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="hiddenWrite"
|
||||||
|
disabled={!tokenHiddenRead || tokenPermission === 'read'}
|
||||||
|
checked={tokenHiddenWrite}
|
||||||
|
onChange={(e) => setTokenHiddenWrite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
允许修改隐藏账户(同时勾选读取,且选择草稿或直接写入)
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<label>
|
||||||
|
验证当前密码
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="当前密码"
|
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
<button className="secondary" disabled={busy}>
|
<button className="secondary" disabled={busy}>
|
||||||
创建个人令牌
|
创建个人令牌
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{token && (
|
{token && (
|
||||||
<div role="status">
|
<div role="status" className="agent-secret">
|
||||||
<p>完整令牌仅显示这一次,请妥善保存。</p>
|
<p>完整令牌仅显示这一次,请妥善保存到客户端凭据设置。</p>
|
||||||
<code className="agent-address">{token}</code>
|
<code className="agent-address">{token}</code>
|
||||||
|
<div className="actions">
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void navigator.clipboard.writeText(token)}
|
onClick={() => void copy(token, '令牌已复制')}
|
||||||
>
|
>
|
||||||
复制令牌
|
复制令牌
|
||||||
</button>
|
</button>
|
||||||
@@ -218,14 +454,43 @@ export function AgentConnections() {
|
|||||||
隐藏令牌
|
隐藏令牌
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section className="agent-card agent-wide">
|
||||||
|
<div className="agent-copy-row">
|
||||||
<h3>已授权连接(最近 100 条)</h3>
|
<h3>已授权连接(最近 100 条)</h3>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void act(load)}
|
||||||
|
>
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!data.grants.length && (
|
||||||
|
<p className="muted">尚未授权连接。通过 OAuth 接入,或创建个人令牌。</p>
|
||||||
|
)}
|
||||||
{data.grants.map((g) => (
|
{data.grants.map((g) => (
|
||||||
<div key={g.id} className="agent-row">
|
<div key={g.id} className="agent-row">
|
||||||
<span>
|
<div>
|
||||||
{g.name} · {g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '}
|
<strong>{g.name}</strong>
|
||||||
{new Date(g.expiresAt).toLocaleString()} · {g.revokedAt ? '已撤销' : ''}
|
<p>
|
||||||
</span>
|
{g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '}
|
||||||
|
{g.revokedAt
|
||||||
|
? '已撤销'
|
||||||
|
: g.expiresAt && new Date(g.expiresAt) <= new Date()
|
||||||
|
? '已过期'
|
||||||
|
: '有效'}
|
||||||
|
</p>
|
||||||
|
<small>
|
||||||
|
{g.expiresAt
|
||||||
|
? '到期:' + new Date(g.expiresAt).toLocaleString()
|
||||||
|
: '永久 · 不会自动过期'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
{!g.revokedAt && (
|
{!g.revokedAt && (
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
@@ -243,15 +508,16 @@ export function AgentConnections() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<h3>待确认及最近操作(最近 100 条)</h3>
|
</section>
|
||||||
<button
|
</>
|
||||||
className="secondary"
|
)}
|
||||||
disabled={busy}
|
{agentView === 'operations' && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => void act(load)}
|
<section className="agent-card agent-wide">
|
||||||
>
|
<h3>待确认及最近操作</h3>
|
||||||
刷新状态
|
{!data.operations.length && (
|
||||||
</button>
|
<p className="muted">暂无操作。Agent 发起的草稿会出现在这里。</p>
|
||||||
|
)}
|
||||||
{data.operations.map((o) => (
|
{data.operations.map((o) => (
|
||||||
<div key={o.id} className="agent-row">
|
<div key={o.id} className="agent-row">
|
||||||
<span>
|
<span>
|
||||||
@@ -271,14 +537,20 @@ export function AgentConnections() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<h3>最近调用(不保存参数和敏感内容)</h3>
|
<details className="agent-details">
|
||||||
|
<summary>最近调用 · 不保存参数、密码或令牌</summary>
|
||||||
|
{!data.calls.length && <p className="muted">暂无调用记录。</p>}
|
||||||
{data.calls.map((c) => (
|
{data.calls.map((c) => (
|
||||||
<div key={c.id} className="agent-row">
|
<div key={c.id} className="agent-row">
|
||||||
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
|
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{consent && (
|
{consent && (
|
||||||
<div className="agent-confirm">
|
<div className="agent-confirm">
|
||||||
<h3>审核 OAuth 连接</h3>
|
<h3>审核 OAuth 连接</h3>
|
||||||
@@ -287,6 +559,46 @@ export function AgentConnections() {
|
|||||||
<p>资源:{consent.resource}</p>
|
<p>资源:{consent.resource}</p>
|
||||||
<p>回调地址:{consent.redirectUri}</p>
|
<p>回调地址:{consent.redirectUri}</p>
|
||||||
<p className="muted">只批准你正在连接的客户端;请核对回调地址。</p>
|
<p className="muted">只批准你正在连接的客户端;请核对回调地址。</p>
|
||||||
|
<label>
|
||||||
|
授予此连接的权限
|
||||||
|
<select
|
||||||
|
value={consentLevel}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConsentLevel(e.target.value);
|
||||||
|
if (e.target.value === 'read') setConsentHiddenWrite(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="read">只读</option>
|
||||||
|
{consent.scopes.includes('draft') && (
|
||||||
|
<option value="draft">草稿修改(网页确认)</option>
|
||||||
|
)}
|
||||||
|
{consent.scopes.includes('write') && <option value="write">直接写入</option>}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{consent.scopes.includes('hidden_read') && (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={consentHiddenRead}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConsentHiddenRead(e.target.checked);
|
||||||
|
if (!e.target.checked) setConsentHiddenWrite(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
允许读取隐藏账户
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{consent.scopes.includes('hidden_write') && (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!consentHiddenRead || consentLevel === 'read'}
|
||||||
|
checked={consentHiddenWrite}
|
||||||
|
onChange={(e) => setConsentHiddenWrite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
允许修改隐藏账户
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
{[true, false].map((approve) => (
|
{[true, false].map((approve) => (
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
@@ -298,7 +610,16 @@ export function AgentConnections() {
|
|||||||
const v = await api<{ redirect: string }>(
|
const v = await api<{ redirect: string }>(
|
||||||
'/agent/authorizations/' + consent.id,
|
'/agent/authorizations/' + consent.id,
|
||||||
'POST',
|
'POST',
|
||||||
{ approve },
|
{
|
||||||
|
approve,
|
||||||
|
scopes: consent.scopes.filter(
|
||||||
|
(s) =>
|
||||||
|
s === 'read' ||
|
||||||
|
s === consentLevel ||
|
||||||
|
(s === 'hidden_read' && consentHiddenRead) ||
|
||||||
|
(s === 'hidden_write' && consentHiddenWrite),
|
||||||
|
),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
location.assign(v.redirect);
|
location.assign(v.redirect);
|
||||||
})
|
})
|
||||||
@@ -321,15 +642,10 @@ export function AgentConnections() {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const f = new FormData(e.currentTarget),
|
const form = e.currentTarget;
|
||||||
form = e.currentTarget;
|
|
||||||
void act(async () => {
|
void act(async () => {
|
||||||
await api('/agent/operations/' + preview.operationId, 'POST', {
|
await api('/agent/operations/' + preview.operationId, 'POST', {
|
||||||
approve: true,
|
approve: true,
|
||||||
...(f.get('password') ? { password: f.get('password') } : {}),
|
|
||||||
...(f.get('username') ? { username: f.get('username') } : {}),
|
|
||||||
...(f.get('newPassword') ? { newPassword: f.get('newPassword') } : {}),
|
|
||||||
...(f.get('confirmation') ? { confirmation: f.get('confirmation') } : {}),
|
|
||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
await show(preview.operationId);
|
await show(preview.operationId);
|
||||||
@@ -337,37 +653,6 @@ export function AgentConnections() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{preview.web === 'clear' && (
|
|
||||||
<>
|
|
||||||
<a href="/api/backup" download>
|
|
||||||
先下载当前账号备份
|
|
||||||
</a>
|
|
||||||
<p>确认备份已保存后输入“确定清空”。</p>
|
|
||||||
<input name="confirmation" required placeholder="确定清空" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{preview.web === 'credentials' && (
|
|
||||||
<>
|
|
||||||
<input name="username" placeholder="新账号(可选)" autoComplete="username" />
|
|
||||||
<input
|
|
||||||
name="newPassword"
|
|
||||||
type="password"
|
|
||||||
minLength={10}
|
|
||||||
maxLength={72}
|
|
||||||
placeholder="新密码(可选)"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{preview.sensitive && (
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="当前密码"
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button className="secondary" disabled={busy}>
|
<button className="secondary" disabled={busy}>
|
||||||
确认执行上述操作
|
确认执行上述操作
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1538,7 +1538,6 @@ export default function App() {
|
|||||||
<ArrowUpRight size={18} />
|
<ArrowUpRight size={18} />
|
||||||
</div>
|
</div>
|
||||||
<h3>{p.name}</h3>
|
<h3>{p.name}</h3>
|
||||||
{p.groupName && <span className="badge">{p.groupName}</span>}
|
|
||||||
{(p.included === false ||
|
{(p.included === false ||
|
||||||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
|
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
|
||||||
<span className="badge">{tr('不计入总览')}</span>
|
<span className="badge">{tr('不计入总览')}</span>
|
||||||
@@ -2268,7 +2267,7 @@ export default function App() {
|
|||||||
{tr('选择备份并验证')}
|
{tr('选择备份并验证')}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".zip,application/zip,.json,application/json"
|
accept=".zip,application/zip"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
setBackup(null);
|
setBackup(null);
|
||||||
@@ -2685,7 +2684,6 @@ export default function App() {
|
|||||||
date: v.date,
|
date: v.date,
|
||||||
metalType: v.metalType,
|
metalType: v.metalType,
|
||||||
metalGrams: v.metalGrams,
|
metalGrams: v.metalGrams,
|
||||||
metalPurity: v.metalPurity,
|
|
||||||
metalCostPerGram: v.metalCostPerGram || null,
|
metalCostPerGram: v.metalCostPerGram || null,
|
||||||
autoValuation: true,
|
autoValuation: true,
|
||||||
hidden: f.has('hidden'),
|
hidden: f.has('hidden'),
|
||||||
@@ -2866,9 +2864,6 @@ export default function App() {
|
|||||||
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={tr('纯度(0–1,例如 0.999)')}>
|
|
||||||
<input name="metalPurity" required defaultValue="1" inputMode="decimal" />
|
|
||||||
</Field>
|
|
||||||
<Field label={tr('买入价(每克,选填)')}>
|
<Field label={tr('买入价(每克,选填)')}>
|
||||||
<input
|
<input
|
||||||
name="metalCostPerGram"
|
name="metalCostPerGram"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { api, money, type Position, currencies } from './api';
|
import { api, money, type Position } from './api';
|
||||||
import { t as tr } from './i18n';
|
import { t as tr } from './i18n';
|
||||||
import { useToast } from './Toast';
|
import { useToast } from './Toast';
|
||||||
type Quote = { metalType: string; currency: string; price: string; date: string; source: string };
|
type Quote = { metalType: string; currency: string; price: string; date: string; source: string };
|
||||||
@@ -87,7 +87,7 @@ export function MetalPanel({
|
|||||||
<h2>{tr('贵金属估价')}</h2>
|
<h2>{tr('贵金属估价')}</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{tr(
|
{tr(
|
||||||
'黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
|
'黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{position && (
|
{position && (
|
||||||
@@ -96,7 +96,6 @@ export function MetalPanel({
|
|||||||
position.id +
|
position.id +
|
||||||
String(position.metalGrams) +
|
String(position.metalGrams) +
|
||||||
String(position.autoValuation) +
|
String(position.autoValuation) +
|
||||||
String(position.metalPurity) +
|
|
||||||
String(position.metalType) +
|
String(position.metalType) +
|
||||||
String(position.metalCostPerGram)
|
String(position.metalCostPerGram)
|
||||||
}
|
}
|
||||||
@@ -109,7 +108,6 @@ export function MetalPanel({
|
|||||||
metalType: f.get('metalType'),
|
metalType: f.get('metalType'),
|
||||||
metalGrams: f.get('metalGrams'),
|
metalGrams: f.get('metalGrams'),
|
||||||
metalCostPerGram: f.get('metalCostPerGram') || null,
|
metalCostPerGram: f.get('metalCostPerGram') || null,
|
||||||
metalPurity: f.get('metalPurity'),
|
|
||||||
autoValuation: f.has('autoValuation'),
|
autoValuation: f.has('autoValuation'),
|
||||||
}),
|
}),
|
||||||
'贵金属估价设置已保存',
|
'贵金属估价设置已保存',
|
||||||
@@ -133,18 +131,6 @@ export function MetalPanel({
|
|||||||
defaultValue={position.metalGrams || ''}
|
defaultValue={position.metalGrams || ''}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
{tr('纯度(0–1,例如 0.999)')}
|
|
||||||
<input
|
|
||||||
name="metalPurity"
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
min="0.00000001"
|
|
||||||
max="1"
|
|
||||||
step="0.00000001"
|
|
||||||
defaultValue={position.metalPurity || '1'}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
{tr('买入价(每克,选填)')}({position.currency})
|
{tr('买入价(每克,选填)')}({position.currency})
|
||||||
<input
|
<input
|
||||||
@@ -155,7 +141,7 @@ export function MetalPanel({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{tr('买入成本按实物总克数计算;参考估值按克数和纯度计算。清空买入价后不显示盈亏。')}
|
{tr('买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。')}
|
||||||
</p>
|
</p>
|
||||||
{position.metalCost != null && (
|
{position.metalCost != null && (
|
||||||
<p>
|
<p>
|
||||||
@@ -205,57 +191,6 @@ export function MetalPanel({
|
|||||||
<p className={data?.status.state === 'error' ? 'danger-text' : 'muted'}>
|
<p className={data?.status.state === 'error' ? 'danger-text' : 'muted'}>
|
||||||
{tr(data?.status.message || '尚未尝试更新')}
|
{tr(data?.status.message || '尚未尝试更新')}
|
||||||
</p>
|
</p>
|
||||||
<details>
|
|
||||||
<summary>{tr('手动录入贵金属价格')}</summary>
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const f = new FormData(e.currentTarget);
|
|
||||||
void act(
|
|
||||||
() => api('/metals/prices', 'POST', Object.fromEntries(f)),
|
|
||||||
'贵金属价格已保存',
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label>
|
|
||||||
{tr('贵金属品种')}
|
|
||||||
<select name="metalType" defaultValue={position?.metalType || 'gold'}>
|
|
||||||
<option value="gold">{tr('黄金')}</option>
|
|
||||||
<option value="silver">{tr('白银')}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{tr('币种')}
|
|
||||||
<select name="currency" defaultValue={position?.currency || 'CNY'}>
|
|
||||||
{currencies.map((c) => (
|
|
||||||
<option key={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{tr('每克纯金属价格')}
|
|
||||||
<input
|
|
||||||
name="price"
|
|
||||||
required
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="(0|[1-9][0-9]{0,11})([.][0-9]{1,12})?"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{tr('报价日期')}
|
|
||||||
<input
|
|
||||||
name="date"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
max={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
|
|
||||||
defaultValue={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button className="primary" disabled={busy}>
|
|
||||||
{tr('保存参考价')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
{!position && (
|
{!position && (
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -558,5 +558,7 @@
|
|||||||
"估值盈亏": "Valuation profit/loss",
|
"估值盈亏": "Valuation profit/loss",
|
||||||
"待估值": "Awaiting valuation",
|
"待估值": "Awaiting valuation",
|
||||||
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "Add by grams with automatic market valuation. Missing quotes mean awaiting valuation, not a known zero value.",
|
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "Add by grams with automatic market valuation. Missing quotes mean awaiting valuation, not a known zero value.",
|
||||||
"缺少报价,当前总额不完整。": "Quotes are missing; current totals are incomplete."
|
"缺少报价,当前总额不完整。": "Quotes are missing; current totals are incomplete.",
|
||||||
|
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "Gold and silver are valued by weight and the reference price per gram. Daily updates retain prior prices on failure; fees and resale discounts are excluded.",
|
||||||
|
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "Purchase cost uses total grams. New holdings use grams and reference prices for valuation. Clearing purchase price hides profit and loss."
|
||||||
}
|
}
|
||||||
@@ -558,5 +558,7 @@
|
|||||||
"估值盈亏": "估值盈虧",
|
"估值盈亏": "估值盈虧",
|
||||||
"待估值": "待估值",
|
"待估值": "待估值",
|
||||||
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "按克數添加,參考價自動估值;缺少報價時顯示待估值,不計為已知零價值。",
|
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "按克數添加,參考價自動估值;缺少報價時顯示待估值,不計為已知零價值。",
|
||||||
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。"
|
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。",
|
||||||
|
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "黃金與白銀按重量及每克參考價估值。每日嘗試更新,失敗保留舊價;參考價不含工費與回收折價。",
|
||||||
|
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "買入成本按總克數計算;新持倉按克數和參考價估值。清空買入價後不顯示盈虧。"
|
||||||
}
|
}
|
||||||
@@ -1880,3 +1880,158 @@ textarea,
|
|||||||
.agent-panel form > label input[type='checkbox'] {
|
.agent-panel form > label input[type='checkbox'] {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
.agent-heading,
|
||||||
|
.agent-copy-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.agent-heading h2,
|
||||||
|
.agent-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.agent-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.agent-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.agent-card {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.agent-card p {
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.agent-copy-row .agent-address {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
.agent-steps {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.agent-steps li {
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
.agent-tutorial {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.65;
|
||||||
|
user-select: text;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.agent-details {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.agent-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.agent-details pre,
|
||||||
|
.agent-details code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.agent-details ul {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.agent-details li p {
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.agent-capabilities,
|
||||||
|
.agent-scope-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.agent-scope-grid article {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.agent-panel .agent-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.agent-panel .agent-form > label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.agent-scope-options {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
.agent-scope-options label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.agent-scope-options input {
|
||||||
|
width: auto;
|
||||||
|
flex: none;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
.agent-scope-options small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.agent-secret,
|
||||||
|
.agent-notice {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.agent-row > div {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.agent-row p {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.agent-layout,
|
||||||
|
.agent-capabilities,
|
||||||
|
.agent-scope-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.agent-wide {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
.agent-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.agent-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.agent-tabs button {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 首版验收(2026-10-01,Asia/Hong_Kong)
|
# 首版验收(2026-10-01,Asia/Hong_Kong)
|
||||||
|
|
||||||
|
> 历史验收记录:2026-10-04 起所有旧备份兼容与单文件 JSON 支持已删除,当前仅接受 ZIP v9;本文旧版本测试记录不代表现行功能。现行 MCP 权限与工具范围见 [本次更新](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
代码位于 E:\WorthPath,远程 origin 为用户指定 GitHub 仓库,分支 main。初始目录和仓库为空;没有覆盖已有项目文件。已有其他程序占用 API 默认端口,因此本项目使用 3100,前端使用 5173。
|
代码位于 E:\WorthPath,远程 origin 为用户指定 GitHub 仓库,分支 main。初始目录和仓库为空;没有覆盖已有项目文件。已有其他程序占用 API 默认端口,因此本项目使用 3100,前端使用 5173。
|
||||||
|
|
||||||
已验证:
|
已验证:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WorthPath 首版设计
|
# WorthPath 首版设计
|
||||||
|
|
||||||
2026-10-02 更新:查询、历史分页、趋势重放、借入借出联动及三语言的当前实现和实测见 [性能与联动验收](performance.md)。以下首版设计中的历史 ZIP v3/v4 说明保留作格式沿革;当前导出为 v7,兼容读取 v3/v4/v5/v6/v7 ZIP 和旧 JSON。
|
2026-10-04 更新:当前仅支持完整 ZIP v9 备份,不支持任何单文件 JSON 或旧 ZIP。本文旧版结构说明作为历史记录,现行权限与验证以 [本次更新](update-agent-backup-2026-10-04.md) 和 [MCP 接入](mcp.md) 为准。
|
||||||
|
|
||||||
本轮账户分组、定时计划、收支日历、迁移步骤与验证边界见 [更新说明](update-2026-10-02.md)。
|
本轮账户分组、定时计划、收支日历、迁移步骤与验证边界见 [更新说明](update-2026-10-02.md)。
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Revision 保存按业务日期生效的绝对金额,每次金额更新新增
|
|||||||
|
|
||||||
Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob 和可选公开来源;Position.iconId 外键 SetNull,多个账户共享同一图片。所有图标接口使用已验证身份,读取/检索/赋值均限定 shared=true 或 ownerId=当前用户,响应不返回 ownerId。个人上传默认私有;共享发布须中文名称和明确公开确认。拒绝 SVG、动图、损坏图片、超限像素/文件,重编码移除元数据。内置图标固定 ID 追加初始化;在线请求不会发送用户财务数据。
|
Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob 和可选公开来源;Position.iconId 外键 SetNull,多个账户共享同一图片。所有图标接口使用已验证身份,读取/检索/赋值均限定 shared=true 或 ownerId=当前用户,响应不返回 ownerId。个人上传默认私有;共享发布须中文名称和明确公开确认。拒绝 SVG、动图、损坏图片、超限像素/文件,重编码移除元数据。内置图标固定 ID 追加初始化;在线请求不会发送用户财务数据。
|
||||||
|
|
||||||
备份逻辑增加可选 icons 数组(旧格式缺失可兼容),v4 ZIP 将图标内容放入 icons.json;内容校验和解码在导入事务前完成,图标在事务内以当前用户私有范围重建,账户关联重映射。清空删除私有图标,公开共享图标不因发布者清空而消失。用户删除时图标 ownerId SetNull,不影响他人已引用的公共图标。
|
当前备份要求完整 icons 数组,ZIP v9 将图标内容放入 icons.json;内容校验和解码在导入事务前完成,图标在事务内以当前用户私有范围重建,账户关联重映射。清空删除私有图标,公开共享图标不因发布者清空而消失。用户删除时图标 ownerId SetNull,不影响他人已引用的公共图标。
|
||||||
|
|
||||||
## 转账及显示偏好
|
## 转账及显示偏好
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "agentpolicy",
|
"TABLE_NAME": "agentpolicy",
|
||||||
"TABLE_COMMENT": "用户的 Agent 写入策略"
|
"TABLE_COMMENT": "已停用的历史 Agent 策略数据,保留原数据但不参与权限判定"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "exchangerate",
|
"TABLE_NAME": "exchangerate",
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
{
|
{
|
||||||
"TABLE_NAME": "agentgrant",
|
"TABLE_NAME": "agentgrant",
|
||||||
"COLUMN_NAME": "scopes",
|
"COLUMN_NAME": "scopes",
|
||||||
"COLUMN_COMMENT": "权限列表:read、draft、write、sensitive"
|
"COLUMN_COMMENT": "权限列表:read、draft 或 write;隐藏账户附加权限 hidden_read、hidden_write"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "agentgrant",
|
"TABLE_NAME": "agentgrant",
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
{
|
{
|
||||||
"TABLE_NAME": "agentgrant",
|
"TABLE_NAME": "agentgrant",
|
||||||
"COLUMN_NAME": "expiresAt",
|
"COLUMN_NAME": "expiresAt",
|
||||||
"COLUMN_COMMENT": "到期时间,UTC"
|
"COLUMN_COMMENT": "访问令牌到期时间,UTC;空表示可撤销的永久个人令牌"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "agentgrant",
|
"TABLE_NAME": "agentgrant",
|
||||||
|
|||||||
+42
-40
@@ -1,69 +1,71 @@
|
|||||||
# 现有功能 → MCP 覆盖与验证矩阵
|
# 现有功能 → MCP 覆盖与验证矩阵
|
||||||
|
|
||||||
|
> 2026-10-04 最新边界:MCP 提供 39 个工具。用户明确要求撤下敏感操作、汇率和贵金属报价修改;备份、清空、密码与隐藏解锁流程仍在网站完成。单文件 JSON 及旧 ZIP 全部拒绝。此要求取代最初“全部业务均可由 MCP 完成”的范围。
|
||||||
|
|
||||||
盘点来源:App.tsx、Calendar、SchedulePanel、MetalPanel、IconLibrary、TransferForm、DebtPaymentForm、GroupOrderList 及所有 API Controller 路由。架构沿用 NestJS/Express/Prisma/MySQL/Zod,不新增消费账本或 AI 框架。
|
盘点来源:App.tsx、Calendar、SchedulePanel、MetalPanel、IconLibrary、TransferForm、DebtPaymentForm、GroupOrderList 及所有 API Controller 路由。架构沿用 NestJS/Express/Prisma/MySQL/Zod,不新增消费账本或 AI 框架。
|
||||||
|
|
||||||
验证记号:**M** `test/mcp.test.ts` 第一组真实官方 SDK/MySQL;**C** 第二组并发/图标/密码修改;**O** 第三组 OAuth;**R** 18 组真实 REST/MySQL 回归;**U** 原单元计算/行情/ZIP 测试。均有实际业务断言。M 中公共刷新验证无需要更新资产时的真实调用;外部行情响应和失败回退由 U 验证,未声称行情供应商稳定可用。
|
验证记号:**M** 第一组官方 SDK 普通业务/草稿/隐藏权限;**C** 第二组幂等/并发/私有图标/回滚;**O** 第三组 OAuth;**P** 第四组期限/永久令牌/隔离/撤销;**R** 18 组真实 REST/MySQL;**U** 34 项单元计算/行情/ZIP 测试。均有真实业务断言,外部行情成功/失败路径由 U 验证。
|
||||||
|
|
||||||
**B**:2026-10-03 22:55(UTC+8)真实浏览器验证黄金/白银创建、可选买入价与盈亏、清空成本、估值历史及报价完成后自动同步,临时用户已清理。
|
**B**:2026-10-03 22:55(UTC+8)真实浏览器验证黄金/白银创建、可选买入价与盈亏、清空成本、估值历史及报价完成后自动同步,临时用户已清理。
|
||||||
|
|
||||||
普通写入需要 draft 或 write,且受用户 readonly/draft/direct 策略约束;敏感操作额外要求 sensitive 并强制网页确认。所有写入工具要求幂等键和 expectedState。每项都先检查可信用户归属;annotations 不替代权限。
|
当前权限为 read(只读)、read+draft(网页确认后提交)、read+write(直接普通修改)。hidden_read/hidden_write 是独立附加授权。没有网站全局写入策略。所有写入均校验可信身份、归属、幂等键和 expectedState。
|
||||||
|
|
||||||
| 现有页面/API 与功能 | MCP 操作路径 | 权限 | 确认方式 | 验证 |
|
| 现有页面/API 与功能 | MCP 操作路径 | 权限 | 确认方式 | 验证 |
|
||||||
| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------- | --------------------------------------------------------- |
|
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------- | ---------------------------------------- | --------------------------------------------------------- |
|
||||||
| 账户/独立资产/债务列表 GET positions | positions_list:kind、名称、归档、方向、币种、分组、排序、offset/limit | read | 无 | M:查询、重名、分页和外用户隔离 |
|
| 账户/独立资产/债务列表 GET positions | positions_list:kind、名称、归档、方向、币种、分组、排序、offset/limit | read | 无 | M:查询、重名、分页和外用户隔离 |
|
||||||
| 项目详情 GET positions/:id | position_get | read | 隐藏项目须连接独立网页解锁 | M:当前余额、隐藏/外用户拒绝 |
|
| 项目详情 GET positions/:id | position_get | read | 隐藏项目须连接独立网页解锁 | M:当前余额、隐藏/外用户拒绝 |
|
||||||
| 创建项目 POST positions | position_create,保留账户/资产/债务初始绝对余额语义 | draft/write | 策略决定草稿或直接 | M/C:3 类对象、重复调用、无效金额、并发 |
|
| 创建项目 POST positions | position_create,保留账户/资产/债务初始绝对余额语义 | draft/write | 连接权限决定草稿或直接 | M/C:3 类对象、重复调用、无效金额、并发 |
|
||||||
| 按克数创建贵金属 POST metals/holdings | metal_holding_create | draft/write | 策略 | M/R/U/B:待估值、报价、成本盈亏、旧备份 |
|
| 按克数创建贵金属 POST metals/holdings | metal_holding_create | draft/write | 连接权限 | M/R/U/B:待估值、报价、成本盈亏、旧备份 |
|
||||||
| 元数据、分类、分组、图标、归档/恢复、隐藏、计入开关 PATCH positions/:id | position_update,完整 metadata;币种与方向固定 | draft/write | 策略 | M:分组、归档禁止金额更新与恢复;R:隐藏和开关 |
|
| 元数据、分类、分组、图标、归档/恢复、隐藏、计入开关 PATCH positions/:id | position_update,完整 metadata;币种与方向固定 | draft/write | 连接权限 | M:分组、归档禁止金额更新与恢复;R:隐藏和开关 |
|
||||||
| 分组拖拽排序 PATCH settings | settings_update.accountGroupOrder | draft/write | 策略 | M/R:持久化、排序、备份 |
|
| 分组拖拽排序 PATCH settings | settings_update.accountGroupOrder | draft/write | 连接权限 | M/R:持久化、排序、备份 |
|
||||||
| 类别选项、名称与分组配置 | position_create/update;positions_list 读取现有值,Schema/描述提供规则 | read + draft/write | 策略 | M/R;当前没有独立分类/分组实体 CRUD |
|
| 类别选项、名称与分组配置 | position_create/update;positions_list 读取现有值,Schema/描述提供规则 | read + draft/write | 连接权限 | M/R;当前没有独立分类/分组实体 CRUD |
|
||||||
| 新增余额、资产估值、负债余额及单边还款 POST revisions | balance_record,reason 区分;amount 为变更后余额 | draft/write | 策略 | M:精确余额;R/U:负债、估值、业务时间 |
|
| 新增余额、资产估值、负债余额及单边还款 POST revisions | balance_record,reason 区分;amount 为变更后余额 | draft/write | 连接权限 | M:精确余额;R/U:负债、估值、业务时间 |
|
||||||
| 历史全局/单项目 GET history、positions/:id/history | history_list.positionId、cursor、from/to | read | 无 | M/R:页大小、游标、前序余额、日期范围 |
|
| 历史全局/单项目 GET history、positions/:id/history | history_list.positionId、cursor、from/to | read | 无 | M/R:页大小、游标、前序余额、日期范围 |
|
||||||
| 更正普通历史 PUT revisions | history_update | draft/write | 策略 | M/R:重放、精度、日历联动 |
|
| 更正普通历史 PUT revisions | history_update | draft/write | 连接权限 | M/R:重放、精度、日历联动 |
|
||||||
| 删除/撤销历史 DELETE revisions | history_delete;配对记录删除完整双边 | draft/write | 策略,destructive annotation | M/R:余额重算及双边撤销 |
|
| 删除/撤销历史 DELETE revisions | history_delete;配对记录删除完整双边 | draft/write | 连接权限,destructive annotation | M/R:余额重算及双边撤销 |
|
||||||
| 债务关联账户/资产 PUT links | debt_links_set.targetIds | draft/write | 策略 | M:合法关联、隐藏目标拒绝;R:外用户拒绝 |
|
| 债务关联账户/资产 PUT links | debt_links_set.targetIds | draft/write | 连接权限 | M:合法关联、隐藏目标拒绝;R:外用户拒绝 |
|
||||||
| 资金往来列表 GET transfers | movements_list:cursor、日期过滤 | read | 无 | M/R |
|
| 资金往来列表 GET transfers | movements_list:cursor、日期过滤 | read | 无 | M/R |
|
||||||
| 按历史找双边记录 GET transfers/revision/:revisionId | movement_by_revision | read | 无 | M/R |
|
| 按历史找双边记录 GET transfers/revision/:revisionId | movement_by_revision | read | 无 | M/R |
|
||||||
| 转账、借入、借出、收款、还款 POST transfers | movement_create.operation:transfer/borrow/lend/collect/repay | draft/write | 策略,双边事务 | M:转账、repay、信用卡溢缴;R:全部 5 种语义;C:错误回滚 |
|
| 转账、借入、借出、收款、还款 POST transfers | movement_create.operation:transfer/borrow/lend/collect/repay | draft/write | 连接权限,双边事务 | M:转账、repay、信用卡溢缴;R:全部 5 种语义;C:错误回滚 |
|
||||||
| 修改配对记录 PUT transfers/:id | movement_update | draft/write | 策略,后续历史重放 | M/R |
|
| 修改配对记录 PUT transfers/:id | movement_update | draft/write | 连接权限,后续历史重放 | M/R |
|
||||||
| 删除/撤销配对记录 DELETE transfers/:id | movement_delete | draft/write | 策略,双边撤销 | M/R |
|
| 删除/撤销配对记录 DELETE transfers/:id | movement_delete | draft/write | 连接权限,双边撤销 | M/R |
|
||||||
| 净资产、总额、结构、变化归因 GET overview | overview_get,明细分页、保留全局总额 | read | 隐藏项目按本连接可见范围 | M/R/U:总额、开关、外币缺失 |
|
| 净资产、总额、结构、变化归因 GET overview | overview_get,明细分页、保留全局总额 | read | 隐藏项目按本连接可见范围 | M/R/U:总额、开关、外币缺失 |
|
||||||
| 轨迹、日期与统计粒度 GET trend | trend_get:day/week/month 及 from/to | read | 无 | M/R/U:范围、首日、期末、归因 |
|
| 轨迹、日期与统计粒度 GET trend | trend_get:day/week/month 及 from/to | read | 无 | M/R/U:范围、首日、期末、归因 |
|
||||||
| 本位币、币种、实际汇率与更新状态 GET settings | settings_get,固定币种枚举且金额无浮点计算 | read | 无 | M/R/U |
|
| 本位币、币种、实际汇率与更新状态 GET settings | settings_get,固定币种枚举且金额无浮点计算 | read | 无 | M/R/U |
|
||||||
| 本位币切换、显示菜单、备注、闲置退出、登录时长、总览卡片、独立资产计入 PATCH settings | settings_update | draft/write | 策略 | M/R |
|
| 本位币切换、显示菜单、备注、闲置退出、总览卡片、独立资产计入 PATCH settings | settings_update | draft/write | 连接权限 | M/R |
|
||||||
| 是否查看隐藏资产需密码 PATCH settings | settings_update.requireHiddenPassword | sensitive | 强制网页密码确认 | M:draft 权限无法改变;R:会话锁定规则 |
|
| 是否查看隐藏资产需密码 PATCH settings | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 自动/重试日汇率 POST rates/refresh | rates_refresh;已有后台定时更新保留 | draft/write | 策略 | M:真实工具调用;U:成功解析、手工优先、失败保留 |
|
| 自动/重试日汇率 POST rates/refresh | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 金银报价与状态 GET metals | metals_prices,最近 100 条 | read | 无;查询 MCP 不触发隐式自动更新 | M/U/R |
|
| 金银报价与状态 GET metals | metals_prices,最近 100 条 | read | 无;查询 MCP 不触发隐式自动更新 | M/U/R |
|
||||||
| 金银报价刷新 POST metals/refresh | metals_refresh | draft/write | 策略,沿用自动估值 | M:无配置资产调用;U:外部响应和失败回退 |
|
| 金银报价刷新 POST metals/refresh | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 每克报价手动回退 POST metals/prices | metal_price_set,Decimal 价格 | draft/write | 策略 | M/R/U:手动优先、精确估值 |
|
| 贵金属手动报价 | 已从网页、REST、MCP 移除 | 无 | 无 | R:原 REST 路由 404;M:无工具 |
|
||||||
| 克数、品种、纯度、买入成本、自动估价 PUT metals/:id | metal_configure | draft/write | 策略 | M/R/U:估值历史和去重 |
|
| 克数、品种、买入成本、自动估价 PUT metals/:id | metal_configure | draft/write | 连接权限 | M/R/U:估值历史和去重 |
|
||||||
| 应用估价 POST metals/:id/value | metal_value | draft/write | 策略 | M/R/U |
|
| 应用估价 POST metals/:id/value | metal_value | draft/write | 连接权限 | M/R/U |
|
||||||
| 收支月日历 GET calendar | calendar_month,YYYY-MM | read | 无 | M/R/U |
|
| 收支月日历 GET calendar | calendar_month,YYYY-MM | read | 无 | M/R/U |
|
||||||
| 单日日历明细 GET calendar/day | calendar_day,业务日和分页 | read | 无 | M/R/U:历史修改、转账排除、Decimal |
|
| 单日日历明细 GET calendar/day | calendar_day,业务日和分页 | read | 无 | M/R/U:历史修改、转账排除、Decimal |
|
||||||
| 计划列表/状态 GET schedules | schedules_list,按到期排序分页;history_list 查看执行历史 | read | 无 | M/R |
|
| 计划列表/状态 GET schedules | schedules_list,按到期排序分页;history_list 查看执行历史 | read | 无 | M/R |
|
||||||
| 新建/修改支出或转账计划 POST/PUT schedules | schedule_create/update | draft/write | 策略 | M/R:真实执行及编辑限制 |
|
| 新建/修改支出或转账计划 POST/PUT schedules | schedule_create/update | draft/write | 连接权限 | M/R:真实执行及编辑限制 |
|
||||||
| 启用/停用 PATCH schedules/:id | schedule_toggle | draft/write | 策略 | M/R |
|
| 启用/停用 PATCH schedules/:id | schedule_toggle | draft/write | 连接权限 | M/R |
|
||||||
| 删除计划 DELETE schedules/:id | schedule_delete,保留执行历史 | draft/write | 策略 | M/R |
|
| 删除计划 DELETE schedules/:id | schedule_delete,保留执行历史 | draft/write | 连接权限 | M/R |
|
||||||
| 按需运行到期计划 POST schedules/run | schedules_run,最多 20 项、hasMore;MCP 失败整批回滚 | draft/write | 策略,幂等事务 | M/C/R/U |
|
| 按需运行到期计划 POST schedules/run | schedules_run,最多 20 项、hasMore;MCP 失败整批回滚 | draft/write | 连接权限,幂等事务 | M/C/R/U |
|
||||||
| 登录个人资料 GET auth/me | settings_get;connection_info 提供 Agent 授权资料 | read | 无 | M/C |
|
| 登录个人资料 GET auth/me | settings_get;connection_info 提供 Agent 授权资料 | read | 无 | M/C |
|
||||||
| 修改用户名和密码 PATCH auth/credentials | credentials_change_request → 网页 → operation_get | sensitive | 网站验证当前密码、输入新值,Agent 不收到密码 | C/R:真实密码登录、原会话轮换、Agent 降权结果查询 |
|
| 修改用户名和密码 PATCH auth/credentials | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 隐藏资产密码核验 POST auth/reveal | hidden_unlock_request → 网页 → operation_get | sensitive | 网页当前密码,当前连接 5 分钟 | M/R:锁定、隔离、失效规则 |
|
| 隐藏资产密码核验 POST auth/reveal | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 锁定隐藏资产 POST auth/lock | hidden_lock | draft/write | 策略;也可网页锁定 | M/R |
|
| 锁定隐藏资产 POST auth/lock | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 退出登录 POST auth/logout | connection_revoke 退出当前 MCP 连接;网页退出仍在原页面 | 当前连接认证 | 撤销是即时缩小授权,不受写入策略阻碍 | M/O/R:后续 401 |
|
| 退出登录 POST auth/logout | connection_revoke 退出当前 MCP 连接;网页退出仍在原页面 | 当前连接认证 | 撤销是即时缩小授权,不受写入连接权限阻碍 | M/O/R:后续 401 |
|
||||||
| 注册/登录、网页 activity | OAuth 网页入口复用原注册/登录,Agent 令牌不依赖网页 Cookie;activity 为网页会话维护 | 网页认证/OAuth | 用户登录或注册及授权 | O/R、浏览器验证管理入口 |
|
| 注册/登录、网页 activity | OAuth 网页入口复用原注册/登录,Agent 令牌不依赖网页 Cookie;activity 为网页会话维护 | 网页认证/OAuth | 用户登录或注册及授权 | O/R、浏览器验证管理入口 |
|
||||||
| 图标搜索 GET icons | icons_list,60/页、q | read | 无 | M/C/R |
|
| 图标搜索 GET icons | icons_list,60/页、q | read | 无 | M/C/R |
|
||||||
| 图标图片 GET icons/:id/image | icon_image → 同连接 Bearer GET | read | 无 | C/R:PNG 和归属 |
|
| 图标图片 GET icons/:id/image | icon_image → 同连接 Bearer GET | read | 无 | C/R:PNG 和归属 |
|
||||||
| 上传图标 POST icons/upload | file_upload_request(kind=icon) → POST file → icon_publish | draft/write;共享须 sensitive | 私有按策略;共享网页确认及中文名 | C/R:私有和共享实际保存 |
|
| 上传图标 POST icons/upload | file_upload_request(kind=icon) → POST file → icon_publish | draft/write;共享仅网站 | 私有按连接权限;共享仅网站 | C:私有保存及共享拒绝;R:网站共享保存 |
|
||||||
| 导出备份 GET backup | backup_export → 网页 → operation_get → Bearer 下载 | sensitive | 网页密码,短期授权入口 | M/R:完整 ZIP、隐藏数据、令牌排除、跨用户及未登录拒绝 |
|
| 导出备份 GET backup | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 上传备份 POST backup/upload | file_upload_request(kind=backup) → Bearer multipart → file_status | draft/write | 用户控制上传;不提交账目 | M/R:真实 ZIP 传输 |
|
| 上传备份 POST backup/upload | 仅网站上传当前 ZIP v9,MCP 不发行备份上传入口 | 无 MCP 权限 | 网站预检与确认 | R:JSON 拒绝、ZIP 恢复及文件归属 |
|
||||||
| 预检 POST backup/preview | import_preview(token),上传同时预检 | read(上传需要 draft/write) | 不修改账目 | M/R/U:结构/校验/重复/冲突 |
|
| 预检 POST backup/upload 自动预检 | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 追加恢复 POST backup/import、import-file | backup_import(token) → 网页 → operation_get | sensitive | 强制网页密码及数量/冲突预览 | M/R:真实恢复、重复冲突不改变数据 |
|
| 追加恢复 POST backup/import-file | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 清空资格 GET backup/clear-status | data_clear_request 网页确认时复用现有备份资格及指纹 | sensitive | 网站先下载最新备份 | M/R |
|
| 清空资格 GET backup/clear-status | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 清空财务数据 POST backup/clear | data_clear_request → 网页 → operation_get | sensitive | 密码、备份指纹及“确定清空” | M/R:未下载拒绝、真实清空、保留账号 |
|
| 清空财务数据 POST backup/clear | 按用户最新要求仅在网站操作;MCP 不提供该流程 | 无 MCP 权限 | 网站原确认流程 | R/U;M:相关工具不存在且调用拒绝 |
|
||||||
| 新增接入管理、权限、PAT、撤销、调用日志和待确认页 | /api/agent 管理;connection_info、operation_get、state_get;OAuth 标准路径 | 网站身份/本连接 Bearer | PAT/策略验证密码;操作归属校验 | M/C/O、真实浏览器管理页 |
|
| 新增接入管理、权限、PAT、撤销、调用日志和待确认页 | /api/agent 管理;connection_info、operation_get、state_get;OAuth 标准路径 | 网站身份/本连接 Bearer | PAT 创建验证密码;操作归属校验 | M/C/O、真实浏览器管理页 |
|
||||||
|
|
||||||
不存在的业务不列作“已实现”:单个持仓永久删除、账户注销、覆盖导入、批量删除、独立分类/分组对象 CRUD 均不在当前页面/API 中。历史删除、资金往来撤销、计划删除、归档及备份保护清空全部已有路径均已覆盖。
|
不存在的业务不列作“已实现”:单个持仓永久删除、账户注销、覆盖导入、批量删除、独立分类/分组对象 CRUD 均不在当前页面/API 中。历史删除、资金往来撤销、计划删除、归档仍由上述 MCP 工具覆盖;备份保护清空在网站保留,按最新要求退出 MCP 范围。
|
||||||
|
|
||||||
共享服务:PortfolioBusinessService、TransfersBusinessService、SchedulesBusinessService、CalendarBusinessService、SettingsBusinessService、MetalsBusinessService、IconsBusinessService、BackupBusinessService、AuthBusinessService。REST Controller 仅转发并保留原参数、文件拦截器、认证和路由;SDK Catalogue 调用同一服务,不复制金额或重放。
|
共享服务:PortfolioBusinessService、TransfersBusinessService、SchedulesBusinessService、CalendarBusinessService、SettingsBusinessService、MetalsBusinessService、IconsBusinessService。AuthBusinessService 与 BackupBusinessService 保留网站/REST 流程。REST Controller 仅转发并保留原参数、文件拦截器、认证和路由;SDK Catalogue 调用同一服务,不复制金额或重放。
|
||||||
|
|
||||||
金额状态、OAuth、scope、Origin/Host、文件生命周期和客户端限制见 [接入文档](mcp.md)。当前官方客户端验证通过;未验证其他产品配置和生产 HTTPS 反向代理。
|
金额状态、OAuth、scope、Origin/Host、文件生命周期和客户端限制见 [接入文档](mcp.md)。当前官方客户端验证通过;未验证其他产品配置和生产 HTTPS 反向代理。
|
||||||
+42
-97
@@ -1,50 +1,42 @@
|
|||||||
# WorthPath 远程 MCP 接入
|
# WorthPath 远程 MCP 接入
|
||||||
|
|
||||||
## 开始使用
|
2026-10-04:服务提供 39 个工具。按用户最新要求,MCP 不再提供敏感操作、汇率或贵金属报价修改。网站保留备份、清空、密码修改和自动行情功能。完整覆盖矩阵见 [功能覆盖](mcp-coverage.md),本次迁移及验证见 [更新记录](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
网站「设置与备份 → 连接 Agent」显示实际 MCP 地址、写入策略、OAuth 连接、个人令牌、待确认操作和最近调用。默认写入策略为草稿。现有网页登录、REST 和数据计算规则保留。
|
## 开始连接
|
||||||
|
|
||||||
1. 支持远程 OAuth 的客户端填写页面显示的 MCP 地址,选择 Streamable HTTP。
|
在「设置与备份 → 连接 Agent」复制实际 MCP 地址和“Agent 使用教程”。支持远程 OAuth 的客户端填写此地址,选择 Streamable HTTP;在 WorthPath 网页登录,审核客户端名称、回调地址、资源,并选择连接权限。网页会回到已注册的精确回调地址并保留 state。客户端自行验证 state。
|
||||||
2. 客户端通过 `401 WWW-Authenticate` 或 `/.well-known/oauth-protected-resource/mcp` 发现授权服务。支持 OAuth 2.1 授权码、S256 PKCE、RFC 8707 resource、动态注册的公开客户端。
|
|
||||||
3. 浏览器进入 WorthPath,登录或注册,核对客户端名称、回调地址、资源和权限后授权。网站会回到注册的精确回调地址并保留 state。
|
|
||||||
4. 只请求需要的权限:`read` 查询;`draft` 创建草稿;`write` 普通写入;`sensitive` 发起敏感流程。所有授权包含 read。敏感权限仍要求网页验证当前密码,不能用工具参数确认。
|
|
||||||
5. 查询用稳定 UUID。写入先调用 `state_get`,把返回的 state 作为 `expectedState`,同时提供同用户唯一 `idempotencyKey`。状态变化后重新查询并使用新键;网络重试必须保持同键、同参数、同 expectedState。
|
|
||||||
6. 返回 pending 时打开 `confirmationUrl`,用户审核影响并提交或取消。Agent 使用 `operation_get(operationId)` 查询 completed、cancelled、expired 及最终结果。提交失败保留草稿并返回最近失败原因,用户可以重试或取消。
|
|
||||||
|
|
||||||
密码只在网站填写。账号或密码修改后其他连接撤销,当前发起连接只保留 5 分钟 read 权限以读取完成结果,不可刷新;需要重新授权才能继续写入。隐藏资产解锁仅持续 5 分钟并限定当前连接,网站的解锁不自动授予 Agent。
|
OAuth 使用发现元数据、动态注册的公开客户端、授权码、S256 PKCE、RFC 8707 resource。每次 MCP 请求独立验证 Bearer;网页登录 Cookie、MCP 会话 ID 均不作为认证凭证。发现入口为 `/.well-known/oauth-protected-resource/mcp`,未授权响应提供 `WWW-Authenticate`。
|
||||||
|
|
||||||
## 贵金属按克数接入
|
| 连接权限 | OAuth/PAT scopes | 普通修改行为 |
|
||||||
|
| -------- | ---------------- | -------------------------------- |
|
||||||
|
| 只读 | read | 查询,拒绝修改和上传 |
|
||||||
|
| 草稿修改 | read draft | 保存十分钟草稿,由用户在网页确认 |
|
||||||
|
| 直接写入 | read write | 普通修改在事务中直接完成 |
|
||||||
|
|
||||||
新增 `metal_holding_create`:name、currency、metalType(gold/silver)、metalGrams、metalPurity、autoValuation、date,买入价 metalCostPerGram 可省略或 null;无需填写金额或市场价格。写入仍需 expectedState 和 idempotencyKey,并遵循写入策略。有对应业务日或更早报价时复用估值逻辑创建历史,没有报价时返回 valuationAvailable=false,查询总额 complete=false 与 missingValuations。position_get/list 返回可选 metalCost 和 metalProfit,均为原币十进制字符串。成本按实物重量计算,市场价值按重量×纯度×每克纯金属报价计算。
|
选择一种权限等级,draft 和 write 不能同时授予。没有网站全局写入策略。hidden_read 额外允许读取隐藏账户;hidden_write 额外允许修改,必须同时拥有 hidden_read 和 draft/write。两个附加权限默认关闭,OAuth 网页也可选择关闭。服务在查询和修改时分别构造业务上下文,网页解锁状态不自动授予 Agent。
|
||||||
|
|
||||||
metal_configure 省略 metalCostPerGram 保留旧成本,传 null 清除。克数或成本修改与其他写入一样参与并发状态校验;盈亏由共享业务服务计算,不存储额外浮点累计值。[更新与验证](update-metal-grams-2026-10-03.md)。
|
敏感操作(密码/账号修改、清空、备份导出恢复、公开图标发布、安全设置变更)仅在网站进行,不存在 sensitive scope。汇率和参考报价可以查询,MCP 不能修改或主动刷新。贵金属仍可按克数创建、设置成本和重量,并用已有报价更新资产估值;估值历史与报价是不同业务。
|
||||||
|
|
||||||
## 开发启动与环境
|
个人访问令牌仅作为支持自定义 Bearer 头客户端的补充。在网站验证当前密码,选择权限和 1、3、7、30 天、365 天或永久。完整值仅创建时展示,数据库只存 SHA-256 摘要。永久令牌数据库到期日为空,每个请求仍重新检查撤销和资源;为兼容 SDK 中间件,每次认证上下文有有限期验证断言,不改变令牌期限。永久令牌内部业务会话按天续期。OAuth 访问令牌一小时、刷新授权三十天,刷新时轮换摘要;不支持永久 OAuth。
|
||||||
|
|
||||||
```powershell
|
旧连接有 write 时,现在直接执行普通修改;旧全局策略不再阻止它。只想创建草稿时,撤销旧连接并重新授权 read draft。历史策略行保留但不参与任何权限判断;旧敏感工具草稿不可提交。
|
||||||
cd E:\WorthPath
|
|
||||||
.\scripts\pnpm.ps1 install
|
|
||||||
# 保留已有 apps/api/.env,按 .env.example 增加下面三项配置。
|
|
||||||
.\scripts\pnpm.ps1 db:generate
|
|
||||||
.\scripts\pnpm.ps1 db:migrate
|
|
||||||
.\scripts\pnpm.ps1 dev
|
|
||||||
```
|
|
||||||
|
|
||||||
生成 Prisma 前暂停 API,Windows 会锁定其引擎 DLL。迁移仅 deploy,不 reset。
|
## Agent 操作
|
||||||
|
|
||||||
| 变量 | 本地示例 | 用途 |
|
查询使用稳定 UUID;名称重名时先让用户选择。列表 limit 最大 100;图标固定 60;历史和资金往来使用 cursor。日期为 UTC+8 业务日期 `YYYY-MM-DD` 或 `YYYY-MM-DDTHH:mm`,返回时间戳使用 UTC ISO。金额、克数、汇率与报价为十进制字符串,沿用 Decimal 和 DECIMAL 规则。
|
||||||
| ------------------- | ------------------------- | ---------------------------------------------------- |
|
|
||||||
| MCP_PUBLIC_URL | http://localhost:3100/mcp | 固定的 OAuth resource;生产必须 HTTPS,路径必须 /mcp |
|
|
||||||
| MCP_WEB_URL | http://localhost:5173 | 网页授权和操作确认入口;生产必须 HTTPS |
|
|
||||||
| MCP_ALLOWED_ORIGINS | http://localhost:5173 | 精确浏览器 Origin 列表,逗号分隔,不支持通配符 |
|
|
||||||
|
|
||||||
生产继续要求 COOKIE_SECURE=true、准确的 WEB_ORIGIN。MCP 请求以独立 Bearer 验证,不接受网页登录 Cookie 作为授权,不把会话 ID 当凭证。未带 Origin 的非浏览器客户端允许接入;带 Origin 的请求必须匹配列表。Host 必须匹配规范资源地址。反向代理应传递规范的 Host,不以任意转发头构造资源 URL。
|
写入先 `state_get`,再传 expectedState、唯一 idempotencyKey 与具体工具参数。重试必须保持同键、同参数、同状态;状态冲突后重新查询并换键。同用户同键不同参数或不同连接明确拒绝。资金转账、借贷还款、资产估值和绝对余额录入保留原业务语义。
|
||||||
|
|
||||||
## 已验证的客户端配置
|
draft 返回 operationId、pending、confirmationUrl;用户在网站审阅影响后确认或取消,Agent 用 `operation_get` 查询最终结果。confirmed=true 不能绕过确认。提交重新检查权限、过期/撤销、账目版本、归属和业务规则。写入记录与业务修改在同一 Serializable 事务提交,失败回滚。MCP 到期计划按批事务执行,REST 原逐计划执行语义保留。
|
||||||
|
|
||||||
实际验证:官方 `@modelcontextprotocol/sdk@1.31.0`,StreamableHTTPClientTransport;Bearer PAT 和 OAuth 动态注册/发现/PKCE/刷新/撤销均经真实本地服务测试。服务协商 SDK 1.31.0 支持的协议,默认最新 `2025-11-25`。选择维护中的 v1 API 并精确锁定版本,未混用 v2 示例。[官方 SDK](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x)、[对应授权规范](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)。
|
私有图标使用 `file_upload_request(kind=icon)` → 同一 Bearer multipart 上传 file → `file_status` → `icon_publish`。最多 2 MiB,入口十分钟有效,绑定用户和连接。`icon_image` 返回同一 Bearer 保护的短期图片入口。MCP 不发行备份文件入口。
|
||||||
|
|
||||||
下面是测试使用的官方 SDK 构造形式:
|
贵金属 `metal_holding_create` 无需填写市场金额;metalCostPerGram 可省略或 null。没有报价时 valuationAvailable=false,总额 complete=false 且包含 missingValuations。成本按实物重量计算,新持仓价值按重量×每克参考报价计算,既有持仓历史系数保留,盈亏由共享服务计算。metal_configure 省略成本保留旧值,null 清除。创建与配置均不接受 metalPurity 参数,新持仓使用默认比例 1;既有持仓的历史比例不改写。
|
||||||
|
|
||||||
|
## 已验证客户端
|
||||||
|
|
||||||
|
真实验证官方 `@modelcontextprotocol/sdk@1.31.0` 的 StreamableHTTPClientTransport:Bearer、OAuth 发现/DCR/PKCE、轮换、撤销与业务操作。精确锁定 v1 SDK,默认协议 2025-11-25。[官方 SDK](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x)、[授权规范](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)。
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
@@ -59,83 +51,36 @@ console.log(await client.listTools());
|
|||||||
await client.close();
|
await client.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
本项目提供只读协议诊断客户端,完整个人令牌在网站创建时仅展示一次:
|
OAuth 客户端使用 authProvider,浏览器回调后 finishAuth(code) 再连接;真实 provider 示例在 `apps/api/test/mcp.test.ts` 第三组。客户端安全保存 verifier、注册和令牌,禁止跨用户共享。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MCP_SERVER_URL='http://localhost:3100/mcp'
|
$env:MCP_SERVER_URL='http://localhost:3100/mcp'
|
||||||
# 在本机终端设置 MCP_ACCESS_TOKEN;不要写进仓库或聊天。
|
# 在终端安全设置 MCP_ACCESS_TOKEN,不写入仓库或聊天。
|
||||||
pnpm --filter @worthpath/api mcp:probe
|
pnpm --filter @worthpath/api mcp:probe
|
||||||
Remove-Item Env:MCP_ACCESS_TOKEN
|
Remove-Item Env:MCP_ACCESS_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
OAuth 客户端使用 `authProvider` 代替手工 Bearer 头;实现 SDK 的 OAuthClientProvider,在 `redirectToAuthorization` 打开浏览器,在回调后调用 transport.finishAuth(code) 再连接。真实的 provider、发现和交换实现见 `apps/api/test/mcp.test.ts` 第三组测试。生产客户端还应独立校验 state,安全持久化 verifier、客户端注册和令牌,禁止跨用户共享。WorthPath 返回原 state,但客户端必须自行验证。
|
未验证 MCP Inspector、Claude、ChatGPT、Cursor、Codex 等商业客户端的配置和 OAuth 实际行为,不提供推测配置。生产 HTTPS 代理未部署验证。
|
||||||
|
|
||||||
没有验证 MCP Inspector、Claude、ChatGPT、Cursor、Codex 或其他产品的实际 OAuth/config 格式,不提供猜测配置或普遍兼容承诺。HTTPS 代理环境也未实际部署验证。
|
## 启动与部署
|
||||||
|
|
||||||
## 写入、权限和一致性
|
```powershell
|
||||||
|
cd E:\WorthPath
|
||||||
| 用户策略 | draft 权限 | write 权限 | sensitive 权限 |
|
.\scripts\pnpm.ps1 install
|
||||||
| ------------- | ------------- | ------------- | -------------------------------------------------- |
|
# 生成 Prisma 前暂停 API,Windows 会锁定引擎 DLL。
|
||||||
| readonly | 拒绝写入/上传 | 拒绝写入/上传 | 仅允许网页确认的完整备份导出和隐藏解锁,不修改账目 |
|
.\scripts\pnpm.ps1 db:generate
|
||||||
| draft(默认) | 保存草稿 | 保存草稿 | 强制网页确认 |
|
.\scripts\pnpm.ps1 db:migrate
|
||||||
| direct | 保存草稿 | 执行普通写入 | 强制网页确认 |
|
.\scripts\pnpm.ps1 dev
|
||||||
|
|
||||||
草稿 10 分钟到期,保存用户、连接、具体参数、账目摘要和状态。提交重新检查连接过期/撤销、scope、当前策略、账目状态、数据归属及业务规则。`confirmed=true` 被严格 Schema 拒绝。修改隐藏密码要求的设置、发布共享图标也升级为敏感确认。
|
|
||||||
|
|
||||||
幂等记录和业务变更在同一 Serializable 数据库事务提交,用户唯一键保证重试不重复入账。同键不同参数或不同连接明确拒绝。所有普通直接写入和草稿提交通过状态摘要防止覆盖 REST 或其他 Agent 的并发修改。状态包括全部用户账目、历史、计划、汇率、金属报价及相关设置,只返回摘要,不返回隐藏数据。
|
|
||||||
|
|
||||||
复用现有 Decimal、余额计算、转账和历史重放。AsyncLocalStorage 仅保存当前数据库事务,认证信息来自每请求的 Bearer 上下文。现有服务中的嵌套事务加入 MCP 外层事务;MCP 计划执行任一失败会回滚整批,REST 仍保留逐计划执行和失败列表语义。
|
|
||||||
|
|
||||||
金额为十进制字符串,现有 DECIMAL(24,8) 和报价/汇率 DECIMAL(24,12) 保留。`balance_record.amount` 为绝对余额;转账本金/到账/手续费是增量。负债账户数据库正数为欠款,负数为溢缴存款;网站显示相反符号。独立资产和借贷本金非负。业务日期 `YYYY-MM-DD` 或 `YYYY-MM-DDTHH:mm` 为 UTC+8;返回时间戳为 UTC ISO。计划 nextAt 可在未来,余额记录日期不可在未来。
|
|
||||||
|
|
||||||
查询页 limit 最多 100;图标固定 60;历史和资金往来使用 cursor,列表使用 offset。净资产轨迹沿用范围上限:日 366 天、周 3 年、月 10 年。金属和汇率返回最近 100 条。大备份通过文件入口传输。
|
|
||||||
|
|
||||||
## 备份、图标和敏感操作
|
|
||||||
|
|
||||||
备份追加:`file_upload_request(kind=backup)` → 使用返回 URL 和当前 Bearer 以 multipart/form-data 的 file 字段上传 ZIP/旧 JSON → 获取 token 或 `file_status` → `import_preview` → `backup_import` → 网页核对影响并验证密码 → `operation_get`。文件上传上限 512 MiB,ZIP 解压上限沿用现有 1 GiB;实际预检与追加恢复沿用现有严格校验和事务,任何冲突不修改账目。
|
|
||||||
|
|
||||||
完整导出:`backup_export` → 网页密码确认 → `operation_get` 获得 10 分钟有效的 GET URL → 同一连接 Bearer 下载 ZIP。URL 单独不可下载,其他用户/连接不可下载,账目变化必须重新确认。备份包含隐藏项目,但不含密码、Cookie 或 MCP 令牌。Agent 授权、草稿和调用日志不属于财务备份。
|
|
||||||
|
|
||||||
图标:`file_upload_request(kind=icon)` 上传不超过 2 MiB 的图像 → `icon_publish` 保存私有图标;shared=true 需敏感权限、中文名称、网页明确确认 → `icon_image` 获取受 Bearer 保护的 PNG URL。
|
|
||||||
|
|
||||||
清空数据:`data_clear_request` → 网页展示项目/历史/计划数量 → 先在网页下载最新备份 → 验证当前密码并输入“确定清空” → 提交 → `operation_get`。已有备份指纹与 10 分钟有效期规则保留,任何账目变化都要求重新备份。登录账号和共享图标保留。
|
|
||||||
|
|
||||||
当前产品不存在账户注销、任意单个持仓删除、覆盖导入、批量删除和另外的分类实体;因此不新增此类业务。已有删除路径是历史、资金往来、计划删除及备份保护的全量清空,完整支持。名称、分组、分类、图标、归档和统计开关通过 position_update/settings_update 设置。完整覆盖和逐项证据见 [覆盖矩阵](mcp-coverage.md)。
|
|
||||||
|
|
||||||
## 服务和代理
|
|
||||||
|
|
||||||
采用无状态 Streamable HTTP:每个 POST 新建 server/transport,独立 Bearer 认证;关闭响应时释放 transport。响应为 SDK 标准 JSON MCP 结果,支持初始化、协商、工具发现和调用。GET/DELETE 返回 405,没有长期 SSE 会话或会话凭证。当前没有订阅通知需求。
|
|
||||||
|
|
||||||
现有备份上传及 MCP 文件票据在进程内,10–15 分钟过期;进程重启或切换节点后需重新上传/生成入口。**当前部署必须单 API 实例**,不能直接在多节点间轮询文件请求;OAuth、令牌、草稿、幂等和审计本身已持久化。日志不保存工具参数、密码、令牌或财务内容,管理页只展示用户自己的最近 100 条。
|
|
||||||
|
|
||||||
反向代理参考(示例未经实际部署,修改为自己的 HTTPS 域名及上游):
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location = /mcp {
|
|
||||||
proxy_pass http://127.0.0.1:3100;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_read_timeout 330s;
|
|
||||||
proxy_send_timeout 330s;
|
|
||||||
}
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:3100;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
client_max_body_size 512m;
|
|
||||||
proxy_read_timeout 330s;
|
|
||||||
}
|
|
||||||
# 同样代理 /.well-known/、/authorize、/token、/register、/revoke。
|
|
||||||
# 前端静态站点与 MCP_WEB_URL 应位于已配置的 HTTPS origin。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
用户认证数据不进入全局共享变量。SDK OAuth 路由包含进程内限流,现有登录限流也保留,已认证动作按用户及来源 IP 限流。生产单实例应另外配置代理级流量限制和日志保留策略;不得在代理访问日志中记录 Authorization、上传正文或敏感查询参数。数据库账号需要现有 DDL 迁移权限及正常业务 DML 权限。
|
| 环境变量 | 本地示例 | 用途 |
|
||||||
|
| ------------------- | ------------------------- | ------------------------------------ |
|
||||||
|
| MCP_PUBLIC_URL | http://localhost:3100/mcp | 规范资源 URL,路径必须 /mcp |
|
||||||
|
| MCP_WEB_URL | http://localhost:5173 | 网页授权及草稿确认入口 |
|
||||||
|
| MCP_ALLOWED_ORIGINS | http://localhost:5173 | 精确 Origin 列表,逗号分隔,无通配符 |
|
||||||
|
|
||||||
## 实际验证与交付
|
本次没有新增环境变量。保留本机 .env;示例不含凭据。生产必须 HTTPS、COOKIE_SECURE=true、准确 WEB_ORIGIN。Host 匹配规范资源;带 Origin 的请求匹配白名单,无 Origin 的非浏览器客户端允许接入。不得用任意转发头构造授权地址。
|
||||||
|
|
||||||
迁移:`20261003090000_agent_access` 新增授权/令牌/策略/操作 5 表;`20261003100000_agent_audit` 新增无参数调用审计;`20261003110000_agent_comments` 补齐 6 张新表字段的 MySQL 注释。原有财务表与记录保留。已经 migrate deploy,未执行 reset。
|
服务采用无状态 Streamable HTTP,请求结束关闭 transport/server;不共享用户认证上下文,不支持长期 GET SSE 或持久会话。代理转发规范 Host,禁用 MCP 响应缓冲,超时建议 330 秒;同时代理 /.well-known/、/authorize、/token、/register、/revoke 和 /api/。网站备份上传上限 512 MiB,ZIP 解压总计 1 GiB,代理配置对应 body 限制。日志不能保存 Authorization、密码、令牌或上传正文。
|
||||||
|
|
||||||
主要修改:原 8 个业务模块及 auth 抽取 BusinessService;database 增加事务上下文;新增 `src/mcp/{oauth,catalogue,operations,files,management,transport}.ts`;设置页新增 AgentConnections;SDK 精确版本和 pnpm lock;新增真实 MCP 测试及只读 probe。
|
当前实际通过前后端类型检查/构建、34 项单元测试、18 组 REST/MySQL、4 组官方 SDK 测试。24 项迁移已 deploy;17 张表、159 个字段注释完整。未部署或推送。
|
||||||
|
|
||||||
实际通过:前后端 typecheck/build、36 项单元检查、18 组真实 REST/MySQL 回归、3 组真实官方 MCP SDK 端到端检查,工具发现验证 49 个工具(原 mcp:probe 的 48 个工具上增加 metal_holding_create)。新迁移 deploy 成功,实际 MySQL 17 张表(含 Prisma 迁移表)、159 个字段注释完整。测试真实连接 MySQL,使用随机临时用户并清理,未替代现有 REST 回归。贵金属创建、待估值、买入成本、盈亏及报价完成后的页面自动同步已通过真实浏览器验证,测试用户已清理。端到端文件传输、事务回滚、归属、金额精度、幂等、并发、失效/撤销和 OAuth 均在测试中检查;外部行情源的正常和失败路径由原业务单元测试验证,不保证外部报价服务实时可用。
|
|
||||||
|
|
||||||
部署未执行;未推送代码;其他客户端及实际 HTTPS/代理兼容性尚未验证。
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 2026-10-02 更新验收与使用
|
# 2026-10-02 更新验收与使用
|
||||||
|
|
||||||
|
> 历史验收记录:2026-10-04 起所有旧备份兼容与单文件 JSON 支持已删除,当前仅接受 ZIP v9;本文旧版本测试记录不代表现行功能。现行 MCP 权限与工具范围见 [本次更新](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
## 本次范围
|
## 本次范围
|
||||||
|
|
||||||
按主目录 `update.md` 的要求完成 13 项代码更新,包括执行期间追加的红色金额和收支日历;完成时间为 2026-10-02 17:37(Asia/Hong_Kong)。
|
按主目录 `update.md` 的要求完成 13 项代码更新,包括执行期间追加的红色金额和收支日历;完成时间为 2026-10-02 17:37(Asia/Hong_Kong)。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 2026-10-03 账号、删除与备份更新
|
# 2026-10-03 账号、删除与备份更新
|
||||||
|
|
||||||
|
> 历史验收记录:2026-10-04 起所有旧备份兼容与单文件 JSON 支持已删除,当前仅接受 ZIP v9;本文旧版本测试记录不代表现行功能。现行 MCP 权限与工具范围见 [本次更新](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
## 已完成
|
## 已完成
|
||||||
|
|
||||||
- 设置分类新增“账号与密码”。可单独修改账号、密码或同时修改,须验证当前密码;新密码须二次输入。服务端沿用账号格式、密码长度和认证限流,重复账号返回冲突;在事务中撤销全部旧会话并重新签发当前会话,财务数据和个人设置保留。
|
- 设置分类新增“账号与密码”。可单独修改账号、密码或同时修改,须验证当前密码;新密码须二次输入。服务端沿用账号格式、密码长度和认证限流,重复账号返回冲突;在事务中撤销全部旧会话并重新签发当前会话,财务数据和个人设置保留。
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 2026-10-04 Agent、备份与界面更新
|
||||||
|
|
||||||
|
本次按 update.md 和聊天补充要求完成。MCP 工具由 49 个调整为 39 个;敏感操作与汇率、贵金属报价修改退出 MCP 范围。该范围变更取代最初的全部业务 MCP 覆盖要求,网站原业务入口保留。
|
||||||
|
|
||||||
|
## 用户使用
|
||||||
|
|
||||||
|
进入「设置与备份 → 连接 Agent」。默认仅显示服务地址与复制教程,其他内容分为「权限与工具」「连接管理」「操作记录」。教程、SDK 配置与个人令牌表单按需展开。工具清单来自实际后端注册内容。复制教程不会包含密码或令牌。
|
||||||
|
|
||||||
|
OAuth 授权选择只读、草稿修改或直接写入;个人令牌创建也选择相同三档。read 为必需基础权限;draft 与 write 互斥。隐藏账户读取与修改为独立附加授权,默认关闭;修改还要求读取权限及 draft/write。网页解锁不会自动授予 Agent。旧全局策略入口及运行时判断已移除;已有 write 连接现在直接执行普通修改,如需草稿应撤销并重新授权 read draft。
|
||||||
|
|
||||||
|
个人令牌期限为 1、3、7、30、365 天或永久;只展示一次完整值,数据库保存摘要。永久令牌每次请求检查资源与撤销;内部会话有界续期。OAuth 仍使用一小时访问令牌和三十天刷新授权,刷新轮换。实际接入步骤、环境示例和经过验证的官方 SDK 配置见 [MCP 接入](mcp.md)。
|
||||||
|
|
||||||
|
贵金属手动报价入口已从网页、REST 与 MCP 删除。创建、编辑和 MCP Schema 也不再提供纯度输入,新持仓按默认比例 1 估值;保留已有比例、报价来源及历史金额,不静默改写既有账目。重量、可选买入成本、盈亏、自动估值和网站报价刷新保留。账户卡片不重复显示分组徽标,分组标题、筛选与排序保留。
|
||||||
|
|
||||||
|
## 仅支持当前 ZIP v9
|
||||||
|
|
||||||
|
网站「备份与恢复」下载新的 ZIP,并通过上传预检、影响预览、确认追加恢复。单文件 JSON(包括 v3)全部拒绝;ZIP v3–v8 也拒绝,不保留补字段、缺文件回退或版本分支。旧 /api/backup/import 与 /api/backup/preview JSON 路由删除;仅保留 /api/backup、/upload、/import-file 与原清空资格流程。
|
||||||
|
|
||||||
|
ZIP 内仍有可读 JSON 数据文件及校验摘要,但不能将单文件 JSON 作为备份上传。manifest version=9,所有当前文件、设置和元数据必须完整。上传 512 MiB、解压总计 1 GiB;文件绑定用户与会话、十五分钟失效。预检失败删除临时上传;正式恢复冲突或失败在事务中回滚。
|
||||||
|
|
||||||
|
升级前导出的旧备份无法再导入,请从升级后的网站重新导出。此次没有删除用户保存的备份文件,也没有改写财务记录。备份操作仅在网站完成,MCP 不提供导出、恢复或备份上传。
|
||||||
|
|
||||||
|
## 迁移、文件与启动
|
||||||
|
|
||||||
|
两项新增迁移已在本机 MySQL 应用:
|
||||||
|
|
||||||
|
- 20261004090000_agent_permanent_tokens:AgentGrant.expiresAt 可为空,表示可撤销的永久 PAT。
|
||||||
|
- 20261004103000_agent_connection_permissions:更新权限字段及已停用策略表的说明,不删除旧策略行。
|
||||||
|
|
||||||
|
自动审批拒绝了 DROP TABLE 和批量取消待确认操作的提案,原因是具体数据副作用未获授权。因此采用保留原数据的实现;历史 AgentPolicy 表不参与权限判断,已撤下工具的旧草稿无法提交。未执行 reset、drop 或批量取消。
|
||||||
|
|
||||||
|
当前 24 项迁移状态最新;17 张表(含 Prisma 迁移表)、159 个字段注释全部核验通过。核验清单在 [database-comments.json](database-comments.json)。无新增环境变量、无新依赖、无硬编码数据库或授权凭据。
|
||||||
|
|
||||||
|
主要修改文件组:
|
||||||
|
|
||||||
|
- API:backup.ts、backup-format.ts、zip.ts、metals.ts、openapi.ts。
|
||||||
|
- MCP:catalogue.ts、oauth.ts、operations.ts、files.ts、management.ts、transport.ts、information.ts。
|
||||||
|
- 网页:AgentConnections.tsx、MetalPanel.tsx、App.tsx、style.css 及两份翻译字典。
|
||||||
|
- 数据:Prisma Schema 和两项迁移。
|
||||||
|
- 测试:ZIP、MCP、贵金属与设置及所有使用备份恢复的 REST 测试;backup-fixture.ts 将测试数据打包成 ZIP,实际调用上传/确认 API,没有调用已删除 JSON 路由。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\WorthPath
|
||||||
|
# 暂停 API 后生成客户端;保留现有 apps/api/.env。
|
||||||
|
.\scripts\pnpm.ps1 db:generate
|
||||||
|
.\scripts\pnpm.ps1 db:migrate
|
||||||
|
.\scripts\pnpm.ps1 dev
|
||||||
|
```
|
||||||
|
|
||||||
|
现有开发地址 http://localhost:5173,MCP http://localhost:3100/mcp。本次隔离回归用 3101/3102;临时测试服务完成后关闭。没有修改本机 .env,没有部署或推送。README、update.md 原有删除及 Vite 的用户修改保持未提交,只提交与此次功能直接相关的文档更改。
|
||||||
|
|
||||||
|
## 实际验证
|
||||||
|
|
||||||
|
- 前后端 typecheck 与生产 build 通过。
|
||||||
|
- 34 项单元测试通过:精确金额、历史重放、行情失败保留、完整 ZIP 校验、旧版本拒绝、大量记录、无缺字段回退。
|
||||||
|
- 18 组真实 REST/MySQL 通过:全部原业务、配对余额、历史、计划、隐私、设置和 ZIP 恢复;实际上传 JSON v1/v2/v3/v9 都返回 400,旧 JSON 路由返回 404,目标数据不变。
|
||||||
|
- 4 组官方 SDK 1.31.0 MCP 测试通过:39 工具、初始化/发现/调用、普通写入和草稿、幂等/并发/回滚、只读拒绝写入、隐藏读取不授予修改、永久令牌会话续期、期限和撤销、资源/来源限制、OAuth DCR/PKCE/一次性码/轮换及越范围授权拒绝。
|
||||||
|
- 临时账户真实浏览器验证:默认页面分区、折叠表单、固定期限、三档权限、隐藏权限默认关闭、复制教程成功提示、账户卡片无分组徽标,贵金属新增与编辑无纯度或手动报价输入。截图在工作区外的验证目录,docs 不保存图片;临时用户及虚构账户清理。
|
||||||
|
|
||||||
|
没有验证商业 Agent 配置、MCP Inspector 或生产 HTTPS 反向代理;未声称所有客户端兼容。外部行情供应商实时可用性不作为本次通过条件。完整当前功能覆盖与撤下项见 [覆盖矩阵](mcp-coverage.md)。
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 2026-10-03 分组拖拽与账户分行
|
# 2026-10-03 分组拖拽与账户分行
|
||||||
|
|
||||||
|
> 历史验收记录:2026-10-04 起所有旧备份兼容与单文件 JSON 支持已删除,当前仅接受 ZIP v9;本文旧版本测试记录不代表现行功能。现行 MCP 权限与工具范围见 [本次更新](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
分组排序弹窗移除上下移动按钮,改为带拖拽手柄、分组名称和序号的列表。按住手柄拖到目标位置即可调整顺序,拖拽时高亮当前分组,靠近列表边缘自动滚动。点击保存后,账户页、分组筛选及账户选择弹窗同步使用新顺序;取消弹窗不会保存。
|
分组排序弹窗移除上下移动按钮,改为带拖拽手柄、分组名称和序号的列表。按住手柄拖到目标位置即可调整顺序,拖拽时高亮当前分组,靠近列表边缘自动滚动。点击保存后,账户页、分组筛选及账户选择弹窗同步使用新顺序;取消弹窗不会保存。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 2026-10-03 账户分组排序
|
# 2026-10-03 账户分组排序
|
||||||
|
|
||||||
|
> 历史验收记录:2026-10-04 起所有旧备份兼容与单文件 JSON 支持已删除,当前仅接受 ZIP v9;本文旧版本测试记录不代表现行功能。现行 MCP 权限与工具范围见 [本次更新](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
当前排序界面已改为拖拽;最新操作及全部账户分行行为见 [后续更新](update-group-drag-2026-10-03.md)。以下保留首轮验收记录。
|
当前排序界面已改为拖拽;最新操作及全部账户分行行为见 [后续更新](update-group-drag-2026-10-03.md)。以下保留首轮验收记录。
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 账户转账、计入总览与贵金属估价
|
# 账户转账、计入总览与贵金属估价
|
||||||
|
|
||||||
|
> 历史验收记录:2026-10-04 起所有旧备份兼容与单文件 JSON 支持已删除,当前仅接受 ZIP v9;本文旧版本测试记录不代表现行功能。现行 MCP 权限与工具范围见 [本次更新](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
完成与验收:2026-10-03 18:56(UTC+8)。
|
完成与验收:2026-10-03 18:56(UTC+8)。
|
||||||
|
|
||||||
## 账户转账
|
## 账户转账
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 贵金属克数与数据库注释更新
|
# 贵金属克数与数据库注释更新
|
||||||
|
|
||||||
|
> 历史验收记录:2026-10-04 起所有旧备份兼容与单文件 JSON 支持已删除,当前仅接受 ZIP v9;本文旧版本测试记录不代表现行功能。现行 MCP 权限与工具范围见 [本次更新](update-agent-backup-2026-10-04.md)。
|
||||||
|
|
||||||
独立资产新增弹窗选择「贵金属」后,填写黄金/白银、克数、纯度、原币和业务时间,无需填写金额或市场单价。默认随参考价自动估值;没有有效报价时为「待估值」,总览提示不完整,不把未知价值作为已知零值。后续自动/手动报价继续沿用现有 MetalsService 和历史逻辑。
|
独立资产新增弹窗选择「贵金属」后,填写黄金/白银、克数、纯度、原币和业务时间,无需填写金额或市场单价。默认随参考价自动估值;没有有效报价时为「待估值」,总览提示不完整,不把未知价值作为已知零值。后续自动/手动报价继续沿用现有 MetalsService 和历史逻辑。
|
||||||
|
|
||||||
买入价为可选的每克实物成本(原币)。成本 = 总克数 × 买入价;估值 = 总克数 × 纯度 × 对应币种纯金属每克报价;盈亏 = 当前估值 − 成本。全程 Decimal,金额 8 位小数四舍五入;每克成本最多 12 位整数、12 位小数且必须大于零。它是估值盈亏,不包含未记录的税费/已实现卖出收益。省略或清空价格不显示盈亏,缺少估值时盈亏待定。旧贵金属资产可在详情补充成本。
|
买入价为可选的每克实物成本(原币)。成本 = 总克数 × 买入价;估值 = 总克数 × 纯度 × 对应币种纯金属每克报价;盈亏 = 当前估值 − 成本。全程 Decimal,金额 8 位小数四舍五入;每克成本最多 12 位整数、12 位小数且必须大于零。它是估值盈亏,不包含未记录的税费/已实现卖出收益。省略或清空价格不显示盈亏,缺少估值时盈亏待定。旧贵金属资产可在详情补充成本。
|
||||||
|
|||||||
@@ -54,6 +54,20 @@
|
|||||||
- ~~独立资产中贵金属支持估价,更新方式和汇率一致~~ — 已完成并验证:2026-10-03 18:56
|
- ~~独立资产中贵金属支持估价,更新方式和汇率一致~~ — 已完成并验证:2026-10-03 18:56
|
||||||
- ~~贵金属为克数,不用设置价格,可以设置买入价格(当设置了之后会显示盈亏)~~ — 已完成并通过真实 REST/MCP 测试:2026-10-03 22:40(UTC+8);浏览器补充验证与自动估价同步修复完成:2026-10-03 22:55(UTC+8),见 docs/update-metal-grams-2026-10-03.md
|
- ~~贵金属为克数,不用设置价格,可以设置买入价格(当设置了之后会显示盈亏)~~ — 已完成并通过真实 REST/MCP 测试:2026-10-03 22:40(UTC+8);浏览器补充验证与自动估价同步修复完成:2026-10-03 22:55(UTC+8),见 docs/update-metal-grams-2026-10-03.md
|
||||||
- ~~所有MySQL数据表要补充注释~~ — 已完成并验证:2026-10-03 22:40(UTC+8),17 张表、159 个字段,包括 Prisma 迁移表。
|
- ~~所有MySQL数据表要补充注释~~ — 已完成并验证:2026-10-03 22:40(UTC+8),17 张表、159 个字段,包括 Prisma 迁移表。
|
||||||
|
- ~~删除贵金属估价手动录入功能~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~优化MCP使用方法,在页面显示使用教程(可以直接复制到agent)~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~优化MCP令牌时间设置方法(有固定的选项1 3 7 30day 1yaer 永久)~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~MCP 页面可以显示可以操作的事情~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~页面中需要可以详细描述read draft write sensitive可操作的具体事情(可不显示)~~ — 已完成并验证:2026-10-04 01:43(UTC+8);按后续要求保留三档权限说明,sensitive 已撤下。
|
||||||
|
- ~~优化MCP设置界面~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~MCP删除敏感操作的功能,删除修改汇率,贵金属报价的功能~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~可以设置是否允许读取修改隐藏账号,~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~删除网站写入策略,直接按照连接权限设置即可(包含3种权限读 草稿修改 直接写入)~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~删除贵金属的纯度设置~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~优化Agent界面,当前页面显示的太复杂,太乱了,用户不需要一次看到这么多东西~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~在账号页的账号卡片中,不需要显示这个卡片的分组~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~删除所有旧备份兼容,仅支持当前 ZIP v9~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
- ~~删除单文件 JSON 备份支持,包括 JSON v3~~ — 已完成并验证:2026-10-04 01:43(UTC+8)
|
||||||
|
|
||||||
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
|
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user