feat: simplify agent permissions and require current ZIP backups

This commit is contained in:
陈煜 committed 2026-10-04 01:46:47 +08:00
1 parent efc2c7c734
commit 91c489513a
51 files changed
+1680 -1270

No files matched your search

@@ -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';
+4 -4
View File
@@ -68,7 +68,7 @@ model AgentCall {
@@index([userId,createdAt])
}
/// 用户的 Agent 写入策略
/// 已停用的历史 Agent 策略数据,保留原数据但不参与权限判定
model AgentPolicy {
/// 所属用户标识,用于数据隔离
userId String @id @db.Char(36)
@@ -117,7 +117,7 @@ model AgentGrant {
clientId String? @db.Char(36)
/// 用户可见连接名称
name String @db.VarChar(100)
/// 权限列表:read、draft、write、sensitive
/// 权限列表:read、draft 或 write;隐藏账户附加权限 hidden_read、hidden_write
scopes Json
/// 令牌适用的规范 MCP 资源地址
resource String @db.VarChar(500)
@@ -125,8 +125,8 @@ model AgentGrant {
accessDigest String @unique @db.Char(64)
/// 刷新令牌 SHA-256 摘要,使用后轮换
refreshDigest String? @unique @db.Char(64)
/// 到期时间,UTC
expiresAt DateTime
/// 访问令牌到期时间,UTC;空表示可撤销的永久个人令牌
expiresAt DateTime?
/// 刷新授权到期时间,UTC
refreshExpiresAt DateTime?
/// 撤销时间,UTC;空表示未撤销
+2
View File
@@ -0,0 +1,2 @@
// Only the current ZIP container format is supported.
export const BACKUP_ZIP_VERSION = 9;
+100 -109
View File
@@ -1,5 +1,6 @@
import { BACKUP_ZIP_VERSION } from './backup-format';
import { Injectable } from '@nestjs/common';
import { metalConfig, metalPriceInput } from './metals';
import { metalConfig, metalPriceInput, storedMetalPurity } from './metals';
import { scheduleInput } from './schedules';
import { movementDeltas } from './movement';
import { pairedReasons } from './validation';
@@ -21,7 +22,7 @@ import { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { tmpdir } from 'node:os';
import { unlink, open, readFile, readdir, stat } from 'node:fs/promises';
import { unlink, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { archiveBackup, readBackupZip, MAX_UPLOAD_BYTES } from './zip';
@@ -56,22 +57,31 @@ const timestamp = z.iso
);
const record = positionMeta
.extend({
metalType: z.enum(['gold', 'silver']).nullable().optional(),
metalGrams: amount.nullable().optional(),
metalCostPerGram: metalConfig.shape.metalCostPerGram,
metalPurity: metalConfig.shape.metalPurity.optional(),
autoValuation: z.boolean().optional(),
groupName: z.string().max(60),
included: z.boolean(),
iconId: z.string().uuid().nullable(),
notes: z.string().max(2000),
archived: z.boolean(),
hidden: z.boolean(),
metalType: z.enum(['gold', 'silver']).nullable(),
metalGrams: amount.nullable(),
metalCostPerGram: metalConfig.shape.metalCostPerGram.nonoptional(),
metalPurity: storedMetalPurity,
autoValuation: z.boolean(),
kind: z.enum(['account', 'asset', 'debt']),
side: z.enum(['asset', 'liability']),
currency,
id: z.string().uuid(),
importedFromId: z.string().uuid().nullable().optional(),
importedFromId: z.string().uuid().nullable(),
createdAt: timestamp,
updatedAt: timestamp,
revisions: z.array(
revisionInput.extend({
id: z.string().uuid(),
sequence: z.number().int().positive().max(2147483647).optional(),
notes: z.string().max(2000),
reason: revisionInput.shape.reason.unwrap(),
date: revisionInput.shape.date.refine((s) => s.length === 16, '备份业务时间必须精确到分钟'),
sequence: z.number().int().positive().max(2147483647),
createdAt: timestamp,
updatedAt: timestamp,
}),
@@ -81,68 +91,65 @@ const record = positionMeta
const backupSchema = z
.object({
format: z.literal('worthpath'),
version: z.union([z.literal(1), z.literal(2)]),
version: z.literal(BACKUP_ZIP_VERSION, { error: '备份数据必须来自当前 ZIP v9 格式' }),
exportedAt: z.iso.datetime(),
baseCurrency: currency,
currencies: z.array(currency).max(10),
preferences: z
.object({
showSidebar: z.boolean().optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
includeIndependentAssets: z.boolean().optional(),
showNotes: z.boolean().optional(),
hiddenMenus: hiddenMenus,
accountGroupOrder: accountGroupOrder,
sessionHours: sessionHours,
requireHiddenPassword: z.boolean(),
overviewCards: overviewCards,
includeIndependentAssets: z.boolean(),
showNotes: z.boolean(),
idleMinutes: z.number().int().min(0).max(1440),
})
.strict()
.optional(),
icons: z
.array(
z
.object({
id: z.string().uuid(),
name: iconName,
shared: z.boolean(),
image: z.string().max(3 * 1024 * 1024),
hash: z.string().regex(/^[a-f0-9]{64}$/),
})
.strict(),
)
.optional(),
transfers: z
.array(
transferInput.safeExtend({
.strict(),
icons: z.array(
z
.object({
id: z.string().uuid(),
importedFromId: z.string().uuid().nullable().optional(),
sourceRevisionId: z.string().uuid(),
targetRevisionId: z.string().uuid(),
sourceCurrency: currency,
targetCurrency: currency,
createdAt: timestamp,
}),
)
.optional(),
schedules: z
.array(
scheduleInput.safeExtend({
id: z.string().uuid(),
importedFromId: z.string().uuid().nullable().optional(),
enabled: z.boolean(),
completed: z.boolean().default(false),
}),
)
.optional(),
metalPrices: z
.array(
metalPriceInput.extend({
source: z.enum(['manual', 'goldapi']),
quotedAt: timestamp,
}),
)
.optional(),
name: iconName,
shared: z.boolean(),
image: z.string().max(3 * 1024 * 1024),
hash: z.string().regex(/^[a-f0-9]{64}$/),
})
.strict(),
),
transfers: z.array(
transferInput.safeExtend({
id: z.string().uuid(),
importedFromId: z.string().uuid().nullable(),
operation: transferInput.shape.operation.unwrap(),
fee: transferInput.shape.fee.unwrap(),
notes: z.string().max(2000),
date: transferInput.shape.date.refine((s) => s.length === 16),
sourceRevisionId: z.string().uuid(),
targetRevisionId: z.string().uuid(),
sourceCurrency: currency,
targetCurrency: currency,
createdAt: timestamp,
}),
),
schedules: z.array(
scheduleInput.safeExtend({
id: z.string().uuid(),
importedFromId: z.string().uuid().nullable(),
enabled: z.boolean(),
completed: z.boolean(),
targetId: z.string().uuid().nullable(),
received: amount,
notes: z.string().max(2000),
}),
),
metalPrices: z.array(
metalPriceInput.extend({
source: z.enum(['manual', 'goldapi']),
quotedAt: timestamp,
}),
),
positions: z.array(record),
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
@@ -155,11 +162,11 @@ export function validateBackup(raw: unknown) {
if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID');
const origins = b.positions.map((p) => p.importedFromId || p.id);
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
const quoteKeys = (b.metalPrices || []).map((q) => q.metalType + q.currency + q.date);
const quoteKeys = b.metalPrices.map((q) => q.metalType + q.currency + q.date);
if (new Set(quoteKeys).size !== quoteKeys.length) throw new BadRequestException('重复贵金属报价');
const iconIds = new Set((b.icons || []).map((i) => i.id));
const iconIds = new Set(b.icons.map((i) => i.id));
if (
iconIds.size !== (b.icons || []).length ||
iconIds.size !== b.icons.length ||
b.positions.some((p) => p.iconId && !iconIds.has(p.iconId))
)
throw new BadRequestException('图标关联无效');
@@ -172,8 +179,7 @@ export function validateBackup(raw: unknown) {
metalType: p.metalType,
metalGrams: p.metalGrams,
metalCostPerGram: p.metalCostPerGram,
metalPurity: p.metalPurity || '1',
autoValuation: p.autoValuation || false,
autoValuation: p.autoValuation,
});
}
positionInput.parse({
@@ -203,7 +209,7 @@ export function validateBackup(raw: unknown) {
if (p.kind !== 'account' && p.revisions.some((r) => r.amount.startsWith('-')))
throw new BadRequestException('仅账户支持负余额');
const planIds = new Set<string>();
for (const plan of b.schedules || []) {
for (const plan of b.schedules) {
const source = ids.get(plan.sourceId),
target = plan.targetId ? ids.get(plan.targetId) : null;
if (
@@ -222,7 +228,7 @@ export function validateBackup(raw: unknown) {
}
const transferIds = new Set<string>(),
usedRevisions = new Set<string>();
for (const t of b.transfers || []) {
for (const t of b.transfers) {
const source = ids.get(t.sourceId),
target = ids.get(t.targetId);
const origin = t.importedFromId || t.id;
@@ -328,16 +334,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
await this.prune(true);
}
private async uploadedData(path: string) {
const handle = await open(path, 'r');
const prefix = Buffer.alloc(2);
try {
await handle.read(prefix, 0, 2, 0);
} finally {
await handle.close();
}
return prefix.toString() === 'PK'
? readBackupZip(path)
: JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
return readBackupZip(path);
}
constructor(private db: Database) {}
async snapshot(userId: string) {
@@ -429,7 +426,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
const schedules = await client.schedule.findMany({ where: { userId } });
return backupSchema.parse({
format: 'worthpath',
version: 2,
version: BACKUP_ZIP_VERSION,
exportedAt: new Date().toISOString(),
baseCurrency: user.baseCurrency,
preferences: {
@@ -569,13 +566,13 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
data.rates.sort((a, b) =>
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
);
data.metalPrices?.sort((a, b) =>
data.metalPrices.sort((a, b) =>
(a.metalType + a.currency + a.date).localeCompare(b.metalType + b.currency + b.date),
);
data.currencies.sort();
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
data.schedules?.sort((a, b) => a.id.localeCompare(b.id));
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
data.transfers.sort((a, b) => a.id.localeCompare(b.id));
data.schedules.sort((a, b) => a.id.localeCompare(b.id));
data.icons.sort((a, b) => a.id.localeCompare(b.id));
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
}
async clearStatus(r: UserRequest) {
@@ -611,15 +608,15 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
async preview(r: UserRequest, raw: unknown) {
const b = validateBackup(raw),
existing = await this.data(r.userId);
for (const i of b.icons || []) await validateStoredIcon(i.image, i.hash);
for (const i of b.icons) await validateStoredIcon(i.image, i.hash);
this.conflicts(b, existing);
return {
positions: b.positions.length,
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
rates: b.rates.length,
icons: (b.icons || []).length,
transfers: (b.transfers || []).length,
schedules: (b.schedules || []).length,
icons: b.icons.length,
transfers: b.transfers.length,
schedules: b.schedules.length,
baseCurrency: b.baseCurrency,
currentBaseCurrency: existing.baseCurrency,
message:
@@ -650,7 +647,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
.parse(raw),
b = validateBackup(backup);
const iconData = new Map<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(
async (tx) => {
const ps = await tx.position.findMany({
@@ -671,7 +668,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
} as unknown as Backup;
this.conflicts(b, existing);
const iconMapping = new Map<string, string>();
for (const i of b.icons || []) {
for (const i of b.icons) {
const row = await tx.icon.upsert({
where: { ownerId_hash_shared: { ownerId: r.userId, hash: i.hash, shared: false } },
create: {
@@ -742,7 +739,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
});
originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id));
}
for (const q of b.metalPrices || []) {
for (const q of b.metalPrices) {
const key = {
userId: r.userId,
metalType: q.metalType,
@@ -759,7 +756,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
data: { ...key, price: q.price, source: q.source, quotedAt: new Date(q.quotedAt) },
});
}
for (const t of b.transfers || [])
for (const t of b.transfers)
await tx.transfer.create({
data: {
userId: r.userId,
@@ -779,7 +776,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
createdAt: new Date(t.createdAt),
},
});
for (const plan of b.schedules || []) {
for (const plan of b.schedules) {
const { id, importedFromId, ...v } = plan;
await tx.schedule.create({
data: {
@@ -814,17 +811,17 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
where: { id: r.userId },
data: {
baseCurrency: b.baseCurrency,
idleMinutes: b.preferences?.idleMinutes,
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
showNotes: b.preferences?.showNotes,
accountGroupOrder: b.preferences?.accountGroupOrder,
sessionHours: b.preferences?.sessionHours,
requireHiddenPassword: b.preferences?.requireHiddenPassword,
overviewCards: b.preferences?.overviewCards,
includeIndependentAssets: b.preferences?.includeIndependentAssets,
idleMinutes: b.preferences.idleMinutes,
hiddenMenus: b.preferences.hiddenMenus.join(','),
showNotes: b.preferences.showNotes,
accountGroupOrder: b.preferences.accountGroupOrder,
sessionHours: b.preferences.sessionHours,
requireHiddenPassword: b.preferences.requireHiddenPassword,
overviewCards: b.preferences.overviewCards,
includeIndependentAssets: b.preferences.includeIndependentAssets,
},
});
if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined)
if (!ps.length && !rs.length)
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
return { ok: true, positions: b.positions.length };
},
@@ -861,10 +858,4 @@ export class BackupController {
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
return this.service.clear(r, raw);
}
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
return this.service.preview(r, raw);
}
@Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) {
return this.service.restore(r, raw);
}
}
+8 -60
View File
@@ -6,9 +6,8 @@ import { TransfersBusinessService } from '../transfers';
import { SchedulesBusinessService, scheduleInput } from '../schedules';
import { CalendarBusinessService } from '../calendar';
import { SettingsBusinessService } from '../rates';
import { MetalsBusinessService, metalConfig, metalHoldingInput, metalPriceInput } from '../metals';
import { MetalsBusinessService, metalConfig, metalHoldingInput } from '../metals';
import { IconsBusinessService } from '../icons';
import { BackupBusinessService } from '../backup';
import {
positionInput,
positionMeta,
@@ -49,9 +48,8 @@ export type ToolDefinition = {
name: string;
description: string;
schema: z.ZodObject<any>;
scope: 'read' | 'write' | 'sensitive';
scope: 'read' | 'write';
destructive?: boolean;
web?: 'credentials' | 'reveal' | 'clear';
run?: (r: UserRequest, p: any) => Promise<unknown>;
};
@Injectable()
@@ -65,7 +63,6 @@ export class AgentCatalogue {
settings: SettingsBusinessService,
metals: MetalsBusinessService,
icons: IconsBusinessService,
backup: BackupBusinessService,
) {
const read = (
name: string,
@@ -282,13 +279,13 @@ export class AgentCatalogue {
),
write(
'settings_update',
'修改个人设置、本位币、分组排序、登录时长及纳入统计配置。隐私设置变更需网页确认。',
settingsInput,
'修改个人设置、本位币、分组排序、登录时长及纳入统计配置。安全设置仅在网站修改。',
settingsInput.safeExtend({
requireHiddenPassword: z.never().optional(),
sessionHours: z.never().optional(),
}),
(r, p) => settings.update(r, p, undefined as any),
),
write('rates_refresh', '重试公共日汇率更新,失败保留原币和历史汇率。', empty, (r) =>
settings.refresh(r),
),
write(
'metal_holding_create',
'按克数创建金银资产,无需市场价格;买入每克成本可选,缺少报价时待估值。日期为 UTC+8 业务时间。',
@@ -296,18 +293,9 @@ export class AgentCatalogue {
(r, p) => metals.create(r, p),
),
read('metals_prices', '最近 100 条金银每克报价和更新状态。', empty, (r) => metals.list(r)),
write('metals_refresh', '刷新贵金属报价并沿用现有自动估值规则。', empty, (r) =>
metals.refresh(r),
),
write(
'metal_price_set',
'设置指定日期、币种、品种每克价格;price 十进制字符串,可能触发自动估值历史。',
metalPriceInput,
(r, p) => metals.manual(r, p),
),
write(
'metal_configure',
'设置金银重量、纯度和自动估值,复用现有估值规则。',
'设置金银重量和自动估值,复用现有估值规则。',
z.object({ id, data: metalConfig }).strict(),
(r, p) => metals.configure(r, p.id, p.data),
),
@@ -328,46 +316,6 @@ export class AgentCatalogue {
.strict(),
(r, p) => icons.list(r, p.q, String(p.page)),
),
{
name: 'backup_import',
description:
'提交已上传备份的追加恢复。先 file_upload_request → 上传 → import_preview;强制网页展示影响并确认,事务失败不保留部分账目。',
schema: z.object({ token: id }).strict(),
scope: 'sensitive',
destructive: true,
run: (r, p) => backup.restoreUpload(r, p.token),
},
read(
'import_preview',
'预检已上传备份并显示追加影响、冲突和条数。',
z.object({ token: id }).strict(),
async (r, p) => (await backup.inspectUpload(r, p.token)).preview,
),
{
name: 'credentials_change_request',
description:
'发起账号或密码修改,返回网页入口。当前密码及新密码仅在网页输入,不传给 Agent。完成后 operation_get 查询结果。',
schema: empty,
scope: 'sensitive',
web: 'credentials',
},
{
name: 'hidden_unlock_request',
description:
'发起隐藏项目解锁。网页用户验证密码后本连接解锁 5 分钟;operation_get 查询结果。',
schema: empty,
scope: 'sensitive',
web: 'reveal',
},
{
name: 'data_clear_request',
description:
'发起清空本账号财务数据。网页须先下载当前备份、验证密码并输入“确定清空”,展示数量;账号保留。',
schema: empty,
scope: 'sensitive',
web: 'clear',
destructive: true,
},
];
}
get(name: string) {
+11 -49
View File
@@ -8,28 +8,20 @@ import {
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Request, Response, Express } from 'express';
import multer, { diskStorage, memoryStorage } from 'multer';
import { tmpdir } from 'node:os';
import { unlink } from 'node:fs/promises';
import multer, { memoryStorage } from 'multer';
import { AgentOAuth, urls } from './oauth';
import { UserRequest } from '../auth';
import { BackupBusinessService } from '../backup';
import { IconsBusinessService } from '../icons';
import { MAX_UPLOAD_BYTES } from '../zip';
import { Database } from '../database';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
type Ticket = {
userId: string;
grantId: string;
expires: number;
kind: 'backup' | 'icon' | 'download' | 'image';
snapshot?: string;
kind: 'icon' | 'image';
iconId?: string;
buffer?: Buffer;
name?: string;
preview?: unknown;
token?: string;
};
@Injectable()
export class AgentFiles implements OnModuleDestroy {
@@ -39,9 +31,7 @@ export class AgentFiles implements OnModuleDestroy {
}, 60000).unref();
constructor(
private oauth: AgentOAuth,
private backup: BackupBusinessService,
private icons: IconsBusinessService,
private db: Database,
) {}
onModuleDestroy() {
clearInterval(this.timer);
@@ -60,19 +50,15 @@ export class AgentFiles implements OnModuleDestroy {
kind,
iconId,
expires: Date.now() + 600000,
...(kind === 'download' ? { snapshot: await this.backup.snapshot(r.userId) } : {}),
});
return {
fileId: id,
url: new URL('/api/agent/files/' + id, urls().issuer).toString(),
method: kind === 'download' || kind === 'image' ? 'GET' : 'POST',
method: kind === 'image' ? 'GET' : 'POST',
headers: { Authorization: 'Bearer <your access token>' },
expiresAt: new Date(Date.now() + 600000).toISOString(),
maxBytes: kind === 'backup' ? MAX_UPLOAD_BYTES : 2 * 1024 * 1024,
format:
kind === 'backup'
? 'multipart/form-data; field file; WorthPath ZIP/JSON'
: 'multipart/form-data; field file; image',
maxBytes: 2 * 1024 * 1024,
format: kind === 'image' ? 'image/png' : 'multipart/form-data; field file; image',
};
}
private ticket(r: UserRequest, grantId: string, id: string) {
@@ -94,9 +80,7 @@ export class AgentFiles implements OnModuleDestroy {
const t = this.ticket(r, grantId, id);
return {
fileId: id,
uploaded: !!t.buffer || !!t.token,
token: t.token,
preview: t.preview,
uploaded: !!t.buffer,
expiresAt: new Date(t.expires).toISOString(),
};
}
@@ -117,13 +101,6 @@ export class AgentFiles implements OnModuleDestroy {
};
}
install(app: Express) {
const disk = multer({
storage: diskStorage({
destination: tmpdir(),
filename: (_r, _f, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
}),
limits: { fileSize: MAX_UPLOAD_BYTES, files: 1, fields: 0 },
}).single('file');
const memory = multer({
storage: memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 },
@@ -136,38 +113,23 @@ export class AgentFiles implements OnModuleDestroy {
res.status(405).end();
return;
}
if ((req.method === 'POST') !== (t.kind === 'backup' || t.kind === 'icon')) {
if ((req.method === 'POST') !== (t.kind === 'icon')) {
res.status(405).end();
return;
}
if (req.method === 'GET') {
if (t.kind === 'download') await this.backup.download(r, res, t.snapshot);
else await this.icons.image(r, t.iconId!, res);
await this.icons.image(r, t.iconId!, res);
return;
}
const selected = grant.scopes as string[];
if (!selected.includes('draft') && !selected.includes('write'))
throw new ForbiddenException('上传需要 draft 或 write 权限');
if (
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ===
'readonly'
)
throw new ForbiddenException('当前策略为只读');
await new Promise<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('请选择文件');
try {
if (t.kind === 'backup') {
const v = await this.backup.upload(r, req.file);
t.token = v.token;
t.preview = v;
} else t.buffer = req.file.buffer;
res.json(await this.inspect(r, grant.id, String(req.params.id)));
} catch (e) {
if (req.file.path) await unlink(req.file.path).catch(() => {});
throw e;
}
t.buffer = req.file.buffer;
res.json(await this.inspect(r, grant.id, String(req.params.id)));
} catch (e) {
if (!res.headersSent)
res
+27
View File
@@ -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;
+16 -21
View File
@@ -2,7 +2,6 @@ import {
Controller,
Get,
Post,
Put,
Delete,
Req,
Param,
@@ -18,6 +17,7 @@ import { Database } from '../database';
import { AuthService, UserRequest } from '../auth';
import { AgentOAuth, urls, scopeInput } from './oauth';
import { AgentOperations } from './operations';
import { protocolTools, tokenDays } from './information';
@Controller('api/agent')
export class AgentManagementController {
constructor(
@@ -62,34 +62,26 @@ export class AgentManagementController {
});
return {
mcpUrl: urls().resource.toString(),
mode:
(await this.db.agentPolicy.findUnique({ where: { userId: r.userId } }))?.mode || 'draft',
capabilities: [
...this.operations.tools.map((t) => ({
name: t.name,
description: t.description,
scope: t.scope,
destructive: !!t.destructive,
})),
...protocolTools,
],
grants,
operations,
calls,
};
}
@Put('policy') async policy(@Req() r: UserRequest, @Body() raw: unknown) {
const { mode, password } = z
.object({ mode: z.enum(['readonly', 'draft', 'direct']), password: z.string().max(72) })
.strict()
.parse(raw);
this.auth.limit(r);
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
await this.db.agentPolicy.upsert({
where: { userId: r.userId },
create: { userId: r.userId, mode },
update: { mode },
});
return { mode };
}
@Post('tokens') async token(@Req() r: UserRequest, @Body() raw: unknown) {
const p = z
.object({
name: z.string().trim().min(1).max(100),
scopes: scopeInput,
days: z.number().int().min(1).max(90),
days: z.union([z.literal(null), ...tokenDays.map((d) => z.literal(d))]),
password: z.string().max(72),
})
.strict()
@@ -118,8 +110,11 @@ export class AgentManagementController {
@Param('id') id: string,
@Body() raw: unknown,
) {
const { approve } = z.object({ approve: z.boolean() }).strict().parse(raw);
return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve);
const { approve, scopes } = z
.object({ approve: z.boolean(), scopes: scopeInput.optional() })
.strict()
.parse(raw);
return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve, scopes);
}
@Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) {
return this.operations.preview(r.userId, z.string().uuid().parse(id));
+50 -11
View File
@@ -20,12 +20,19 @@ import {
InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
export const scopes = ['read', 'draft', 'write', 'sensitive'] as const;
export const scopes = ['read', 'draft', 'write', 'hidden_read', 'hidden_write'] as const;
export const scopeInput = z
.array(z.enum(scopes))
.min(1)
.max(4)
.refine((v) => v.includes('read') && new Set(v).size === v.length);
.refine(
(v) =>
v.includes('read') &&
new Set(v).size === v.length &&
!(v.includes('draft') && v.includes('write')) &&
(!v.includes('hidden_write') ||
(v.includes('hidden_read') && (v.includes('draft') || v.includes('write')))),
);
export const digest = (s: string) => createHash('sha256').update(s).digest('hex');
const secret = () => randomBytes(32).toString('base64url');
export function urls() {
@@ -115,7 +122,15 @@ export class AgentOAuth implements OAuthServerProvider {
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response) {
this.resource(params.resource);
const selected = params.scopes?.length ? params.scopes : ['read'];
if (!scopeInput.safeParse(selected).success) throw new InvalidScopeError('Unsupported scope');
if (
!z
.array(z.enum(scopes))
.min(1)
.max(scopes.length)
.refine((v) => v.includes('read') && new Set(v).size === v.length)
.safeParse(selected).success
)
throw new InvalidScopeError('Unsupported scope');
const row = await this.db.agentAuthorization.create({
data: {
clientId: client.client_id,
@@ -141,10 +156,15 @@ export class AgentOAuth implements OAuthServerProvider {
resource: p.resource,
};
}
async consent(userId: string, id: string, approved: boolean) {
async consent(userId: string, id: string, approved: boolean, selected?: string[]) {
return this.db.atomic(async () => {
await this.pending(id);
const row = await this.db.agentAuthorization.findUniqueOrThrow({ where: { id } });
const parameters = row.parameters as any;
const allowed = selected || ['read'];
scopeInput.parse(allowed);
if (allowed.some((scope: string) => !parameters.scopes.includes(scope)))
throw new BadRequestException('不能授予客户端未请求的权限');
const code = secret();
const changed = await this.db.agentAuthorization.updateMany({
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
@@ -152,6 +172,7 @@ export class AgentOAuth implements OAuthServerProvider {
userId,
status: approved ? 'approved' : 'denied',
codeDigest: approved ? digest(code) : null,
parameters: { ...parameters, scopes: allowed },
},
});
if (!changed.count) throw new BadRequestException('授权请求已处理');
@@ -175,14 +196,25 @@ export class AgentOAuth implements OAuthServerProvider {
throw new InvalidGrantError('Invalid authorization code');
return (row.parameters as any).codeChallenge as string;
}
async issue(userId: string, name: string, selected: string[], days: number, clientId?: string) {
async issue(
userId: string,
name: string,
selected: string[],
days: number | null,
clientId?: string,
) {
if (clientId && days === null) throw new BadRequestException('OAuth 连接必须有期限');
const access = secret(),
refresh = clientId ? secret() : undefined,
sessionId = digest(secret());
const expiresAt = new Date(Date.now() + days * 86400000),
const expiresAt = days === null ? null : new Date(Date.now() + days * 86400000),
refreshExpiresAt = clientId ? new Date(Date.now() + 30 * 86400000) : null;
await this.db.session.create({
data: { id: sessionId, userId, expiresAt: refreshExpiresAt || expiresAt },
data: {
id: sessionId,
userId,
expiresAt: refreshExpiresAt || expiresAt || new Date(Date.now() + 86400000),
},
});
const grant = await this.db.agentGrant.create({
data: {
@@ -203,7 +235,7 @@ export class AgentOAuth implements OAuthServerProvider {
tokens: {
access_token: access,
token_type: 'Bearer',
expires_in: Math.floor(days * 86400),
...(days === null ? {} : { expires_in: Math.floor(days * 86400) }),
scope: selected.join(' '),
...(refresh ? { refresh_token: refresh } : {}),
} as OAuthTokens,
@@ -296,7 +328,7 @@ export class AgentOAuth implements OAuthServerProvider {
if (
!row ||
row.revokedAt ||
row.expiresAt <= new Date() ||
(row.expiresAt ? row.expiresAt <= new Date() : !!row.clientId) ||
row.resource !== urls().resource.toString()
)
throw new InvalidTokenError('Expired, revoked or invalid resource token');
@@ -304,7 +336,9 @@ export class AgentOAuth implements OAuthServerProvider {
token,
clientId: row.clientId || row.id,
scopes: row.scopes as string[],
expiresAt: Math.floor(+row.expiresAt / 1000),
// SDK bearer middleware requires a finite verified-authentication expiry.
// A permanent PAT stays expiry-free in storage; every request rechecks revocation.
expiresAt: Math.floor((row.expiresAt?.getTime() ?? Date.now() + 3600000) / 1000),
resource: new URL(row.resource),
extra: { grantId: row.id, userId: row.userId },
};
@@ -320,7 +354,12 @@ export class AgentOAuth implements OAuthServerProvider {
}
async grant(id: string, userId?: string) {
const row = await this.db.agentGrant.findFirst({
where: { id, ...(userId ? { userId } : {}), revokedAt: null, expiresAt: { gt: new Date() } },
where: {
id,
...(userId ? { userId } : {}),
revokedAt: null,
OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null, clientId: null }],
},
});
if (!row) throw new ForbiddenException('Agent 连接已过期或撤销');
return row;
+43 -113
View File
@@ -6,12 +6,10 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Prisma, AgentGrant } from '@prisma/client';
import { compare } from 'bcryptjs';
import { Response } from 'express';
import { z } from 'zod';
import { Database } from '../database';
import { AuthBusinessService, UserRequest } from '../auth';
import { BackupBusinessService } from '../backup';
import { UserRequest } from '../auth';
import { AgentOAuth, digest, webLink } from './oauth';
import { AgentCatalogue, ToolDefinition, empty } from './catalogue';
import { AgentFiles } from './files';
@@ -46,41 +44,23 @@ export class AgentOperations {
private db: Database,
private oauth: AgentOAuth,
catalogue: AgentCatalogue,
private auth: AuthBusinessService,
private backup: BackupBusinessService,
private files: AgentFiles,
) {
this.tools = [
...catalogue.tools,
{
name: 'backup_export',
description:
'创建短期受 Bearer 保护的完整 ZIP 备份下载入口,包含隐藏项目;网页验证密码并确认后 operation_get 获取入口,URL 本身不是凭证。',
schema: empty,
scope: 'sensitive',
run: async (r) => this.files.issue(r, r.agentGrantId!, 'download'),
},
{
name: 'icon_publish',
description:
'保存已上传图标,shared=true 发布到共享库,须中文名称及网页确认。先 file_upload_request(kind=icon)。',
description: '保存已上传私有图标,不允许发布共享图标。先 file_upload_request(kind=icon)。',
schema: z
.object({
fileId: z.string().uuid(),
name: z.string().min(1).max(100),
shared: z.boolean().default(false),
shared: z.literal(false).default(false),
})
.strict(),
scope: 'write',
run: (r, p) => this.files.publishIcon(r, r.agentGrantId!, p.fileId, p.name, p.shared),
},
{
name: 'hidden_lock',
description: '立即锁定本连接的隐藏项目授权。',
schema: empty,
scope: 'write',
run: async (r) => this.auth.lock(r),
},
];
}
get(name: string) {
@@ -88,13 +68,32 @@ export class AgentOperations {
if (!t) throw new BadRequestException('未知工具');
return t;
}
async context(grant: AgentGrant) {
const s = await this.db.session.findUnique({ where: { id: grant.sessionId } });
async context(grant: AgentGrant, writing = false) {
let s = await this.db.session.findUnique({ where: { id: grant.sessionId } });
// A permanent PAT is the credential. Its internal business session has a bounded
// lifetime and may be recreated after the normal expired-session cleanup.
if (!grant.expiresAt && !grant.clientId && (!s || s.expiresAt <= new Date())) {
await this.oauth.grant(grant.id, grant.userId);
s = await this.db.session.upsert({
where: { id: grant.sessionId },
create: {
id: grant.sessionId,
userId: grant.userId,
expiresAt: new Date(Date.now() + 86400000),
},
update: {
expiresAt: new Date(Date.now() + 86400000),
revealUntil: null,
backupDigest: null,
backupExpiresAt: null,
},
});
}
if (!s || s.expiresAt <= new Date()) throw new ForbiddenException('连接会话失效,请重新授权');
return {
userId: grant.userId,
sessionId: grant.sessionId,
revealed: !!s.revealUntil && +s.revealUntil > Date.now(),
revealed: (grant.scopes as string[]).includes(writing ? 'hidden_write' : 'hidden_read'),
agent: true,
agentGrantId: grant.id,
cookies: {},
@@ -134,35 +133,26 @@ export class AgentOperations {
]);
return digest(stable(plain(data)));
}
private sensitive(t: ToolDefinition, p: any) {
return (
t.scope === 'sensitive' ||
(t.name === 'settings_update' && p.requireHiddenPassword !== undefined) ||
(t.name === 'icon_publish' && p.shared)
);
}
private async permission(grant: AgentGrant, t: ToolDefinition, p: any) {
const selected = grant.scopes as string[],
mode =
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ||
'draft';
const selected = grant.scopes as string[];
if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限');
if (t.scope === 'read') return { mode, sensitive: false };
const sensitive = this.sensitive(t, p);
if (sensitive && !selected.includes('sensitive'))
throw new ForbiddenException('此操作需要 sensitive 权限');
if (!sensitive && !selected.includes('write') && !selected.includes('draft'))
throw new ForbiddenException('缺少 draft 或 write 权限');
if (mode === 'readonly' && !['backup_export', 'hidden_unlock_request'].includes(t.name))
throw new ForbiddenException('当前用户写入策略为只读');
return { mode, sensitive };
const mode = selected.includes('write')
? 'direct'
: selected.includes('draft')
? 'draft'
: 'readonly';
if (t.scope === 'read') return { mode };
if (mode === 'readonly') throw new ForbiddenException('本连接只有只读权限');
if (!selected.includes('hidden_write') && (p.hidden === true || p.data?.hidden === true))
throw new ForbiddenException('缺少隐藏账户修改权限');
return { mode };
}
async call(grantId: string, name: string, input: any) {
const t = this.get(name);
const parsed = (t.scope === 'read' ? t.schema : t.schema.safeExtend(writing)).parse(input);
const { idempotencyKey, expectedState, ...p } = parsed as any;
const grant = await this.oauth.grant(grantId),
permission = await this.permission(grant, t, p);
const grant = await this.oauth.grant(grantId);
await this.permission(grant, t, p);
if (t.scope === 'read') return t.run!(await this.context(grant), p);
const hash = digest(stable({ tool: name, parameters: p, expectedState }));
return this.db.atomic(async () => {
@@ -192,11 +182,7 @@ export class AgentOperations {
expiresAt: new Date(Date.now() + 600000),
},
});
if (
access.sensitive ||
access.mode === 'draft' ||
!(fresh.scopes as string[]).includes('write')
)
if (access.mode === 'draft' || !(fresh.scopes as string[]).includes('write'))
return this.view(row);
const result = await this.execute(t, fresh, p);
return this.view(
@@ -208,7 +194,7 @@ export class AgentOperations {
});
}
private async execute(t: ToolDefinition, grant: AgentGrant, p: any) {
return t.run!(await this.context(grant), p);
return t.run!(await this.context(grant, true), p);
}
private view(row: any) {
return {
@@ -234,22 +220,9 @@ export class AgentOperations {
const t = this.get(row.tool),
grant = await this.oauth.grant(row.grantId, userId);
let impact: unknown = { parameters: row.parameters, message: t.description };
if (t.name === 'backup_import')
impact = (
await this.backup.inspectUpload(await this.context(grant), (row.parameters as any).token)
).preview;
if (t.web === 'clear')
impact = {
positions: await this.db.position.count({ where: { userId } }),
history: await this.db.revision.count({ where: { position: { userId } } }),
schedules: await this.db.schedule.count({ where: { userId } }),
message: '清空账户、资产、债务、历史、私有图标和汇率;保留账号和个人设置。先下载当前备份。',
};
return {
...this.view(row),
impact,
web: t.web,
sensitive: this.sensitive(t, row.parameters),
description: t.description,
};
}
@@ -257,10 +230,6 @@ export class AgentOperations {
const input = z
.object({
approve: z.boolean(),
password: z.string().max(72).optional(),
username: z.string().max(64).optional(),
newPassword: z.string().max(72).optional(),
confirmation: z.string().max(20).optional(),
})
.strict()
.parse(raw);
@@ -280,44 +249,10 @@ export class AgentOperations {
const grant = await this.oauth.grant(row.grantId, r.userId),
t = this.get(row.tool),
p = row.parameters as any;
const access = await this.permission(grant, t, p);
if (access.sensitive) {
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (!input.password || !(await compare(input.password, u.passwordHash)))
throw new ForbiddenException('请验证当前密码');
}
await this.permission(grant, t, p);
if ((await this.state(r.userId)) !== row.snapshot)
throw new ConflictException('账目已变化,请取消并重新创建操作');
let result: unknown;
if (t.web === 'credentials') {
result = await this.auth.changeCredentials(
r,
{
currentPassword: input.password,
username: input.username,
newPassword: input.newPassword,
},
res,
);
await this.db.agentGrant.updateMany({
where: { userId: r.userId, id: { not: grant.id } },
data: { revokedAt: new Date() },
});
await this.db.session.create({
data: { id: grant.sessionId, userId: r.userId, expiresAt: new Date(Date.now() + 300000) },
});
await this.db.agentGrant.update({
where: { id: grant.id },
data: { scopes: ['read'], expiresAt: new Date(Date.now() + 300000), refreshDigest: null },
});
} else if (t.web === 'reveal')
result = await this.auth.reveal(
Object.assign(Object.create(r), { sessionId: grant.sessionId }),
{ password: input.password },
);
else if (t.web === 'clear')
result = await this.backup.clear(r, { confirmation: input.confirmation });
else result = await this.execute(t, grant, p);
const result = await this.execute(t, grant, p);
return this.view(
await this.db.agentOperation.update({
where: { id },
@@ -326,16 +261,11 @@ export class AgentOperations {
);
}, 300000);
}
async uploadRequest(grantId: string, kind: 'backup' | 'icon') {
async uploadRequest(grantId: string, kind: 'icon') {
const grant = await this.oauth.grant(grantId);
const selected = grant.scopes as string[];
if (!selected.includes('draft') && !selected.includes('write'))
throw new ForbiddenException('上传需要 draft 或 write 权限');
if (
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ===
'readonly'
)
throw new ForbiddenException('当前策略为只读');
return this.files.issue(await this.context(grant), grantId, kind);
}
async fileStatus(grantId: string, id: string) {
+10 -6
View File
@@ -183,13 +183,13 @@ export class AgentTransport {
register(
'file_upload_request',
'创建受 Bearer 保护的短期文件上传入口;multipart/form-data 的 file 字段。',
z.object({ kind: z.enum(['backup', 'icon']) }).strict(),
z.object({ kind: z.enum(['icon']) }).strict(),
(p) => this.operations.uploadRequest(grantId, p.kind),
false,
);
register(
'file_status',
'查看此连接文件上传状态、预检结果和备份导入 token。',
'查看此连接私有图标上传状态,不返回令牌。',
z.object({ fileId: z.string().uuid() }).strict(),
(p) => this.operations.fileStatus(grantId, p.fileId),
);
@@ -201,7 +201,7 @@ export class AgentTransport {
);
register(
'connection_info',
'查询本连接权限、到期时间、资源和用户写入策略;不返回任何令牌。',
'查询本连接权限、到期时间、资源及隐藏账户权限;不返回任何令牌。',
z.object({}).strict(),
async () => {
const g = await this.oauth.grant(grantId);
@@ -210,9 +210,13 @@ export class AgentTransport {
scopes: g.scopes,
expiresAt: g.expiresAt,
resource: g.resource,
writePolicy:
(await this.db.agentPolicy.findUnique({ where: { userId: g.userId } }))?.mode ||
'draft',
permission: (g.scopes as string[]).includes('write')
? 'write'
: (g.scopes as string[]).includes('draft')
? 'draft'
: 'read',
readHidden: (g.scopes as string[]).includes('hidden_read'),
writeHidden: (g.scopes as string[]).includes('hidden_write'),
};
},
);
+10 -44
View File
@@ -30,14 +30,15 @@ import {
} from './validation';
import { businessTime, businessDay } from './calculation';
export const metalType = z.enum(['gold', 'silver']);
// Historical stored ratios remain part of valuation and ZIP data, not user input.
export const storedMetalPurity = z
.string()
.regex(/^(0|1)(\.\d{1,8})?$/)
.refine((v) => new Decimal(v).gt(0) && new Decimal(v).lte(1), '纯度应大于 0 且不超过 1');
export const metalConfig = z
.object({
metalType,
metalGrams: amount.refine((v) => new Decimal(v).gt(0), '重量必须大于零'),
metalPurity: z
.string()
.regex(/^(0|1)(\.\d{1,8})?$/)
.refine((v) => new Decimal(v).gt(0) && new Decimal(v).lte(1), '纯度应大于 0 且不超过 1'),
metalCostPerGram: rateValue
.nullable()
.optional()
@@ -152,8 +153,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
},
});
if (!p) throw new NotFoundException('贵金属资产不存在或已归档');
if (!p.metalType || !p.metalGrams)
throw new BadRequestException('请先设置贵金属品种、重量和纯度');
if (!p.metalType || !p.metalGrams) throw new BadRequestException('请先设置贵金属品种和重量');
const quote = await tx.metalPrice.findFirst({
where: {
userId,
@@ -163,7 +163,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
},
orderBy: { date: 'desc' },
});
if (!quote) throw new BadRequestException('缺少该币种的贵金属价格,请刷新或手动录价');
if (!quote) throw new BadRequestException('缺少该币种的贵金属价格,请刷新报价后重试');
const value = metalValue(
p.metalGrams.toString(),
p.metalPurity.toString(),
@@ -286,17 +286,17 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
if (p.autoValuation) await this.apply(tx, userId, p.id, true);
}
});
const message = '贵金属参考价已更新;同日手动价格已保留,已开启的自动估价已记入历史';
const message = '贵金属参考价已更新,已开启的自动估价已记入历史';
this.attempts.set(userId, today());
this.outcomes.set(userId, { state: 'ok', message, attemptedAt: new Date().toISOString() });
return { message };
} catch {
this.outcomes.set(userId, {
state: 'error',
message: '贵金属价格更新失败,已有价格与估值已保留,可手动录价',
message: '贵金属价格更新失败,已有价格与估值已保留,请稍后重试',
attemptedAt: new Date().toISOString(),
});
throw new BadGatewayException('贵金属价格更新失败,已有价格与估值已保留,可手动录价');
throw new BadGatewayException('贵金属价格更新失败,已有价格与估值已保留,请稍后重试');
} finally {
this.running.delete(userId);
}
@@ -323,37 +323,6 @@ export class MetalsBusinessService {
refresh(r: UserRequest) {
return this.metals.refresh(r.userId);
}
async manual(r: UserRequest, body: unknown) {
const v = metalPriceInput.parse(body);
const key = {
userId: r.userId,
metalType: v.metalType,
currency: v.currency,
date: new Date(v.date),
};
return this.db.serial(async (tx) => {
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
await tx.metalPrice.upsert({
where: { userId_metalType_currency_date: key },
create: { ...key, price: v.price, source: 'manual', quotedAt: new Date() },
update: { price: v.price, source: 'manual', quotedAt: new Date() },
});
const ps = await tx.position.findMany({
where: {
userId: r.userId,
kind: 'asset',
category: 'gold',
metalType: v.metalType,
currency: v.currency,
archived: false,
autoValuation: true,
...(r.revealed ? {} : { hidden: false }),
},
});
for (const p of ps) await this.metals.apply(tx, r.userId, p.id, r.revealed);
return { message: '贵金属价格已保存' };
});
}
async create(r: UserRequest, body: unknown) {
const v = metalHoldingInput.parse(body);
const when = toBusinessDate(v.date);
@@ -436,9 +405,6 @@ export class MetalsController {
@Post('refresh') refresh(@Req() r: UserRequest) {
return this.service.refresh(r);
}
@Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) {
return this.service.manual(r, body);
}
@Put(':id') async configure(
@Req() r: UserRequest,
@Param('id') id: string,
+2 -10
View File
@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { metalConfig, metalPriceInput } from './metals';
import { metalConfig, metalHoldingInput } from './metals';
import { scheduleInput } from './schedules';
import { z } from 'zod';
import {
@@ -47,7 +47,7 @@ export function setupOpenApi(app: INestApplication) {
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
'PATCH /api/settings': settingsInput,
'PUT /api/metals/{id}': metalConfig,
'POST /api/metals/prices': metalPriceInput,
'POST /api/metals/holdings': metalHoldingInput,
'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }),
'POST /api/backup/import-file': z.object({
token: z.string().uuid(),
@@ -155,14 +155,6 @@ export function setupOpenApi(app: INestApplication) {
},
};
}
if (['/api/backup/preview', '/api/backup/import'].includes(path))
operation.requestBody = {
required: true,
description: '旧版 JSON 兼容入口;新版请使用 upload + import-file(支持完整 ZIP 备份)',
content: {
'application/json': { schema: { type: 'object', additionalProperties: true } },
},
};
}
}
SwaggerModule.setup('api/docs', app, document, {
+15 -41
View File
@@ -3,8 +3,8 @@ import * as yauzl from 'yauzl';
import { createHash } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { z } from 'zod';
import { BACKUP_ZIP_VERSION } from './backup-format';
import type { Backup } from './backup';
import { accountGroupOrder, sessionHours, overviewCards } from './validation';
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
const files = [
@@ -38,9 +38,9 @@ export function packBackup(b: Backup) {
),
'links.json': b.links,
'rates.json': b.rates,
'icons.json': b.icons || [],
'transfers.json': b.transfers || [],
'schedules.json': b.schedules || [],
'icons.json': b.icons,
'transfers.json': b.transfers,
'schedules.json': b.schedules,
};
const contents = Object.fromEntries(
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
@@ -48,7 +48,7 @@ export function packBackup(b: Backup) {
contents['manifest.json'] = JSON.stringify(
{
format: 'worthpath',
version: 8,
version: BACKUP_ZIP_VERSION,
exportedAt: b.exportedAt,
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
},
@@ -123,30 +123,17 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
const manifest = z
.object({
format: z.literal('worthpath'),
version: z.union([
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
z.literal(8),
]),
version: z.literal(BACKUP_ZIP_VERSION),
exportedAt: z.iso.datetime(),
files: z
.array(
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
)
.min(files.length - 3)
.max(files.length),
.length(files.length),
})
.strict()
.parse(parse('manifest.json'));
const expected = files.filter(
(f) =>
(manifest.version >= 4 || f !== 'icons.json') &&
(manifest.version >= 5 || f !== 'transfers.json') &&
(manifest.version >= 7 || f !== 'schedules.json'),
);
const expected = files;
if (
contents.size !== expected.length + 1 ||
new Set(manifest.files.map((f) => f.name)).size !== expected.length ||
@@ -157,21 +144,8 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
const settings = z
.object({
baseCurrency: z.string(),
metalPrices: z.array(z.record(z.string(), z.unknown())).optional(),
preferences: z
.object({
showSidebar: z.boolean().optional(),
hiddenMenus: z.array(z.string()).optional(),
accountGroupOrder: accountGroupOrder.optional(),
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
includeIndependentAssets: z.boolean().optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440),
})
.strict()
.optional(),
metalPrices: z.array(z.record(z.string(), z.unknown())),
preferences: z.record(z.string(), z.unknown()),
})
.strict()
.parse(parse('settings.json'));
@@ -194,20 +168,20 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
}
return {
format: 'worthpath',
version: 2,
version: BACKUP_ZIP_VERSION,
exportedAt: manifest.exportedAt,
...settings,
currencies: parse('currencies.json'),
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
links: parse('links.json'),
rates: parse('rates.json'),
...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}),
...(manifest.version >= 7 ? { schedules: parse('schedules.json') } : {}),
...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}),
transfers: parse('transfers.json'),
schedules: parse('schedules.json'),
icons: parse('icons.json'),
};
} catch {
throw new BadRequestException(
'备份 ZIP 无效:请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)',
'备份 ZIP 无效:仅接受当前 v9 格式,不支持旧备份。请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)',
);
} finally {
zip.close();
+34
View File
@@ -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 }),
});
}
+15 -1
View File
@@ -144,7 +144,21 @@ test('reject invalid dates, negative values and credit card assets', () => {
test('backup rejects auth data and broken relations', () => {
const b = {
format: 'worthpath',
version: 1,
version: 9,
preferences: {
hiddenMenus: [],
showNotes: true,
idleMinutes: 0,
accountGroupOrder: [],
sessionHours: 168,
requireHiddenPassword: true,
overviewCards: ['net'],
includeIndependentAssets: true,
},
icons: [],
transfers: [],
schedules: [],
metalPrices: [],
exportedAt: new Date().toISOString(),
baseCurrency: 'CNY',
currencies: ['CNY'],
+3 -2
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -25,7 +26,7 @@ test('liability account credit balances survive revisions, replay, calendar, pri
return { id: u.id, cookie: 'wp_session=' + token };
}
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -163,7 +164,7 @@ test('liability account credit balances survive revisions, replay, calendar, pri
assert.equal(zip.status, 200);
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/overview', b.cookie)).data;
+4 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -25,7 +26,7 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
return { id: row.id, token };
}
async function call(token: string, path: string, method = 'GET', body?: unknown) {
const response = await fetch(base + path, {
const response = await fixtureFetch(base + path, {
method,
headers: {
Cookie: 'wp_session=' + token,
@@ -151,11 +152,11 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
200,
);
const backup = (await call(a.token, '/backup')).data;
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 8);
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 9);
assert.equal(backup.transfers.length, 5);
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
assert.equal(
(await call(b.token, '/backup/import', 'POST', { confirmed: true, backup })).status,
(await call(b.token, '/backup/restore-fixture', 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal(
+7 -6
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -5,7 +6,7 @@ import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('group order persists per user, validates input and survives ZIP and legacy imports', async () => {
test('group order persists per user, validates input and survives ZIP and reject incomplete imports', async () => {
const db = new PrismaClient(),
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
@@ -25,7 +26,7 @@ test('group order persists per user, validates input and survives ZIP and legacy
return { id: u.id, cookie: 'wp_session=' + token };
}
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -80,20 +81,20 @@ test('group order persists per user, validates input and survives ZIP and legacy
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
assert.deepEqual(backup.preferences.accountGroupOrder, order);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order);
await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] });
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
409,
);
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']);
delete backup.preferences.accountGroupOrder;
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
(await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status,
400,
);
assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []);
const comments: any[] = await db.$queryRawUnsafe(
+11 -5
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
names: string[] = [],
publicIds: string[] = [];
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -153,16 +154,21 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
assert.doesNotMatch(JSON.stringify(backup.data), /passwordHash|sessionId|ownerId|token/i);
const broken = structuredClone(backup.data);
broken.icons[0].image = 'invalid';
assert.equal((await call('/backup/preview', b.cookie, 'POST', broken)).status, 400);
assert.equal((await call('/backup/preview-fixture', b.cookie, 'POST', broken)).status, 400);
const before = await db.icon.count();
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup: broken }))
.status,
400,
);
assert.equal(await db.icon.count(), before);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: backup.data }))
.status,
(
await call('/backup/restore-fixture', b.cookie, 'POST', {
confirmed: true,
backup: backup.data,
})
).status,
201,
);
const imported = await db.position.findMany({
+59 -38
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import { request } from 'node:http';
@@ -28,6 +29,14 @@ async function fixture() {
// Give this fixture suite its own loopback source address so independent auth
// scenarios do not consume the existing suite's per-IP production rate limit.
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
if (path.endsWith('-fixture')) {
const response = await fixtureFetch(base + path, {
method,
headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: response.status, data: await response.json(), cookie: null };
}
const data = body === undefined ? undefined : JSON.stringify(body);
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
const req = request(
@@ -200,7 +209,6 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await call('/metals/' + cash, a.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '10',
metalPurity: '0.999',
autoValuation: true,
})
).status,
@@ -211,7 +219,6 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await call('/metals/' + metal, b.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '10',
metalPurity: '0.999',
autoValuation: true,
})
).status,
@@ -222,24 +229,26 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await call('/metals/' + metal, a.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '10.86420978',
metalPurity: '0.999',
autoValuation: true,
})
).status,
200,
);
await db.position.update({ where: { id: metal }, data: { metalPurity: '0.999' } }); // Existing stored valuations are preserved, not an editable setting.
const d = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
assert.equal(
(
await call('/metals/prices', a.cookie, 'POST', {
metalType: 'gold',
currency: 'CNY',
price: '700.864209789012',
date: d,
})
).status,
201,
);
assert.equal((await call('/metals/prices', a.cookie, 'POST', {})).status, 404);
await db.metalPrice.create({
data: {
userId: a.id,
metalType: 'gold',
currency: 'CNY',
price: '700.864209789012',
date: new Date(d),
source: 'manual',
quotedAt: new Date(),
},
});
await call('/metals/' + metal + '/value', a.cookie, 'POST', {});
const expected = (await import('../src/metals')).metalValue(
'10.86420978',
'0.999',
@@ -250,12 +259,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
expected.replace(/0+$/, '').replace(/\.$/, ''),
);
const count = await db.revision.count({ where: { positionId: metal } });
await call('/metals/prices', a.cookie, 'POST', {
metalType: 'gold',
currency: 'CNY',
price: '700.864209789012',
date: d,
});
await call('/metals/' + metal + '/value', a.cookie, 'POST', {});
assert.equal(await db.revision.count({ where: { positionId: metal } }), count);
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
const backup = await (controller as any).data(a.id);
@@ -267,8 +271,12 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await done;
const restoredBackup = await readBackupZip(Buffer.concat(chunks));
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: restoredBackup }))
.status,
(
await call('/backup/restore-fixture', b.cookie, 'POST', {
confirmed: true,
backup: restoredBackup,
})
).status,
201,
);
assert.equal((await call('/auth/me', b.cookie)).data.includeIndependentAssets, false);
@@ -294,7 +302,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await db.$disconnect();
}
});
test('gram-only creation, optional exact purchase cost, pending totals and backward-compatible backups', async () => {
test('gram-only creation, optional exact purchase cost, pending totals and strict current backups', async () => {
const a = await fixture(),
b = await fixture();
try {
@@ -303,13 +311,22 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
currency: 'CNY',
metalType: 'gold',
metalGrams: '2.5',
metalPurity: '0.8',
autoValuation: true,
date: '2026-10-01T12:00',
metalCostPerGram: '12.8',
};
assert.equal(
(await call('/metals/holdings', a.cookie, 'POST', { ...input, metalPurity: '0.8' })).status,
400,
);
const created = await call('/metals/holdings', a.cookie, 'POST', input);
assert.equal(created.status, 201, JSON.stringify(created.data));
assert.equal(
(
await db.position.findUniqueOrThrow({ where: { id: created.data.id } })
).metalPurity.toString(),
'1',
);
assert.equal(created.data.valuationAvailable, false);
const id = created.data.id;
let p = (await call('/positions/' + id, a.cookie)).data;
@@ -326,36 +343,40 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
await call('/metals/' + id, b.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '2.5',
metalPurity: '0.8',
autoValuation: true,
})
).status,
404,
);
const price = await call('/metals/prices', a.cookie, 'POST', {
metalType: 'gold',
currency: 'CNY',
price: '20',
date: '2026-10-01',
await db.metalPrice.create({
data: {
userId: a.id,
metalType: 'gold',
currency: 'CNY',
price: '20',
date: new Date('2026-10-01'),
source: 'goldapi',
quotedAt: new Date(),
},
});
assert.equal(price.status, 201, JSON.stringify(price.data));
assert.equal((await call('/metals/' + id + '/value', a.cookie, 'POST', {})).status, 201);
p = (await call('/positions/' + id, a.cookie)).data;
assert.equal(p.amount, '40');
assert.equal(p.metalProfit, '8.00000000');
assert.equal(p.amount, '50');
assert.equal(p.metalProfit, '18.00000000');
assert.equal(p.valuationAvailable, true);
const cfg = { metalType: 'gold', metalGrams: '2.5', metalPurity: '0.8', autoValuation: true };
const cfg = { metalType: 'gold', metalGrams: '2.5', autoValuation: true };
assert.equal((await call('/metals/' + id, a.cookie, 'PUT', cfg)).status, 200);
assert.equal((await call('/positions/' + id, a.cookie)).data.metalCostPerGram, '12.8');
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
const backup = await (controller as any).data(a.id);
assert.equal(backup.positions[0].metalCostPerGram, '12.8');
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/positions?kind=asset', b.cookie)).data[0];
assert.equal(restored.metalCostPerGram, '12.8');
assert.equal(restored.metalProfit, '8.00000000');
assert.equal(restored.metalProfit, '18.00000000');
const invalidCost = structuredClone(backup);
Object.assign(invalidCost.positions[0], {
metalType: null,
@@ -367,7 +388,7 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
);
const old = structuredClone(backup);
delete old.positions[0].metalCostPerGram;
assert.doesNotThrow(() =>
assert.throws(() =>
(require('../src/backup') as typeof import('../src/backup')).validateBackup(old),
);
assert.equal(
@@ -382,7 +403,7 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
});
assert.equal(next.status, 201);
assert.equal(next.data.valuationAvailable, true);
assert.equal((await call('/positions/' + next.data.id, a.cookie)).data.amount, '40');
assert.equal((await call('/positions/' + next.data.id, a.cookie)).data.amount, '50');
assert.equal(
(await call('/metals/holdings', a.cookie, 'POST', { ...input, metalGrams: '0' })).status,
400,
+14 -12
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
const db = new PrismaClient(),
created: { id: string; username: string }[] = [];
async function call(path: string, method = 'GET', body?: unknown, cookie = '') {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Origin: origin,
@@ -281,16 +282,17 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
JSON.stringify(backup),
/"(?:password|passwordHash|token|userId|session|sessionId|sessions|cookie)"\s*:/i,
);
assert.equal((await call('/backup/preview', 'POST', backup, a.cookie)).status, 409);
assert.equal((await call('/backup/preview', 'POST', backup, b.cookie)).status, 201);
assert.equal((await call('/backup/preview-fixture', 'POST', backup, a.cookie)).status, 409);
assert.equal((await call('/backup/preview-fixture', 'POST', backup, b.cookie)).status, 201);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: false, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: false, backup }, b.cookie))
.status,
400,
);
assert.equal(
(
await call(
'/backup/import',
'/backup/restore-fixture',
'POST',
{
confirmed: true,
@@ -303,7 +305,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
);
assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 0);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status,
201,
);
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, o.net);
@@ -326,7 +328,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
sameDay.map((h: { after: string }) => h.after),
);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status,
409,
);
assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 4);
@@ -346,21 +348,21 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
b.cookie,
);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status,
409,
);
const c = await account();
const racing = await Promise.all([
call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie),
call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie),
call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, c.cookie),
call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, c.cookie),
]);
assert.deepEqual(racing.map((r) => r.status).sort(), [201, 409]);
assert.deepEqual(racing.map((r) => r.status).sort(), [201, 400]);
assert.equal((await call('/positions', 'GET', undefined, c.cookie)).data.length, 4);
assert.equal((await call('/auth/logout', 'POST', undefined, a.cookie)).status, 201);
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401);
assert.equal((await call('/backup', 'GET', undefined, a.cookie)).status, 401);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, a.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, a.cookie)).status,
401,
);
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
+221 -135
View File
@@ -13,7 +13,7 @@ const root = process.env.TEST_API_URL?.replace(/\/api$/, '') || 'http://127.0.0.
const resource = process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp';
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isolation, files and sensitive confirmation', async () => {
test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isolation, hidden permissions and removed capabilities', async () => {
const db = new PrismaClient(),
users: string[] = [],
clients: Client[] = [];
@@ -33,15 +33,22 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
cookie: response.headers.get('set-cookie')?.split(';')[0] || cookie,
};
}
async function fixture(mode = 'direct', selected = ['read', 'draft', 'write', 'sensitive']) {
async function fixture(
mode = 'direct',
selected = mode === 'direct'
? ['read', 'write']
: mode === 'draft'
? ['read', 'draft']
: ['read'],
) {
const username = 'mcp_test_' + randomUUID().slice(0, 12),
password = randomBytes(20).toString('hex');
const registered = await web('', '/auth/register', 'POST', { username, password });
assert.equal(registered.status, 201);
const user = await db.user.findUniqueOrThrow({ where: { username } });
users.push(user.id);
await db.agentPolicy.create({ data: { userId: user.id, mode: 'readonly' } }); // Historical policy must not override connection permissions.
const cookie = registered.cookie;
assert.equal((await web(cookie, '/agent/policy', 'PUT', { mode, password })).status, 200);
const token = await web(cookie, '/agent/tokens', 'POST', {
name: 'Official SDK integration',
days: 1,
@@ -82,7 +89,6 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
async function confirm(a: any, operation: any, extra: any = {}) {
const v = await web(a.cookie, '/agent/operations/' + operation.operationId, 'POST', {
approve: true,
password: a.password,
...extra,
});
assert.equal(v.status, 201, JSON.stringify(v.data));
@@ -113,11 +119,10 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
const a = await fixture(),
b = await fixture(),
d = await fixture('draft', ['read', 'draft']);
await write(a, 'rates_refresh');
await write(a, 'metals_refresh');
await call(a, 'connection_info');
const discovered = await a.client.listTools();
assert.equal(discovered.tools.length, 49);
assert.equal(discovered.tools.length, 39);
assert.ok(!discovered.tools.some((t) => t.name === 'metal_price_set'));
assert.equal(
discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint,
true,
@@ -310,15 +315,20 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
id: metal,
data: { name: 'gold', category: 'gold', notes: '', archived: false, hidden: false },
});
await write(a, 'metal_price_set', {
metalType: 'gold',
currency: 'CNY',
price: '10.876543210987',
date: day,
await db.metalPrice.create({
data: {
userId: a.id,
metalType: 'gold',
currency: 'CNY',
price: '10.876543210987',
date: new Date(day),
source: 'goldapi',
quotedAt: new Date(),
},
});
await write(a, 'metal_configure', {
id: metal,
data: { metalType: 'gold', metalGrams: '2', metalPurity: '1', autoValuation: true },
data: { metalType: 'gold', metalGrams: '2', autoValuation: true },
});
assert.equal((await call(a, 'position_get', { id: metal })).amount, '21.75308642');
await write(a, 'metal_value', { id: metal });
@@ -328,7 +338,6 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
currency: 'CNY',
metalType: 'gold',
metalGrams: '2',
metalPurity: '1',
autoValuation: true,
metalCostPerGram: '9',
date: day,
@@ -362,9 +371,8 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
assert.notEqual((await call(a, 'position_get', { id: cash })).amount, before);
assert.equal((await write(a, 'schedules_run')).result.executed, 0);
await write(a, 'schedule_delete', { id: plan });
const hidden = (
await write(a, 'position_create', { ...position, name: 'hidden', hidden: true })
).result.id;
const hidden = (await write(a, 'position_create', { ...position, name: 'hidden' })).result.id;
await db.position.update({ where: { id: hidden }, data: { hidden: true } });
await fail(a, 'position_get', { id: hidden });
await fail(a, 'debt_links_set', {
id: debt,
@@ -377,80 +385,45 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
expectedState: (await call(d, 'state_get')).state,
idempotencyKey: randomUUID(),
});
const unlock = await write(a, 'hidden_unlock_request');
await confirm(a, unlock);
for (const removed of [
'rates_refresh',
'metals_refresh',
'metal_price_set',
'backup_export',
'backup_import',
'credentials_change_request',
'hidden_unlock_request',
'hidden_lock',
'data_clear_request',
'import_preview',
]) {
assert.ok(!discovered.tools.some((t) => t.name === removed));
await fail(a, removed);
}
await db.agentGrant.update({
where: { id: a.grantId },
data: { scopes: ['read', 'write', 'hidden_read'] },
});
assert.equal((await call(a, 'position_get', { id: hidden })).id, hidden);
await write(a, 'hidden_lock');
await fail(a, 'position_get', { id: hidden });
const exported = await write(a, 'backup_export');
assert.equal(exported.status, 'pending');
const out = (await confirm(a, exported)).result;
assert.equal((await fetch(out.url)).status, 401);
assert.equal(
(await fetch(out.url, { headers: { Authorization: 'Bearer ' + b.token } })).status,
403,
);
const download = await fetch(out.url, { headers: { Authorization: 'Bearer ' + a.token } });
assert.equal(download.status, 200);
const zipped = Buffer.from(await download.arrayBuffer());
const backup: any = await readBackupZip(zipped);
assert.ok(backup.positions.find((p: any) => p.id === hidden));
assert.equal(JSON.stringify(backup).includes(a.token), false);
const target = await fixture('draft'),
upload = await call(target, 'file_upload_request', { kind: 'backup' }),
form = new FormData();
form.append('file', new Blob([zipped]), 'backup.zip');
const uploaded = await fetch(upload.url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + target.token },
body: form,
await fail(a, 'position_update', {
id: hidden,
data: { name: 'forbidden', category: 'cash', hidden: true },
expectedState: (await call(a, 'state_get')).state,
idempotencyKey: randomUUID(),
});
await db.agentGrant.update({
where: { id: a.grantId },
data: { scopes: ['read', 'write', 'hidden_read', 'hidden_write'] },
});
await write(a, 'position_update', {
id: hidden,
data: { name: 'authorized hidden', category: 'cash', hidden: true },
});
assert.equal(uploaded.status, 200);
const info = await uploaded.json();
assert.ok(info.token);
await call(target, 'file_status', { fileId: upload.fileId });
await call(target, 'import_preview', { token: info.token });
const imported = await write(target, 'backup_import', { token: info.token });
await confirm(target, imported);
assert.equal(
await db.position.count({ where: { userId: target.id } }),
backup.positions.length,
);
const retry = await write(target, 'backup_import', { token: info.token });
assert.equal(
(
await web(target.cookie, '/agent/operations/' + retry.operationId, 'POST', {
approve: true,
password: target.password,
})
).status,
409,
);
assert.equal(
await db.position.count({ where: { userId: target.id } }),
backup.positions.length,
);
const clear = await write(target, 'data_clear_request');
assert.equal(
(
await web(target.cookie, '/agent/operations/' + clear.operationId, 'POST', {
approve: true,
password: target.password,
confirmation: '确定清空',
})
).status,
400,
);
const save = await fetch(root + '/api/backup', { headers: { Cookie: target.cookie } });
assert.equal(save.status, 200);
await save.arrayBuffer();
await confirm(target, clear, { confirmation: '确定清空' });
assert.equal(await db.position.count({ where: { userId: target.id } }), 0);
assert.equal(
(await web(a.cookie, '/agent/policy', 'PUT', { mode: 'readonly', password: a.password }))
.status,
200,
(await db.position.findUniqueOrThrow({ where: { id: hidden } })).name,
'authorized hidden',
);
await db.agentGrant.update({ where: { id: a.grantId }, data: { scopes: ['read'] } });
await fail(a, 'position_create', {
...position,
expectedState: (await call(a, 'state_get')).state,
@@ -495,7 +468,7 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
}
});
test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback and credentials completion', async () => {
test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback', async () => {
const db = new PrismaClient();
const username = 'mcp_extra_' + randomUUID().slice(0, 10),
password = randomBytes(20).toString('hex');
@@ -534,12 +507,11 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
try {
assert.equal((await web('/auth/register', 'POST', { username, password })).status, 201);
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
await web('/agent/policy', 'PUT', { mode: 'direct', password });
const grant = (
await web('/agent/tokens', 'POST', {
name: 'extra',
days: 1,
scopes: ['read', 'draft', 'write', 'sensitive'],
scopes: ['read', 'write'],
password,
})
).data;
@@ -602,18 +574,15 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
iconIds.push(published.id);
const image = await call(c, 'icon_image', { id: published.id });
assert.equal((await fetch(image.url, { headers })).status, 200);
const shared = await write(c, 'icon_publish', {
const forbiddenShared = await tool(c, 'icon_publish', {
fileId: upload.fileId,
name: '测试共享图标',
shared: true,
expectedState: (await call(c, 'state_get')).state,
idempotencyKey: randomUUID(),
});
assert.equal(shared.status, 'pending');
assert.equal(
(await web('/agent/operations/' + shared.operationId, 'POST', { approve: true, password }))
.status,
201,
);
iconIds.push((await call(c, 'operation_get', { operationId: shared.operationId })).result.id);
assert.equal(forbiddenShared.isError, true);
assert.equal(await db.icon.count({ where: { ownerId: userId, shared: true } }), 0);
// A failed paired transfer leaves neither side changed, including inside outer
// idempotency transaction and nested service savepoints.
const account = one.result.id,
@@ -670,36 +639,6 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
const management = (await web('/agent')).data;
assert.ok(management.calls.some((v: any) => v.status === 'error'));
assert.equal(JSON.stringify(management).includes(grant.token), false);
const operation = await write(c, 'credentials_change_request');
const replacement = randomBytes(20).toString('hex');
assert.equal(
(
await web('/agent/operations/' + operation.operationId, 'POST', {
approve: true,
password,
newPassword: replacement,
})
).status,
201,
);
assert.equal(
(await call(c, 'operation_get', { operationId: operation.operationId })).status,
'completed',
);
assert.equal(
(
await tool(c, 'position_create', {
...position,
idempotencyKey: randomUUID(),
expectedState: (await call(c, 'state_get')).state,
})
).isError,
true,
);
assert.equal(
(await web('/auth/login', 'POST', { username, password: replacement })).status,
201,
);
} finally {
for (const c of clients) await c.close().catch(() => {});
await db.icon.deleteMany({ where: { id: { in: iconIds } } });
@@ -726,7 +665,7 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
scope: 'read draft write sensitive',
scope: 'read write',
},
clientInformation: () => saved,
saveClientInformation: (v) => {
@@ -766,19 +705,22 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
const registered = await post('/api/auth/register', { username, password });
assert.equal(registered.status, 201);
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
assert.equal(
await auth(provider, { serverUrl: resource, scope: 'read draft write sensitive' }),
'REDIRECT',
);
assert.equal(await auth(provider, { serverUrl: resource, scope: 'read write' }), 'REDIRECT');
assert.ok(authorization);
const redirected = await fetch(authorization!, { redirect: 'manual' });
assert.equal(redirected.status, 302);
const location = new URL(redirected.headers.get('location')!);
const id = location.searchParams.get('agent_authorization');
assert.ok(id);
const deniedEscalation = await post(
'/api/agent/authorizations/' + id,
{ approve: true, scopes: ['read', 'write', 'hidden_read'] },
registered.cookie,
);
assert.equal(deniedEscalation.status, 400);
const consent = await post(
'/api/agent/authorizations/' + id,
{ approve: true },
{ approve: true, scopes: ['read', 'write'] },
registered.cookie,
);
assert.equal(consent.status, 201);
@@ -795,7 +737,7 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
await client.connect(
new StreamableHTTPClientTransport(new URL(resource), { authProvider: provider }),
);
assert.ok((await client.listTools()).tools.length >= 40);
assert.ok((await client.listTools()).tools.length === 39);
await client.close();
async function exchange(params: Record<string, string>) {
const r = await fetch(root + '/token', {
@@ -888,3 +830,147 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
await db.$disconnect();
}
});
test('fixed PAT durations, permanent session renewal, expiry, isolation and revocation are real', async () => {
const db = new PrismaClient(),
users: string[] = [],
clients: Client[] = [];
const { createHash } = await import('node:crypto'),
{ hash } = await import('bcryptjs');
const password = 'Temporary-pat-test-Only!';
async function fixture() {
const u = await db.user.create({
data: {
username: 'pat_' + randomUUID(),
passwordHash: await hash(password, 4),
idleMinutes: 0,
},
});
users.push(u.id);
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId: u.id,
expiresAt: new Date(Date.now() + 86400000),
},
});
return { id: u.id, cookie: 'wp_session=' + token };
}
async function web(u: any, path: string, method = 'GET', body?: unknown) {
const r = await fetch(root + '/api' + path, {
method,
headers: {
Origin: origin,
Cookie: u.cookie,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, data: await r.json() };
}
try {
const a = await fixture(),
b = await fixture();
let permanent: any;
const issuedTokens = new Map<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();
}
});
+14 -7
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -9,7 +10,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
const db = new PrismaClient(),
names: string[] = [];
async function call(path: string, method = 'GET', data?: unknown, cookie = '') {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
@@ -118,7 +119,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
);
assert.equal(history[1].delta, '15');
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
assert.equal(backup.version, 2);
assert.equal(backup.version, 9);
assert.equal(backup.positions.find((p: { id: string }) => p.id === hidden).hidden, true);
assert.equal(
backup.positions.find((p: { id: string }) => p.id === hidden).revisions[0].date,
@@ -192,7 +193,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
);
assert.equal((await call('/overview', 'GET', undefined, c.cookie)).data.net, '20.00');
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status,
201,
);
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '27.00');
@@ -267,11 +268,17 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
assert.equal((await call('/auth/me', 'GET', undefined, otherSession.cookie)).status, 200);
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup: legacy }, login.cookie))
.status,
201,
(
await call(
'/backup/restore-fixture',
'POST',
{ confirmed: true, backup: legacy },
login.cookie,
)
).status,
400,
);
assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 2);
assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 0);
} finally {
for (const username of names) await db.user.deleteMany({ where: { username } });
await db.$disconnect();
+6 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -15,7 +16,9 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
sessionId = createHash('sha256').update(token).digest('hex');
let restoredUserId: string | undefined;
async function call(path: string) {
const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } });
const response = await fixtureFetch(base + path, {
headers: { Cookie: 'wp_session=' + token },
});
return { status: response.status, data: await response.json() };
}
try {
@@ -142,7 +145,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
date: '2026-09-03T00:01',
};
const post = async (path: string, body: unknown, cookie = token) => {
const response = await fetch(base + path, {
const response = await fixtureFetch(base + path, {
method: 'POST',
headers: {
Cookie: 'wp_session=' + cookie,
@@ -179,7 +182,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
},
});
assert.equal(
(await post('/backup/import', { confirmed: true, backup }, restoredToken)).status,
(await post('/backup/restore-fixture', { confirmed: true, backup }, restoredToken)).status,
201,
);
const restoredTransfer = await db.transfer.findFirstOrThrow({ where: { userId: restored.id } });
+31 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -141,6 +142,30 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
const backup: any = await readBackupZip(bytes);
assert.equal(backup.positions.find((p: any) => p.id === emptyId).revisions.length, 0);
assert.equal(backup.positions.find((p: any) => p.id === sourceId).revisions[0].amount, '-10');
const beforeRejected = await db.position.count({ where: { userId: b.id } });
for (const version of [1, 2, 3, 9]) {
const unsupported = new FormData();
unsupported.set(
'file',
new Blob([JSON.stringify({ ...backup, version })], { type: 'application/json' }),
'backup.json',
);
const rejected = await fetch(base + '/backup/upload', {
method: 'POST',
headers: { Cookie: b.cookie, Origin: origin },
body: unsupported,
});
assert.equal(rejected.status, 400);
}
for (const path of ['/backup/import', '/backup/preview']) {
const removed = await fetch(base + path, {
method: 'POST',
headers: { Cookie: b.cookie, Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmed: true, backup }),
});
assert.equal(removed.status, 404);
}
assert.equal(await db.position.count({ where: { userId: b.id } }), beforeRejected);
const form = new FormData();
form.append('file', new Blob([bytes]), 'backup.zip');
const upload = await fetch(base + '/backup/upload', {
@@ -234,10 +259,13 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount,
'-5',
);
assert.equal((await call('/backup/preview', b.cookie, 'POST', emptyAccountData)).status, 409);
assert.equal(
(await call('/backup/preview-fixture', b.cookie, 'POST', emptyAccountData)).status,
409,
);
const fresh = await user();
assert.equal(
(await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status,
(await call('/backup/preview-fixture', fresh.cookie, 'POST', emptyAccountData)).status,
201,
);
} finally {
+12 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import { request } from 'node:http';
@@ -28,6 +29,14 @@ async function fixture() {
// Give this fixture suite its own loopback source address so independent auth
// scenarios do not consume the existing suite's per-IP production rate limit.
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
if (path.endsWith('-fixture')) {
const response = await fixtureFetch(base + path, {
method,
headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: response.status, data: await response.json(), cookie: null };
}
const data = body === undefined ? undefined : JSON.stringify(body);
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
const req = request(
@@ -127,7 +136,7 @@ test('session duration boundaries, privacy isolation and card settings survive b
assert.equal(backup.preferences.sessionHours, 720);
assert.equal(backup.preferences.requireHiddenPassword, false);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const imported = (await call('/settings', b.cookie)).data;
@@ -138,8 +147,8 @@ test('session duration boundaries, privacy isolation and card settings survive b
delete backup.preferences.requireHiddenPassword;
delete backup.preferences.overviewCards;
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
(await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status,
400,
);
const legacy = (await call('/settings', c.cookie)).data;
assert.equal(legacy.sessionHours, 168);
+6 -4
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
const db = new PrismaClient(),
names: string[] = [];
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
const r = await fetch(base + path, {
const r = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -227,12 +228,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
const broken = structuredClone(backup);
broken.transfers[0].amount = '999';
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup: broken }))
.status,
400,
);
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/backup', b.cookie)).data;
@@ -241,7 +243,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
const c = await account();
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal((await call('/settings', c.cookie)).data.showNotes, false);
+3 -2
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -12,7 +13,7 @@ test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP
const minute = (delta = 0) =>
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -186,7 +187,7 @@ test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP
const backup = (await call('/backup', a.cookie)).data;
assert.equal(backup.schedules.length, 3);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0);
+50 -99
View File
@@ -7,14 +7,24 @@ import { packBackup, readBackupZip } from '../src/zip';
const empty = () =>
validateBackup({
format: 'worthpath',
version: 2,
version: 9,
exportedAt: new Date().toISOString(),
baseCurrency: 'CNY',
preferences: { hiddenMenus: ['asset'], showNotes: false, idleMinutes: 9 },
preferences: {
hiddenMenus: ['asset'],
showNotes: false,
idleMinutes: 9,
accountGroupOrder: [],
sessionHours: 168,
requireHiddenPassword: true,
overviewCards: ['net'],
includeIndependentAssets: true,
},
currencies: ['CNY'],
icons: [],
transfers: [],
schedules: [],
metalPrices: [],
positions: [],
rates: [],
links: [],
@@ -48,7 +58,10 @@ test('ZIP contains separate JSON files and restores settings without authenticat
'settings.json',
'transfers.json',
]);
assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i);
assert.doesNotMatch(
Object.values(contents).join('\n'),
/"(?:passwordHash|password|token|sessionId|userId|accessDigest|refreshDigest)"/i,
);
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
});
test('ZIP rejects incomplete files, tampering, unknown entries and invalid entry names', async () => {
@@ -69,6 +82,15 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000
const position = (count: number) => ({
id: randomUUID(),
name: 'count acceptance',
groupName: '',
iconId: null,
included: true,
importedFromId: null,
metalType: null,
metalGrams: null,
metalCostPerGram: null,
metalPurity: '1',
autoValuation: false,
kind: 'asset',
side: 'asset',
category: 'other',
@@ -100,102 +122,31 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000
);
});
test('legacy v3 ZIP remains readable without icons', async () => {
const contents = packBackup(empty());
delete contents['icons.json'];
delete contents['transfers.json'];
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 3;
manifest.files = manifest.files.filter(
(f: { name: string }) => !['icons.json', 'transfers.json', 'schedules.json'].includes(f.name),
);
contents['manifest.json'] = JSON.stringify(manifest);
const restored = validateBackup(await readBackupZip(await archive(contents)));
assert.equal(restored.icons, undefined);
assert.deepEqual(restored.positions, []);
test('all historical ZIP versions and JSON formats are rejected', async () => {
for (const version of [3, 4, 5, 6, 7, 8]) {
const contents = packBackup(empty());
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = version;
contents['manifest.json'] = JSON.stringify(manifest);
await assert.rejects(async () => readBackupZip(await archive(contents)));
}
for (const version of [1, 2, 3]) assert.throws(() => validateBackup({ ...empty(), version }));
});
test('legacy v4 ZIP remains readable without transfers', async () => {
const contents = packBackup(empty());
delete contents['transfers.json'];
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 4;
manifest.files = manifest.files.filter(
(f: { name: string }) => !['transfers.json', 'schedules.json'].includes(f.name),
);
contents['manifest.json'] = JSON.stringify(manifest);
const restored = validateBackup(await readBackupZip(await archive(contents)));
assert.deepEqual(restored.icons, []);
assert.equal(restored.transfers, undefined);
assert.equal(restored.preferences?.showNotes, false);
assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']);
});
test('ZIP v7 restores groups and schedules while v6 remains readable', async () => {
const id = randomUUID(),
stamp = new Date().toISOString();
const b = validateBackup({
...empty(),
positions: [
{
id,
name: '账户',
groupName: '日常',
kind: 'account',
side: 'asset',
category: 'bank',
currency: 'CNY',
notes: '',
archived: false,
hidden: false,
createdAt: stamp,
updatedAt: stamp,
revisions: [
{
id: randomUUID(),
amount: '-10',
date: '2026-09-01T10:00',
notes: '',
reason: 'balance',
createdAt: stamp,
updatedAt: stamp,
},
],
},
],
schedules: [
{
id: randomUUID(),
name: '租金',
operation: 'expense',
sourceId: id,
targetId: null,
amount: '100',
received: '0',
nextAt: '2026-11-01T10:00',
intervalDays: 30,
enabled: true,
notes: '',
},
],
});
const contents = packBackup(b);
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 6;
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'schedules.json');
contents['manifest.json'] = JSON.stringify(manifest);
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
});
test('ZIP settings retain group order while missing legacy order remains optional', async () => {
test('current backups require complete settings and metadata, with no legacy defaults', async () => {
const b = empty();
b.preferences!.accountGroupOrder = ['理财', '', '未分组', '日常'];
const restored = validateBackup(await readBackupZip(await archive(packBackup(b))));
assert.deepEqual(restored.preferences!.accountGroupOrder, b.preferences!.accountGroupOrder);
const legacy = validateBackup(await readBackupZip(await archive(packBackup(empty()))));
assert.equal(legacy.preferences!.accountGroupOrder, undefined);
b.preferences.accountGroupOrder = ['日常', ''];
assert.deepEqual(validateBackup(await readBackupZip(await archive(packBackup(b)))), b);
for (const key of Object.keys(b.preferences)) {
const incomplete = structuredClone(b);
delete (incomplete.preferences as any)[key];
assert.throws(() => validateBackup(incomplete));
}
for (const key of ['icons', 'transfers', 'schedules', 'metalPrices']) {
const incomplete = structuredClone(b);
delete (incomplete as any)[key];
assert.throws(() => validateBackup(incomplete));
}
assert.throws(() =>
validateBackup({ ...b, preferences: { ...b.preferences, showSidebar: false } }),
);
});
+527 -242
View File
@@ -5,14 +5,14 @@ type Connection = {
id: string;
name: string;
scopes: string[];
expiresAt: string;
expiresAt: string | null;
revokedAt: string | null;
clientId: string | null;
};
type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string };
type Management = {
capabilities: { name: string; description: string; scope: string; destructive?: boolean }[];
mcpUrl: string;
mode: string;
grants: Connection[];
operations: Operation[];
calls: { id: string; tool: string; status: string; createdAt: string }[];
@@ -23,8 +23,6 @@ type Preview = {
status: string;
description: string;
impact: unknown;
web?: string;
sensitive: boolean;
result?: unknown;
};
type Consent = {
@@ -34,6 +32,72 @@ type Consent = {
redirectUri: string;
resource: string;
};
const scopeDetails = [
{
id: 'read',
title: '只读查询',
summary: '查看账目和状态,不修改金额',
detail:
'查询账户、资产、债务、关联、余额和历史、净资产趋势、日历、计划与执行记录、汇率、贵金属报价、设置和图标;读取本连接操作及文件状态。隐藏账户默认不允许读取,须额外授权。',
},
{
id: 'draft',
title: '创建草稿',
summary: '提出普通修改,等待你在网页确认',
detail:
'可提出创建或编辑持仓、余额/估值更新、转账和借贷还款、历史更正/删除、计划管理、分组排序、设置修改及私有图标上传等普通操作。草稿明确保存参数和影响,10 分钟过期;不会直接入账。',
},
{
id: 'write',
title: '普通写入',
summary: '普通修改直接执行',
detail:
'覆盖普通修改和创建草稿的操作。授予 write 后普通修改直接执行;draft 等待网页确认;read 拒绝修改。归档、历史删除和转账撤销也属于普通写入,请按所需范围授权。',
},
];
const durationOptions = [
['1', '1 天'],
['3', '3 天'],
['7', '7 天'],
['30', '30 天'],
['365', '1 年(365 天)'],
['permanent', '永久(可撤销)'],
];
const capabilityGroups = [
{
title: '账户、资产与债务',
pattern: /^(positions_|position_|balance_|metal_holding|metal_configure|metal_value)/,
summary: '创建和管理持仓、分组、图标、归档与计入开关;贵金属按克数管理。',
},
{
title: '资金变化与历史',
pattern: /^(transfer|loan|debt|history|revision)/,
summary: '转账、借入借出、收款还款、记录更正、删除与撤销,沿用金额重算规则。',
},
{
title: '统计、汇率与计划',
pattern: /^(overview|trend|calendar|schedule|rates|metals_)/,
summary: '净资产与趋势、日历筛选、定时计划及执行记录、汇率和参考报价。',
},
{
title: '设置与私有图标',
pattern: /^(settings|icons|icon_|backup|import|data_|credentials|hidden|file_)/,
summary: '非安全类用户设置、私有图库上传与读取;敏感操作仅在网站执行。',
},
];
function instructions(url: string) {
return [
'请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url,
'接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;支持自定义 Bearer 头的客户端可在安全的凭据设置中填写个人令牌。不要让我把密码或令牌粘贴到对话中。',
'先 tools/list 发现工具,调用 connection_info 核对权限:read 只读,draft 创建待网页确认的草稿,write 直接普通写入。隐藏账户读写以连接附加授权为准。',
'查询:用 positions_list 搜索对象;遇到同名先让我选择稳定 ID;按 limit/offset 或 cursor 翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。',
'写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。',
'若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。',
'MCP 不提供密码修改、清空数据、备份导出恢复、共享图标发布及汇率或报价修改;这些流程由我在网站操作。',
'我接下来会告诉你具体任务;在我提出任务前先不要修改数据。未发现的工具或未授予的权限请如实告知,不要猜测成功。',
].join('\n\n');
}
export function AgentConnections() {
const [data, setData] = useState<Management | null>(null),
[error, setError] = useState(''),
@@ -41,7 +105,14 @@ export function AgentConnections() {
[token, setToken] = useState(''),
[preview, setPreview] = useState<Preview | 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 act = async (work: () => Promise<unknown>) => {
if (busy) return;
@@ -68,216 +139,417 @@ export function AgentConnections() {
if (operation) await show(operation);
});
}, []);
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setMessage(label);
} catch {
setError('复制失败,请从下方可选中文本手动复制');
}
};
return (
<section className="panel agent-panel">
<h2>连接 Agent</h2>
<div className="agent-heading">
<div>
<h2>连接 Agent</h2>
<p className="muted">管理接入、操作权限和需要你确认的修改。</p>
</div>
{data && <span className="badge">{data.capabilities.length} 个工具 · 按连接授权</span>}
</div>
{error && (
<p role="alert" className="danger-text">
{error}
</p>
)}
{message && <p role="status">{message}</p>}
{message && (
<p role="status" className="agent-notice">
{message}
</p>
)}
{data && (
<>
<p>远程 MCP 地址</p>
<code className="agent-address">{data.mcpUrl}</code>
<button
className="secondary"
type="button"
onClick={() => void navigator.clipboard.writeText(data.mcpUrl)}
>
复制地址
</button>
<p className="muted">
支持 Streamable HTTP。OAuth 客户端使用此地址发现授权信息,浏览器登录 WorthPath
后审核连接名称、回调地址和权限。访问令牌每小时过期,刷新令牌最多 30
天并在使用时轮换。个人令牌适用于支持 Bearer 头的客户端。
</p>
<p className="muted">
已验证客户端:官方 TypeScript SDK 1.31.0(OAuth / Bearer)。其他 Agent
尚未验证;不会保证任意客户端兼容。
</p>
<details>
<summary>官方 SDK 的已验证接入配置</summary>
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
<button
className="secondary"
type="button"
onClick={() =>
void navigator.clipboard.writeText(
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
<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>
<p className="muted">
先在终端设置 MCP_ACCESS_TOKEN,再运行复制的命令。完整 OAuth 示例及安全存储说明见
docs/mcp.md;此配置仅针对官方 SDK 1.31.0。
</p>
</details>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget),
form = e.currentTarget;
void act(async () => {
await api('/agent/policy', 'PUT', {
mode: f.get('mode'),
password: f.get('password'),
});
form.reset();
setMessage('写入策略已保存');
});
}}
>
<h3>写入策略</h3>
<select name="mode" defaultValue={data.mode} key={data.mode}>
<option value="readonly">只读</option>
<option value="draft">创建草稿,由网页确认</option>
<option value="direct">已授权 write 的连接可直接普通写入</option>
</select>
<input
name="password"
type="password"
autoComplete="current-password"
required
placeholder="当前密码"
/>
<button className="secondary" disabled={busy}>
保存策略
</button>
</form>
<h3>OAuth 授权与个人令牌</h3>
<p className="muted">
read 查询;draft 创建草稿;write 按写入策略执行普通写入;sensitive
发起敏感操作,仍须网页验证密码。令牌到期可新建并撤销旧令牌。
</p>
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget,
f = new FormData(form);
void act(async () => {
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
name: f.get('name'),
days: Number(f.get('days')),
password: f.get('password'),
scopes: f.getAll('scope'),
});
setToken(v.token);
form.reset();
});
}}
>
<input name="name" required maxLength={100} placeholder="连接名称" />
<input
name="days"
type="number"
min={1}
max={90}
defaultValue={30}
required
aria-label="有效天数"
/>
{['read', 'draft', 'write', 'sensitive'].map((s) => (
<label key={s}>
<input
type="checkbox"
name="scope"
value={s}
defaultChecked={s === 'read'}
required={s === 'read'}
/>
{s}
</label>
? ' · 待确认'
: ''}
</button>
))}
<input
name="password"
type="password"
required
autoComplete="current-password"
placeholder="当前密码"
/>
<button className="secondary" disabled={busy}>
创建个人令牌
</button>
</form>
{token && (
<div role="status">
<p>完整令牌仅显示这一次,请妥善保存。</p>
<code className="agent-address">{token}</code>
<button
className="secondary"
type="button"
onClick={() => void navigator.clipboard.writeText(token)}
>
复制令牌
</button>
<button className="secondary" type="button" onClick={() => setToken('')}>
隐藏令牌
</button>
</div>
</nav>
{agentView === 'connect' && (
<>
<section className="agent-card agent-wide">
<h3>开始连接</h3>
<p>远程 MCP 地址</p>
<div className="agent-copy-row">
<code className="agent-address">{data.mcpUrl}</code>
<button
className="secondary"
type="button"
onClick={() => void copy(data.mcpUrl, 'MCP 地址已复制')}
>
复制地址
</button>
</div>
<p className="muted">在客户端添加地址,完成网页授权,再复制教程并描述任务。</p>
<details className="agent-details">
<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 &lt;令牌&gt;。个人令牌只显示一次,不能作为通用 OAuth 的替代。
</p>
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
<button
className="secondary"
type="button"
onClick={() =>
void copy(
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
'诊断命令已复制',
)
}
>
复制诊断客户端命令
</button>
<p className="muted">
先安全设置 MCP_ACCESS_TOKEN。实际验证客户端:官方 TypeScript SDK 1.31.0(OAuth /
Bearer);其他 Agent 尚未验证,不提供未经验证的产品配置格式。
</p>
</details>
</section>
</>
)}
<h3>已授权连接(最近 100 条)</h3>
{data.grants.map((g) => (
<div key={g.id} className="agent-row">
<span>
{g.name} · {g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '}
{new Date(g.expiresAt).toLocaleString()} · {g.revokedAt ? '已撤销' : ''}
</span>
{!g.revokedAt && (
<button
className="secondary"
disabled={busy}
type="button"
onClick={() =>
void act(async () => {
await api('/agent/connections/' + g.id, 'DELETE');
setMessage('连接已撤销');
})
}
>
撤销
</button>
)}
</div>
))}
<h3>待确认及最近操作(最近 100 条)</h3>
<button
className="secondary"
disabled={busy}
type="button"
onClick={() => void act(load)}
>
刷新状态
</button>
{data.operations.map((o) => (
<div key={o.id} className="agent-row">
<span>
{o.tool} ·{' '}
{o.status === 'pending' && new Date(o.expiresAt) < new Date()
? 'expired'
: o.status}{' '}
· {new Date(o.createdAt).toLocaleString()}
</span>
<button
className="secondary"
type="button"
disabled={busy}
onClick={() => void act(() => show(o.id))}
>
查看影响和结果
</button>
</div>
))}
<h3>最近调用(不保存参数和敏感内容)</h3>
{data.calls.map((c) => (
<div key={c.id} className="agent-row">
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
</div>
))}
</>
{agentView === 'tools' && (
<>
<section className="agent-card agent-wide">
<h3>Agent 可以做什么</h3>
<p className="muted">
这里列出服务实际提供的工具。能否执行还取决于连接权限、隐藏账户授权、项目归属和草稿确认。
</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>
<form
className="agent-form"
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget,
f = new FormData(form);
void act(async () => {
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
name: f.get('name'),
days: f.get('days') === 'permanent' ? null : Number(f.get('days')),
password: f.get('password'),
scopes: [
'read',
...(f.get('permission') !== 'read'
? [String(f.get('permission'))]
: []),
...(f.get('hiddenRead') ? ['hidden_read'] : []),
...(f.get('hiddenWrite') ? ['hidden_write'] : []),
],
});
setToken(v.token);
form.reset();
setTokenPermission('read');
setTokenHiddenRead(false);
setTokenHiddenWrite(false);
});
}}
>
<label>
连接名称
<input name="name" required maxLength={100} placeholder="例如:桌面助手" />
</label>
<label>
令牌有效期
<select name="days" defaultValue="30">
{durationOptions.map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</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
type="checkbox"
name="hiddenRead"
checked={tokenHiddenRead}
onChange={(e) => {
setTokenHiddenRead(e.target.checked);
if (!e.target.checked) setTokenHiddenWrite(false);
}}
/>
允许读取隐藏账户
</label>
<label>
<input
type="checkbox"
name="hiddenWrite"
disabled={!tokenHiddenRead || tokenPermission === 'read'}
checked={tokenHiddenWrite}
onChange={(e) => setTokenHiddenWrite(e.target.checked)}
/>
允许修改隐藏账户(同时勾选读取,且选择草稿或直接写入)
</label>
</fieldset>
<label>
验证当前密码
<input
name="password"
type="password"
required
autoComplete="current-password"
/>
</label>
<button className="secondary" disabled={busy}>
创建个人令牌
</button>
</form>
{token && (
<div role="status" className="agent-secret">
<p>完整令牌仅显示这一次,请妥善保存到客户端凭据设置。</p>
<code className="agent-address">{token}</code>
<div className="actions">
<button
className="secondary"
type="button"
onClick={() => void copy(token, '令牌已复制')}
>
复制令牌
</button>
<button className="secondary" type="button" onClick={() => setToken('')}>
隐藏令牌
</button>
</div>
</div>
)}
</details>
</section>
<section className="agent-card agent-wide">
<div className="agent-copy-row">
<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) => (
<div key={g.id} className="agent-row">
<div>
<strong>{g.name}</strong>
<p>
{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 && (
<button
className="secondary"
disabled={busy}
type="button"
onClick={() =>
void act(async () => {
await api('/agent/connections/' + g.id, 'DELETE');
setMessage('连接已撤销');
})
}
>
撤销
</button>
)}
</div>
))}
</section>
</>
)}
{agentView === 'operations' && (
<>
<section className="agent-card agent-wide">
<h3>待确认及最近操作</h3>
{!data.operations.length && (
<p className="muted">暂无操作。Agent 发起的草稿会出现在这里。</p>
)}
{data.operations.map((o) => (
<div key={o.id} className="agent-row">
<span>
{o.tool} ·{' '}
{o.status === 'pending' && new Date(o.expiresAt) < new Date()
? 'expired'
: o.status}{' '}
· {new Date(o.createdAt).toLocaleString()}
</span>
<button
className="secondary"
type="button"
disabled={busy}
onClick={() => void act(() => show(o.id))}
>
查看影响和结果
</button>
</div>
))}
<details className="agent-details">
<summary>最近调用 · 不保存参数、密码或令牌</summary>
{!data.calls.length && <p className="muted">暂无调用记录。</p>}
{data.calls.map((c) => (
<div key={c.id} className="agent-row">
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
</div>
))}
</details>
</section>
</>
)}
</div>
)}
{consent && (
<div className="agent-confirm">
@@ -287,6 +559,46 @@ export function AgentConnections() {
<p>资源:{consent.resource}</p>
<p>回调地址:{consent.redirectUri}</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) => (
<button
className="secondary"
@@ -298,7 +610,16 @@ export function AgentConnections() {
const v = await api<{ redirect: string }>(
'/agent/authorizations/' + consent.id,
'POST',
{ approve },
{
approve,
scopes: consent.scopes.filter(
(s) =>
s === 'read' ||
s === consentLevel ||
(s === 'hidden_read' && consentHiddenRead) ||
(s === 'hidden_write' && consentHiddenWrite),
),
},
);
location.assign(v.redirect);
})
@@ -321,15 +642,10 @@ export function AgentConnections() {
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget),
form = e.currentTarget;
const form = e.currentTarget;
void act(async () => {
await api('/agent/operations/' + preview.operationId, 'POST', {
approve: true,
...(f.get('password') ? { password: f.get('password') } : {}),
...(f.get('username') ? { username: f.get('username') } : {}),
...(f.get('newPassword') ? { newPassword: f.get('newPassword') } : {}),
...(f.get('confirmation') ? { confirmation: f.get('confirmation') } : {}),
});
form.reset();
await show(preview.operationId);
@@ -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>
+1 -6
View File
@@ -1538,7 +1538,6 @@ export default function App() {
<ArrowUpRight size={18} />
</div>
<h3>{p.name}</h3>
{p.groupName && <span className="badge">{p.groupName}</span>}
{(p.included === false ||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
<span className="badge">{tr('不计入总览')}</span>
@@ -2268,7 +2267,7 @@ export default function App() {
{tr('选择备份并验证')}
<input
type="file"
accept=".zip,application/zip,.json,application/json"
accept=".zip,application/zip"
disabled={busy}
onChange={async (e) => {
setBackup(null);
@@ -2685,7 +2684,6 @@ export default function App() {
date: v.date,
metalType: v.metalType,
metalGrams: v.metalGrams,
metalPurity: v.metalPurity,
metalCostPerGram: v.metalCostPerGram || null,
autoValuation: true,
hidden: f.has('hidden'),
@@ -2866,9 +2864,6 @@ export default function App() {
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</Field>
<Field label={tr('纯度(0–1,例如 0.999)')}>
<input name="metalPurity" required defaultValue="1" inputMode="decimal" />
</Field>
<Field label={tr('买入价(每克,选填)')}>
<input
name="metalCostPerGram"
+3 -68
View File
@@ -1,5 +1,5 @@
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 { useToast } from './Toast';
type Quote = { metalType: string; currency: string; price: string; date: string; source: string };
@@ -87,7 +87,7 @@ export function MetalPanel({
<h2>{tr('贵金属估价')}</h2>
<p className="muted">
{tr(
'黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
'黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
)}
</p>
{position && (
@@ -96,7 +96,6 @@ export function MetalPanel({
position.id +
String(position.metalGrams) +
String(position.autoValuation) +
String(position.metalPurity) +
String(position.metalType) +
String(position.metalCostPerGram)
}
@@ -109,7 +108,6 @@ export function MetalPanel({
metalType: f.get('metalType'),
metalGrams: f.get('metalGrams'),
metalCostPerGram: f.get('metalCostPerGram') || null,
metalPurity: f.get('metalPurity'),
autoValuation: f.has('autoValuation'),
}),
'贵金属估价设置已保存',
@@ -133,18 +131,6 @@ export function MetalPanel({
defaultValue={position.metalGrams || ''}
/>
</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>
{tr('买入价(每克,选填)')}({position.currency})
<input
@@ -155,7 +141,7 @@ export function MetalPanel({
/>
</label>
<p className="muted">
{tr('买入成本按实物总克数计算;参考估值按克数和纯度计算。清空买入价后不显示盈亏。')}
{tr('买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。')}
</p>
{position.metalCost != null && (
<p>
@@ -205,57 +191,6 @@ export function MetalPanel({
<p className={data?.status.state === 'error' ? 'danger-text' : 'muted'}>
{tr(data?.status.message || '尚未尝试更新')}
</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 && (
<div className="table-wrap">
<table>
+3 -1
View File
@@ -558,5 +558,7 @@
"估值盈亏": "Valuation profit/loss",
"待估值": "Awaiting valuation",
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "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."
}
+3 -1
View File
@@ -558,5 +558,7 @@
"估值盈亏": "估值盈虧",
"待估值": "待估值",
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "按克數添加,參考價自動估值;缺少報價時顯示待估值,不計為已知零價值。",
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。"
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。",
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "黃金與白銀按重量及每克參考價估值。每日嘗試更新,失敗保留舊價;參考價不含工費與回收折價。",
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "買入成本按總克數計算;新持倉按克數和參考價估值。清空買入價後不顯示盈虧。"
}
+155
View File
@@ -1880,3 +1880,158 @@ textarea,
.agent-panel form > label input[type='checkbox'] {
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;
}