From 91365ee315bdb83ecb7d2670518031fc321fb018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Sun, 4 Oct 2026 11:06:15 +0800 Subject: [PATCH] feat: improve account deletion and Agent authorization review --- apps/api/package.json | 6 +- .../migration.sql | 1 + apps/api/prisma/schema.prisma | 2 +- apps/api/src/mcp/management.ts | 9 +- apps/api/src/mcp/oauth.ts | 66 +- apps/api/src/mcp/operations.ts | 86 +- apps/api/src/mcp/transport.ts | 1 + apps/api/src/portfolio.ts | 67 + .../test/account-deletion-integration.test.ts | 249 ++++ apps/api/test/account-deletion.test.ts | 97 ++ apps/api/test/oauth-duration.test.ts | 282 +++++ apps/web/src/AccountDeletion.tsx | 88 ++ apps/web/src/AgentConnections.tsx | 1088 +++++++---------- apps/web/src/AgentReview.tsx | 416 +++++++ apps/web/src/App.tsx | 81 +- apps/web/src/agent-display.ts | 166 +++ apps/web/src/style.css | 226 ++++ docs/database-comments.json | 2 +- docs/mcp.md | 8 +- docs/update-agent-accounts-2026-10-04.md | 20 + update.md | 28 +- 21 files changed, 2276 insertions(+), 713 deletions(-) create mode 100644 apps/api/prisma/migrations/20261004110000_oauth_duration_comment/migration.sql create mode 100644 apps/api/test/account-deletion-integration.test.ts create mode 100644 apps/api/test/account-deletion.test.ts create mode 100644 apps/api/test/oauth-duration.test.ts create mode 100644 apps/web/src/AccountDeletion.tsx create mode 100644 apps/web/src/AgentReview.tsx create mode 100644 apps/web/src/agent-display.ts create mode 100644 docs/update-agent-accounts-2026-10-04.md diff --git a/apps/api/package.json b/apps/api/package.json index c956efc..d45517b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,13 +5,13 @@ "dev": "node scripts/dev.cjs", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts", + "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts", "db:generate": "prisma generate", "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", - "test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts", + "test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-integration.test.ts", "test:performance": "tsx scripts/performance.ts after", - "test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts", + "test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts test/oauth-duration.test.ts", "mcp:probe": "tsx scripts/mcp-probe.ts", "icons:seed": "node scripts/seed-icons.cjs", "test:mcp:codex": "tsx --test --test-concurrency=1 test/codex-oauth.test.ts" diff --git a/apps/api/prisma/migrations/20261004110000_oauth_duration_comment/migration.sql b/apps/api/prisma/migrations/20261004110000_oauth_duration_comment/migration.sql new file mode 100644 index 0000000..e105b3d --- /dev/null +++ b/apps/api/prisma/migrations/20261004110000_oauth_duration_comment/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `AgentGrant` MODIFY `refreshExpiresAt` DATETIME(3) NULL COMMENT '刷新授权到期时间,UTC;空表示永久 OAuth 授权或个人令牌不适用'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 16cadbd..061c5d6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -127,7 +127,7 @@ model AgentGrant { refreshDigest String? @unique @db.Char(64) /// 访问令牌到期时间,UTC;空表示可撤销的永久个人令牌 expiresAt DateTime? - /// 刷新授权到期时间,UTC + /// 刷新授权到期时间,UTC;空表示永久 OAuth 授权或个人令牌不适用 refreshExpiresAt DateTime? /// 撤销时间,UTC;空表示未撤销 revokedAt DateTime? diff --git a/apps/api/src/mcp/management.ts b/apps/api/src/mcp/management.ts index 8269395..71458d7 100644 --- a/apps/api/src/mcp/management.ts +++ b/apps/api/src/mcp/management.ts @@ -15,7 +15,7 @@ import { Response } from 'express'; import { z } from 'zod'; import { Database } from '../database'; import { AuthService, UserRequest } from '../auth'; -import { AgentOAuth, urls, scopeInput } from './oauth'; +import { AgentOAuth, urls, scopeInput, oauthDays } from './oauth'; import { AgentOperations } from './operations'; import { protocolTools, tokenDays } from './information'; @Controller('api/agent') @@ -34,6 +34,7 @@ export class AgentManagementController { name: true, scopes: true, expiresAt: true, + refreshExpiresAt: true, createdAt: true, revokedAt: true, clientId: true, @@ -110,11 +111,11 @@ export class AgentManagementController { @Param('id') id: string, @Body() raw: unknown, ) { - const { approve, scopes } = z - .object({ approve: z.boolean(), scopes: scopeInput.optional() }) + const { approve, scopes, days } = z + .object({ approve: z.boolean(), scopes: scopeInput.optional(), days: oauthDays.default(30) }) .strict() .parse(raw); - return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve, scopes); + return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve, scopes, days); } @Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) { return this.operations.preview(r.userId, z.string().uuid().parse(id)); diff --git a/apps/api/src/mcp/oauth.ts b/apps/api/src/mcp/oauth.ts index 1ae9684..3aa5e97 100644 --- a/apps/api/src/mcp/oauth.ts +++ b/apps/api/src/mcp/oauth.ts @@ -33,6 +33,14 @@ export const scopeInput = z (!v.includes('hidden_write') || (v.includes('hidden_read') && (v.includes('draft') || v.includes('write')))), ); +export const oauthDays = z.union([ + z.literal(1), + z.literal(3), + z.literal(7), + z.literal(30), + z.literal(365), + z.literal(null), +]); export const digest = (s: string) => createHash('sha256').update(s).digest('hex'); const secret = () => randomBytes(32).toString('base64url'); export function urls() { @@ -63,6 +71,9 @@ export function urls() { } export function webLink(key: string, id: string) { const u = new URL(urls().web); + u.pathname = + u.pathname.replace(/\/$/, '') + + (key === 'agent_authorization' ? '/agent/authorize' : '/agent/operation'); u.searchParams.set(key, id); return u.toString(); } @@ -156,11 +167,18 @@ export class AgentOAuth implements OAuthServerProvider { resource: p.resource, }; } - async consent(userId: string, id: string, approved: boolean, selected?: string[]) { + async consent( + userId: string, + id: string, + approved: boolean, + selected?: string[], + days: number | null = 30, + ) { 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 authorizationDays = oauthDays.parse(days); const allowed = selected || ['read']; scopeInput.parse(allowed); if (allowed.some((scope: string) => !parameters.scopes.includes(scope))) @@ -172,7 +190,7 @@ export class AgentOAuth implements OAuthServerProvider { userId, status: approved ? 'approved' : 'denied', codeDigest: approved ? digest(code) : null, - parameters: { ...parameters, scopes: allowed }, + parameters: { ...parameters, scopes: allowed, authorizationDays }, }, }); if (!changed.count) throw new BadRequestException('授权请求已处理'); @@ -202,18 +220,23 @@ export class AgentOAuth implements OAuthServerProvider { selected: string[], days: number | null, clientId?: string, + authorizationDays: number | null = 30, ) { if (clientId && days === null) throw new BadRequestException('OAuth 连接必须有期限'); const access = secret(), refresh = clientId ? secret() : undefined, sessionId = digest(secret()); - const expiresAt = days === null ? null : new Date(Date.now() + days * 86400000), - refreshExpiresAt = clientId ? new Date(Date.now() + 30 * 86400000) : null; + const expiresAt = days === null ? null : new Date(Date.now() + days * 86400000); + const lifetime = oauthDays.parse(authorizationDays); + const refreshExpiresAt = + clientId && lifetime !== null ? new Date(Date.now() + lifetime * 86400000) : null; await this.db.session.create({ data: { id: sessionId, userId, - expiresAt: refreshExpiresAt || expiresAt || new Date(Date.now() + 86400000), + expiresAt: clientId + ? refreshExpiresAt || new Date(Date.now() + 30 * 86400000) + : expiresAt || new Date(Date.now() + 86400000), }, }); const grant = await this.db.agentGrant.create({ @@ -274,6 +297,7 @@ export class AgentOAuth implements OAuthServerProvider { p.scopes, 1 / 24, client.client_id, + p.authorizationDays === undefined ? 30 : p.authorizationDays, ) ).tokens; }); @@ -291,8 +315,7 @@ export class AgentOAuth implements OAuthServerProvider { !row || row.clientId !== client.client_id || row.revokedAt || - !row.refreshExpiresAt || - row.refreshExpiresAt <= new Date() + (row.refreshExpiresAt && row.refreshExpiresAt <= new Date()) ) throw new InvalidGrantError('Invalid refresh token'); const current = row.scopes as string[]; @@ -303,21 +326,36 @@ export class AgentOAuth implements OAuthServerProvider { throw new InvalidScopeError('Scope escalation rejected'); const access = secret(), refresh = secret(); + const accessExpiresAt = new Date( + Math.min(Date.now() + 3600000, row.refreshExpiresAt ? +row.refreshExpiresAt : Infinity), + ); const changed = await this.db.agentGrant.updateMany({ where: { id: row.id, refreshDigest: digest(token), revokedAt: null }, data: { accessDigest: digest(access), refreshDigest: digest(refresh), - expiresAt: new Date(Date.now() + 3600000), + expiresAt: accessExpiresAt, scopes: selected || current, }, }); if (!changed.count) throw new InvalidGrantError('Refresh token already used'); + // Permanent OAuth keeps a bounded business session, renewed only after a valid refresh. + const sessionExpiresAt = row.refreshExpiresAt || new Date(Date.now() + 30 * 86400000); + await this.db.session.upsert({ + where: { id: row.sessionId }, + create: { id: row.sessionId, userId: row.userId, expiresAt: sessionExpiresAt }, + update: { + expiresAt: sessionExpiresAt, + revealUntil: null, + backupDigest: null, + backupExpiresAt: null, + }, + }); return { access_token: access, refresh_token: refresh, token_type: 'Bearer', - expires_in: 3600, + expires_in: Math.max(0, Math.floor((+accessExpiresAt - Date.now()) / 1000)), scope: (selected || current).join(' '), }; }); @@ -328,6 +366,7 @@ export class AgentOAuth implements OAuthServerProvider { if ( !row || row.revokedAt || + (row.clientId && row.refreshExpiresAt && row.refreshExpiresAt <= new Date()) || (row.expiresAt ? row.expiresAt <= new Date() : !!row.clientId) || row.resource !== urls().resource.toString() ) @@ -358,6 +397,15 @@ export class AgentOAuth implements OAuthServerProvider { id, ...(userId ? { userId } : {}), revokedAt: null, + AND: [ + { + OR: [ + { clientId: null }, + { refreshExpiresAt: null }, + { refreshExpiresAt: { gt: new Date() } }, + ], + }, + ], OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null, clientId: null }], }, }); diff --git a/apps/api/src/mcp/operations.ts b/apps/api/src/mcp/operations.ts index 6e9137e..5d46e81 100644 --- a/apps/api/src/mcp/operations.ts +++ b/apps/api/src/mcp/operations.ts @@ -219,10 +219,94 @@ export class AgentOperations { if (!row) throw new NotFoundException('操作不存在'); const t = this.get(row.tool), grant = await this.oauth.grant(row.grantId, userId); - let impact: unknown = { parameters: row.parameters, message: t.description }; + const selected = grant.scopes as string[]; + const ids = new Set(); + const collect = (value: unknown) => { + if (typeof value === 'string' && /^[a-f0-9-]{36}$/i.test(value)) ids.add(value); + else if (Array.isArray(value)) value.forEach(collect); + else if (value && typeof value === 'object') Object.values(value).forEach(collect); + }; + collect(row.parameters); + const visible = { userId, ...(selected.includes('hidden_read') ? {} : { hidden: false }) }; + const positions = await this.db.position.findMany({ + where: { ...visible, id: { in: [...ids] } }, + select: { id: true, name: true, currency: true, side: true, kind: true }, + }); + const schedules = await this.db.schedule.findMany({ + where: { userId, id: { in: [...ids] } }, + select: { id: true, name: true, sourceId: true, targetId: true }, + }); + const visibleScheduleIds = new Set( + ( + await this.db.position.findMany({ + where: { + ...visible, + id: { in: schedules.flatMap((s) => [s.sourceId, ...(s.targetId ? [s.targetId] : [])]) }, + }, + select: { id: true }, + }) + ).map((p) => p.id), + ); + const references = Object.fromEntries([ + ...positions.map((p) => [p.id, p]), + ...schedules + .filter( + (s) => + visibleScheduleIds.has(s.sourceId) && + (!s.targetId || visibleScheduleIds.has(s.targetId)), + ) + .map((s) => [s.id, { name: s.name, kind: 'schedule' }]), + ]); + const p = row.parameters as Record; + let current: Record | undefined; + if (['movement_update', 'movement_delete'].includes(row.tool)) { + const movement = await this.db.transfer.findFirst({ + where: { id: p.id, userId, source: visible, target: visible }, + include: { source: true, target: true }, + }); + if (movement) { + references[movement.sourceId] = { + name: movement.source.name, + currency: movement.source.currency, + }; + references[movement.targetId] = { + name: movement.target.name, + currency: movement.target.currency, + }; + current = { + operation: movement.operation, + sourceId: movement.sourceId, + targetId: movement.targetId, + amount: movement.amount.toString(), + received: movement.received.toString(), + fee: movement.fee.toString(), + date: new Date(+movement.effectiveDate + 8 * 3600000).toISOString().slice(0, 16), + notes: movement.notes, + }; + } + } else if (['history_update', 'history_delete', 'balance_record'].includes(row.tool)) { + const revision = await this.db.revision.findFirst({ + where: { + positionId: p.id, + position: visible, + ...(p.revisionId ? { id: p.revisionId } : {}), + }, + orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], + }); + if (revision) + current = { + id: p.id, + amount: revision.amount.toString(), + date: new Date(+revision.effectiveDate + 8 * 3600000).toISOString().slice(0, 16), + notes: revision.notes, + }; + } + const impact = { parameters: row.parameters, references, current }; return { ...this.view(row), impact, + connectionName: grant.name, + destructive: !!t.destructive, description: t.description, }; } diff --git a/apps/api/src/mcp/transport.ts b/apps/api/src/mcp/transport.ts index 8231b09..c8db448 100644 --- a/apps/api/src/mcp/transport.ts +++ b/apps/api/src/mcp/transport.ts @@ -209,6 +209,7 @@ export class AgentTransport { name: g.name, scopes: g.scopes, expiresAt: g.expiresAt, + authorizationExpiresAt: g.clientId ? g.refreshExpiresAt : g.expiresAt, resource: g.resource, permission: (g.scopes as string[]).includes('write') ? 'write' diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 805fabc..7116a05 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -158,6 +158,63 @@ export class PortfolioBusinessService { }); return { ok: true }; } + async deletion(r: UserRequest, id: string) { + const p = await this.own(r.userId, id, r.revealed); + if (p.kind !== 'account') throw new BadRequestException('此功能仅用于删除账户'); + const [historyCount, movementCount, scheduleCount, linkCount] = await Promise.all([ + this.db.revision.count({ where: { positionId: id } }), + this.db.transfer.count({ + where: { userId: r.userId, OR: [{ sourceId: id }, { targetId: id }] }, + }), + this.db.schedule.count({ + where: { userId: r.userId, OR: [{ sourceId: id }, { targetId: id }] }, + }), + this.db.positionLink.count({ where: { OR: [{ sourceId: id }, { targetId: id }] } }), + ]); + return { + name: p.name, + historyCount, + movementCount, + scheduleCount, + linkCount, + canDelete: movementCount === 0 && scheduleCount === 0, + }; + } + async remove(r: UserRequest, id: string, raw: unknown) { + const input = z + .object({ confirmation: z.string().min(1).max(100) }) + .strict() + .parse(raw); + const result = await this.db.serial(async (tx) => { + await tx.$queryRaw( + Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`, + ); + const p = await tx.position.findFirst({ + where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, + }); + if (!p) throw new NotFoundException('账户不存在'); + if (p.kind !== 'account') throw new BadRequestException('此功能仅用于删除账户'); + if (input.confirmation !== p.name) + throw new BadRequestException('请输入完整账户名称确认删除'); + // Paired movements must be removed through replay, never via cascade. + if ( + await tx.transfer.count({ + where: { userId: r.userId, OR: [{ sourceId: id }, { targetId: id }] }, + }) + ) + throw new ConflictException('账户存在资金往来,请先撤销相关记录,或选择归档保留历史'); + if ( + await tx.schedule.count({ + where: { userId: r.userId, OR: [{ sourceId: id }, { targetId: id }] }, + }) + ) + throw new ConflictException('账户被定时计划使用,请先删除相关计划,或选择归档'); + await tx.position.delete({ where: { id } }); + return { ok: true }; + }); + this.fx.invalidate(r.userId); + return result; + } async revise(r: UserRequest, id: string, b: unknown) { const v = revisionInput.parse(b); if (pairedReasons.includes(v.reason)) throw new BadRequestException('请使用账户转账接口'); @@ -351,6 +408,16 @@ export class PortfolioController { ) { return this.service.edit(r, id, b); } + @Get('positions/:id/deletion') async deletion(@Req() r: UserRequest, @Param('id') id: string) { + return this.service.deletion(r, id); + } + @Delete('positions/:id') async remove( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + return this.service.remove(r, id, b); + } @Post('positions/:id/revisions') async revise( @Req() r: UserRequest, @Param('id') id: string, diff --git a/apps/api/test/account-deletion-integration.test.ts b/apps/api/test/account-deletion-integration.test.ts new file mode 100644 index 0000000..3c61e6f --- /dev/null +++ b/apps/api/test/account-deletion-integration.test.ts @@ -0,0 +1,249 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { today } from '../src/validation'; +const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api'; +const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; +test('real REST account deletion preserves paired balances, blocks schedules, cascades own history and isolates users', async () => { + const db = new PrismaClient(), + users: string[] = []; + async function fixture() { + const u = await db.user.create({ + data: { username: 'delete_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 }, + }); + users.push(u.id); + const token = randomBytes(32).toString('hex'); + const sessionId = createHash('sha256').update(token).digest('hex'); + await db.session.create({ + data: { id: sessionId, userId: u.id, expiresAt: new Date(Date.now() + 3600000) }, + }); + return { id: u.id, cookie: 'wp_session=' + token, sessionId }; + } + async function call(u: any, path: string, method = 'GET', data?: unknown) { + const r = await fetch(base + path, { + method, + headers: { + Cookie: u.cookie, + Origin: origin, + ...(data ? { 'Content-Type': 'application/json' } : {}), + }, + body: data ? JSON.stringify(data) : undefined, + }); + return { status: r.status, data: await r.json() }; + } + try { + const a = await fixture(), + b = await fixture(); + const input = { + kind: 'account', + side: 'asset', + category: 'cash', + name: '删除测试账户', + currency: 'CNY', + amount: '100', + date: today(), + }; + const first = await call(a, '/positions', 'POST', input); + assert.equal(first.status, 201); + const id = first.data.id; + assert.equal((await call(b, '/positions/' + id + '/deletion')).status, 404); + assert.equal( + (await call(b, '/positions/' + id, 'DELETE', { confirmation: input.name })).status, + 404, + ); + assert.equal( + (await call(a, '/positions/' + id, 'DELETE', { confirmation: '错误名称' })).status, + 400, + ); + assert.equal( + ( + await call(a, '/positions/' + id + '/revisions', 'POST', { + amount: '125.87654321', + date: today(), + }) + ).status, + 201, + ); + const debt = await call(a, '/positions', 'POST', { + ...input, + kind: 'debt', + side: 'liability', + name: '关联债务', + amount: '10', + }); + assert.equal(debt.status, 201); + assert.equal( + (await call(a, '/positions/' + debt.data.id + '/links', 'PUT', { targetIds: [id] })).status, + 200, + ); + const impact = await call(a, '/positions/' + id + '/deletion'); + assert.equal(impact.data.historyCount, 2); + assert.equal(impact.data.linkCount, 1); + assert.equal(impact.data.canDelete, true); + assert.equal( + (await call(a, '/positions/' + id, 'DELETE', { confirmation: input.name })).status, + 200, + ); + assert.equal((await call(a, '/positions/' + id)).status, 404); + assert.equal(await db.revision.count({ where: { positionId: id } }), 0); + assert.equal(await db.positionLink.count({ where: { targetId: id } }), 0); + const overview = await call(a, '/overview'); + assert.equal(overview.data.assets, '0.00'); + assert.equal(overview.data.liabilities, '10.00'); + assert.ok(!(await call(a, '/history')).data.items.some((h: any) => h.positionId === id)); + assert.ok( + !(await call(a, '/calendar/day?date=' + today())).data.items.some( + (h: any) => h.positionId === id, + ), + ); + + const source = (await call(a, '/positions', 'POST', input)).data.id; + const target = ( + await call(a, '/positions', 'POST', { ...input, name: '收款测试账户', amount: '0' }) + ).data.id; + const movement = await call(a, '/transfers', 'POST', { + sourceId: source, + targetId: target, + amount: '20', + received: '20', + date: today(), + }); + assert.equal(movement.status, 201); + assert.equal((await call(a, '/positions/' + source + '/deletion')).data.canDelete, false); + assert.equal( + (await call(a, '/positions/' + source, 'DELETE', { confirmation: input.name })).status, + 409, + ); + assert.equal((await call(a, '/positions/' + target)).data.amount, '20'); + assert.equal((await call(a, '/transfers/' + movement.data.id, 'DELETE')).status, 200); + assert.equal((await call(a, '/positions/' + target)).data.amount, '0'); + const schedule = await call(a, '/schedules', 'POST', { + name: '未来计划', + operation: 'expense', + sourceId: source, + amount: '5', + nextAt: '2099-01-01T10:00', + intervalDays: 0, + }); + assert.equal(schedule.status, 201); + assert.equal((await call(a, '/positions/' + source + '/deletion')).data.scheduleCount, 1); + assert.equal( + (await call(a, '/positions/' + source, 'DELETE', { confirmation: input.name })).status, + 409, + ); + assert.equal((await call(a, '/schedules/' + schedule.data.id, 'DELETE')).status, 200); + assert.equal( + (await call(a, '/positions/' + source, 'DELETE', { confirmation: input.name })).status, + 200, + ); + + const hidden = (await call(a, '/positions', 'POST', { ...input, hidden: true })).data.id; + assert.equal((await call(a, '/positions/' + hidden + '/deletion')).status, 404); + assert.equal( + (await call(a, '/positions/' + hidden, 'DELETE', { confirmation: input.name })).status, + 404, + ); + await db.session.update({ + where: { id: a.sessionId }, + data: { revealUntil: new Date(Date.now() + 600000) }, + }); + assert.equal( + (await call(a, '/positions/' + hidden, 'DELETE', { confirmation: input.name })).status, + 200, + ); + assert.equal( + (await call(a, '/positions/' + debt.data.id, 'DELETE', { confirmation: '关联债务' })).status, + 400, + ); + } finally { + await db.user.deleteMany({ where: { id: { in: users } } }); + await db.$disconnect(); + } +}); +test('draft review resolves only referenced visible names and canonical standalone confirmation URL', async () => { + const db = new PrismaClient(); + let userId = ''; + try { + const u = await db.user.create({ + data: { username: 'review_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 }, + }); + userId = u.id; + const token = randomBytes(32).toString('hex'), + sid = createHash('sha256').update(token).digest('hex'); + await db.session.create({ + data: { id: sid, userId, expiresAt: new Date(Date.now() + 3600000) }, + }); + const grant = await db.agentGrant.create({ + data: { + userId, + name: '草稿审阅测试', + scopes: ['read', 'draft'], + resource: process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp', + sessionId: sid, + accessDigest: createHash('sha256').update(randomUUID()).digest('hex'), + expiresAt: new Date(Date.now() + 3600000), + }, + }); + const visible = await db.position.create({ + data: { + userId, + name: '可见账户', + kind: 'account', + side: 'asset', + category: 'cash', + currency: 'CNY', + notes: '', + }, + }); + const hidden = await db.position.create({ + data: { + userId, + name: '隐藏名称不能泄露', + hidden: true, + kind: 'account', + side: 'asset', + category: 'cash', + currency: 'CNY', + notes: '', + }, + }); + const unrelated = await db.position.create({ + data: { + userId, + name: '无关账户', + kind: 'account', + side: 'asset', + category: 'cash', + currency: 'CNY', + notes: '', + }, + }); + const op = await db.agentOperation.create({ + data: { + userId, + grantId: grant.id, + key: randomUUID(), + hash: '0'.repeat(64), + snapshot: '0'.repeat(64), + tool: 'debt_links_set', + parameters: { id: visible.id, targetIds: [hidden.id] }, + expiresAt: new Date(Date.now() + 600000), + }, + }); + const res = await fetch(base + '/agent/operations/' + op.id, { + headers: { Cookie: 'wp_session=' + token }, + }); + assert.equal(res.status, 200); + const preview = await res.json(); + assert.equal(preview.connectionName, '草稿审阅测试'); + assert.equal(preview.impact.references[visible.id].name, '可见账户'); + assert.ok(!preview.impact.references[hidden.id]); + assert.ok(!preview.impact.references[unrelated.id]); + assert.equal(new URL(preview.confirmationUrl).pathname, '/agent/operation'); + } finally { + if (userId) await db.user.deleteMany({ where: { id: userId } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/test/account-deletion.test.ts b/apps/api/test/account-deletion.test.ts new file mode 100644 index 0000000..1fd67ee --- /dev/null +++ b/apps/api/test/account-deletion.test.ts @@ -0,0 +1,97 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PortfolioBusinessService } from '../src/portfolio'; +import { NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; +function fixture() { + const row = { id: 'account', userId: 'owner', name: '测试账户', kind: 'account', hidden: false }; + let movements = 0, + schedules = 0, + deleted = false, + invalidated = false; + const db: any = { + serial: async (fn: any) => fn(db), + $queryRaw: async () => [], + position: { + findFirst: async ({ where }: any) => + where.id === row.id && + where.userId === row.userId && + !(where.hidden === false && row.hidden) && + !deleted + ? row + : null, + delete: async () => { + deleted = true; + return row; + }, + }, + transfer: { count: async () => movements }, + schedule: { count: async () => schedules }, + }; + const service = new PortfolioBusinessService( + db, + { + invalidate: () => { + invalidated = true; + }, + } as any, + {} as any, + {} as any, + ); + return { + service, + row, + setMovements: (v: number) => { + movements = v; + }, + setSchedules: (v: number) => { + schedules = v; + }, + state: () => ({ deleted, invalidated }), + }; +} +const owner = { userId: 'owner', revealed: false } as any; +test('account deletion checks ownership and hidden visibility before deleting or invalidating', async () => { + const f = fixture(); + await assert.rejects( + () => f.service.remove({ ...owner, userId: 'other' }, 'account', { confirmation: '测试账户' }), + NotFoundException, + ); + f.row.hidden = true; + await assert.rejects( + () => f.service.remove(owner, 'account', { confirmation: '测试账户' }), + NotFoundException, + ); + assert.deepEqual(f.state(), { deleted: false, invalidated: false }); + await f.service.remove({ ...owner, revealed: true }, 'account', { confirmation: '测试账户' }); + assert.deepEqual(f.state(), { deleted: true, invalidated: true }); +}); +test('wrong confirmation, non-accounts, paired movements and schedules cannot cascade', async () => { + const f = fixture(); + await assert.rejects( + () => f.service.remove(owner, 'account', { confirmation: '错误名称' }), + BadRequestException, + ); + f.row.kind = 'asset'; + await assert.rejects( + () => f.service.remove(owner, 'account', { confirmation: '测试账户' }), + BadRequestException, + ); + f.row.kind = 'account'; + f.setMovements(1); + await assert.rejects( + () => f.service.remove(owner, 'account', { confirmation: '测试账户' }), + ConflictException, + ); + f.setMovements(0); + f.setSchedules(1); + await assert.rejects( + () => f.service.remove(owner, 'account', { confirmation: '测试账户' }), + ConflictException, + ); + assert.deepEqual(f.state(), { deleted: false, invalidated: false }); + f.setSchedules(0); + assert.deepEqual(await f.service.remove(owner, 'account', { confirmation: '测试账户' }), { + ok: true, + }); + assert.deepEqual(f.state(), { deleted: true, invalidated: true }); +}); diff --git a/apps/api/test/oauth-duration.test.ts b/apps/api/test/oauth-duration.test.ts new file mode 100644 index 0000000..3de41e6 --- /dev/null +++ b/apps/api/test/oauth-duration.test.ts @@ -0,0 +1,282 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +const root = process.env.TEST_API_URL?.replace(/\/api$/, '') || 'http://127.0.0.1:3100'; +const resource = process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp'; +const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; +test('OAuth consent persists selected lifetime; refresh rotates and cannot extend authorization or access past its end', async () => { + const db = new PrismaClient(); + let userId = '', + clientId = ''; + try { + const u = await db.user.create({ + data: { username: 'oauth_days_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 }, + }); + userId = u.id; + const session = randomBytes(32).toString('hex'), + sid = createHash('sha256').update(session).digest('hex'); + await db.session.create({ + data: { id: sid, userId, expiresAt: new Date(Date.now() + 3600000) }, + }); + const cookie = 'wp_session=' + session; + clientId = randomUUID(); + const callback = 'http://127.0.0.1:47891/callback'; + await db.agentClient.create({ + data: { + id: clientId, + metadata: { + client_id: clientId, + client_name: '期限测试', + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }, + }, + }); + async function authorization() { + const verifier = randomBytes(32).toString('base64url'), + id = randomUUID(); + await db.agentAuthorization.create({ + data: { + id, + clientId, + expiresAt: new Date(Date.now() + 600000), + parameters: { + redirectUri: callback, + resource, + scopes: ['read', 'draft'], + codeChallenge: createHash('sha256').update(verifier).digest('base64url'), + }, + }, + }); + return { id, verifier }; + } + async function consent(id: string, days?: number) { + const r = await fetch(root + '/api/agent/authorizations/' + id, { + method: 'POST', + headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + approve: true, + scopes: ['read', 'draft'], + ...(days === undefined ? {} : { days }), + }), + }); + return { status: r.status, data: await r.json() }; + } + async function token(data: Record) { + const r = await fetch(root + '/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientId, resource, ...data }), + }); + return { status: r.status, data: await r.json() }; + } + for (const days of [1, 3, 7, 30, 365, undefined]) { + const a = await authorization(), + approved = await consent(a.id, days); + assert.equal(approved.status, 201); + const code = new URL(approved.data.redirect).searchParams.get('code')!; + const issued = await token({ + grant_type: 'authorization_code', + code, + code_verifier: a.verifier, + redirect_uri: callback, + }); + assert.equal(issued.status, 200); + assert.equal(issued.data.expires_in, 3600); + let grant = await db.agentGrant.findUniqueOrThrow({ + where: { + accessDigest: createHash('sha256').update(issued.data.access_token).digest('hex'), + }, + }); + const ending = +grant.refreshExpiresAt!; + assert.ok(Math.abs(ending - Date.now() - (days ?? 30) * 86400000) < 5000); + const sessionRow = await db.session.findUniqueOrThrow({ where: { id: grant.sessionId } }); + assert.equal(+sessionRow.expiresAt, ending); + const refreshed = await token({ + grant_type: 'refresh_token', + refresh_token: issued.data.refresh_token, + }); + assert.equal(refreshed.status, 200); + grant = await db.agentGrant.findUniqueOrThrow({ where: { id: grant.id } }); + assert.equal(+grant.refreshExpiresAt!, ending); + const nearEnd = new Date(Date.now() + 50000); + await db.agentGrant.update({ where: { id: grant.id }, data: { refreshExpiresAt: nearEnd } }); + const finalRefresh = await token({ + grant_type: 'refresh_token', + refresh_token: refreshed.data.refresh_token, + }); + assert.equal(finalRefresh.status, 200); + assert.ok(finalRefresh.data.expires_in <= 50); + grant = await db.agentGrant.findUniqueOrThrow({ where: { id: grant.id } }); + assert.equal(+grant.expiresAt!, +nearEnd); + await db.agentGrant.update({ + where: { id: grant.id }, + data: { refreshExpiresAt: new Date(0) }, + }); + assert.equal( + ( + await token({ + grant_type: 'refresh_token', + refresh_token: finalRefresh.data.refresh_token, + }) + ).status, + 400, + ); + const request = await fetch(resource, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + finalRefresh.data.access_token, + 'Content-Type': 'application/json', + }, + body: '{}', + }); + assert.equal(request.status, 401); + } + for (const days of [0, 2, 366]) { + const a = await authorization(); + assert.equal((await consent(a.id, days)).status, 400); + assert.equal( + (await db.agentAuthorization.findUniqueOrThrow({ where: { id: a.id } })).status, + 'pending', + ); + } + } finally { + if (userId) await db.user.deleteMany({ where: { id: userId } }); + if (clientId) { + await db.agentAuthorization.deleteMany({ where: { clientId } }); + await db.agentClient.deleteMany({ where: { id: clientId } }); + } + await db.$disconnect(); + } +}); +test('permanent OAuth rotates credentials, renews deleted business session and remains revocable', async () => { + const db = new PrismaClient(); + let userId = '', + clientId = ''; + try { + const u = await db.user.create({ + data: { username: 'oauth_permanent_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 }, + }); + userId = u.id; + const cookieToken = randomBytes(32).toString('hex'), + sid = createHash('sha256').update(cookieToken).digest('hex'); + await db.session.create({ + data: { id: sid, userId, expiresAt: new Date(Date.now() + 3600000) }, + }); + clientId = randomUUID(); + const redirectUri = 'http://127.0.0.1:47891/callback'; + await db.agentClient.create({ + data: { + id: clientId, + metadata: { + client_id: clientId, + client_name: '永久授权测试', + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }, + }, + }); + const id = randomUUID(), + verifier = randomBytes(32).toString('base64url'); + await db.agentAuthorization.create({ + data: { + id, + clientId, + parameters: { + redirectUri, + resource, + scopes: ['read'], + codeChallenge: createHash('sha256').update(verifier).digest('base64url'), + }, + expiresAt: new Date(Date.now() + 600000), + }, + }); + const res = await fetch(root + '/api/agent/authorizations/' + id, { + method: 'POST', + headers: { + Cookie: 'wp_session=' + cookieToken, + Origin: origin, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ approve: true, scopes: ['read'], days: null }), + }); + assert.equal(res.status, 201); + const consent = await res.json(); + async function token(data: Record) { + const r = await fetch(root + '/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientId, resource, ...data }), + }); + return { status: r.status, data: await r.json() }; + } + const issued = await token({ + grant_type: 'authorization_code', + code: new URL(consent.redirect).searchParams.get('code')!, + code_verifier: verifier, + redirect_uri: redirectUri, + }); + assert.equal(issued.status, 200); + assert.equal(issued.data.expires_in, 3600); + const grant = await db.agentGrant.findUniqueOrThrow({ + where: { accessDigest: createHash('sha256').update(issued.data.access_token).digest('hex') }, + }); + assert.equal(grant.refreshExpiresAt, null); + assert.ok(grant.expiresAt); + assert.equal( + ((await db.agentAuthorization.findUniqueOrThrow({ where: { id } })).parameters as any) + .authorizationDays, + null, + ); + await db.session.delete({ where: { id: grant.sessionId } }); + const refreshed = await token({ + grant_type: 'refresh_token', + refresh_token: issued.data.refresh_token, + }); + assert.equal(refreshed.status, 200); + const renewed = await db.session.findUniqueOrThrow({ where: { id: grant.sessionId } }); + assert.ok(+renewed.expiresAt > Date.now() + 29 * 86400000); + assert.equal( + (await db.agentGrant.findUniqueOrThrow({ where: { id: grant.id } })).refreshExpiresAt, + null, + ); + assert.equal( + (await token({ grant_type: 'refresh_token', refresh_token: issued.data.refresh_token })) + .status, + 400, + ); + const revoke = await fetch(root + '/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientId, token: refreshed.data.access_token }), + }); + assert.equal(revoke.status, 200); + assert.equal( + (await token({ grant_type: 'refresh_token', refresh_token: refreshed.data.refresh_token })) + .status, + 400, + ); + const denied = await fetch(resource, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + refreshed.data.access_token, + 'Content-Type': 'application/json', + }, + body: '{}', + }); + assert.equal(denied.status, 401); + } finally { + if (userId) await db.user.deleteMany({ where: { id: userId } }); + if (clientId) { + await db.agentAuthorization.deleteMany({ where: { clientId } }); + await db.agentClient.deleteMany({ where: { id: clientId } }); + } + await db.$disconnect(); + } +}); diff --git a/apps/web/src/AccountDeletion.tsx b/apps/web/src/AccountDeletion.tsx new file mode 100644 index 0000000..43a2391 --- /dev/null +++ b/apps/web/src/AccountDeletion.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { api, type Position } from './api'; +import { showToast } from './Toast'; +type Impact = { + name: string; + historyCount: number; + movementCount: number; + scheduleCount: number; + linkCount: number; + canDelete: boolean; +}; +export function AccountDeletion({ + position, + busy, + cancel, + remove, +}: { + position: Position; + busy: boolean; + cancel: () => void; + remove: (name: string) => void; +}) { + const [impact, setImpact] = useState(null), + [name, setName] = useState(''); + useEffect(() => { + let active = true; + void api('/positions/' + position.id + '/deletion') + .then((v) => { + if (active) setImpact(v); + }) + .catch((e) => { + if (active) showToast(e.message, 'error'); + }); + return () => { + active = false; + }; + }, [position.id]); + return ( +
{ + e.preventDefault(); + if (impact?.canDelete && name === impact.name && !busy) remove(name); + }} + > +

删除账户「{position.name}」

+ {!impact ? ( +

正在检查关联记录…

+ ) : ( + <> +

+ 删除后将移除该账户、{impact.historyCount} 条余额历史及 {impact.linkCount}{' '} + 个债务关联。资产总览、趋势和收支日历将同步更新,无法撤销。 +

+ {!impact.canDelete ? ( +
+ 请先处理账户引用 +

+ 还有 {impact.movementCount} 条资金往来、{impact.scheduleCount}{' '} + 个定时计划。请先撤销相关资金往来并删除相关计划,再删除账户。若需要保留历史,可改用归档。 +

+
+ ) : ( + + )} + + )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/AgentConnections.tsx b/apps/web/src/AgentConnections.tsx index 5e466ee..e1f92d5 100644 --- a/apps/web/src/AgentConnections.tsx +++ b/apps/web/src/AgentConnections.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { api } from './api'; +import { toolLabel, statusLabel, permissionLabel } from './agent-display'; import { showToast, useToast } from './Toast'; type Connection = { @@ -7,6 +8,7 @@ type Connection = { name: string; scopes: string[]; expiresAt: string | null; + refreshExpiresAt: string | null; revokedAt: string | null; clientId: string | null; }; @@ -18,21 +20,6 @@ type Management = { operations: Operation[]; calls: { id: string; tool: string; status: string; createdAt: string }[]; }; -type Preview = { - operationId: string; - tool: string; - status: string; - description: string; - impact: unknown; - result?: unknown; -}; -type Consent = { - id: string; - name: string; - scopes: string[]; - redirectUri: string; - resource: string; -}; const scopeDetails = [ { id: 'read', @@ -64,28 +51,6 @@ const durationOptions = [ ['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 [ '以下是配置 MCP 连接后的操作说明,不会自动添加连接或授予工具。若本会话没有 WorthPath 工具,请先告知我,并指导我在客户端配置连接;不要仅凭网址或聊天中的令牌声称已连接。', @@ -100,15 +65,24 @@ function instructions(url: string) { ].join('\n\n'); } -function codexSetupPrompt(url: string, level: string) { - const selected = level === 'read' ? 'read' : 'read,' + level; +function codexSetupPrompt(url: string, level: string, hiddenRead = false, hiddenWrite = false) { + const selected = [ + 'read', + ...(level !== 'read' ? [level] : []), + ...(hiddenRead ? ['hidden_read'] : []), + ...(hiddenRead && hiddenWrite && level !== 'read' ? ['hidden_write'] : []), + ].join(','); return [ '请直接在这台电脑上为我配置 WorthPath MCP,不要只给我命令或方案。我授权你新增或更新名为 worthpath 的 MCP 连接,使用 Streamable HTTP 与 OAuth;仅修改这条连接,保留其他 MCP 和 Codex 配置。', '服务地址:' + url + '。申请权限:' + selected + - '。不申请隐藏账户权限;最终授权由我在 WorthPath 网页审核完成。不要让我把密码或个人令牌发送到聊天里,也不要替我点击授权确认。', + '。隐藏账户读取:' + + (hiddenRead ? '允许' : '不允许') + + ';隐藏账户修改:' + + (hiddenRead && hiddenWrite && level !== 'read' ? '允许' : '不允许') + + '。最终权限和授权期限由我在 WorthPath 网页审核完成。不要让我把密码或个人令牌发送到聊天里,也不要替我点击授权确认。', '先检查本机 codex 命令是否可用,并核对 mcp add/login 的帮助。只检查 WorthPath 相关配置,不输出凭据、Authorization 头或其他秘密。如果你运行在云端或另一台电脑,无法访问此 localhost 地址,请如实说明,不要声称已连接。', 'Windows 命令执行排查也请由你完成,不要一遇到 PowerShell 挂起就让我自行确认 CLI。版本和帮助检查是短命令:执行工具支持时先设置 login:false(或等效 -NoProfile),避免启动配置文件。若短命令超过 15 秒无输出,结束你自己启动的这一条检查,不要停止 WorthPath 服务或其他进程;不要反复原样重试。', '若受限执行环境中的短命令仍挂起,使用执行工具提供的正常权限申请流程,申请 sandbox_permissions=require_escalated 或等效本机执行权限,说明用途是核对 Codex CLI 并配置 WorthPath OAuth。我授权这项必要的本机检查和 WorthPath 连接配置。获得工具批准后继续执行;若被拒绝,报告具体拒绝原因和受影响步骤。不要绕过权限限制,也不要仅因普通沙箱命令挂起就认定 CLI 不可用。', @@ -119,39 +93,39 @@ function codexSetupPrompt(url: string, level: string) { '执行 codex mcp login worthpath --scopes ' + selected + ' --oauth-client-registration dcr 发起网页登录。我会自行登录并确认权限。等待回调时保留登录进程;需要等待我完成网页步骤时,明确告诉我当前状态。', - '实测 Codex CLI 0.160.0 显式执行 --scopes read,draft 时只请求这两项;默认登录会使用 scopes_supported,授权链接列出 read,draft,write,hidden_read,hidden_write。如果实际请求范围较宽,这是客户端请求的候选范围,不是最终授权。WorthPath 网页支持收窄:选择本次指定的权限等级,两个隐藏账户选项保持关闭,页面会明确显示最终授予权限;服务端仅按网页选择发行令牌。不要仅因请求包含全部候选权限就终止登录或让我自行检查页面。你可以打开正确 WorthPath 资源的授权页面供我审阅,最终选择和确认仍由我完成。', + '实测 Codex CLI 0.160.0 显式执行 --scopes read,draft 时只请求这两项;默认登录会使用 scopes_supported,授权链接列出 read,draft,write,hidden_read,hidden_write。如果实际请求范围较宽,这是客户端请求的候选范围,不是最终授权。WorthPath 网页支持收窄:选择本次指定的权限等级,隐藏账户选项严格按本提示词指定的读写范围选择,页面会明确显示最终授予权限;服务端仅按网页选择发行令牌。不要仅因请求包含全部候选权限就终止登录或让我自行检查页面。你可以打开正确 WorthPath 资源的授权页面供我审阅,最终选择和确认仍由我完成。', '授权后检查连接配置与真实工具可用性。能调用时先 tools/list,再 connection_info;最终 scopes 必须仅为本次指定的 ' + selected + - ',且 readHidden/writeHidden 均为 false。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。', + ',且 readHidden=' + + hiddenRead + + '、writeHidden=' + + (hiddenRead && hiddenWrite && level !== 'read') + + '。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。', '配置期间不要创建、修改或删除我的账目。后续资产查询、转账和还款优先使用 WorthPath MCP;工具或权限不可用时如实说明。', ...instructions(url).split('\n\n').slice(3), ].join('\n\n'); } +const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt); export function AgentConnections() { const [data, setData] = useState(null), - [error, setError] = useState(''), - [message, setMessage] = useState(''), - [token, setToken] = useState(''), - [preview, setPreview] = useState(null), - [consent, setConsent] = useState(null), + [view, setView] = useState('connect'), [busy, setBusy] = useState(false), + [error, setError] = useState(''), + [permission, setPermission] = useState('draft'), + [codexHiddenRead, setCodexHiddenRead] = useState(false), + [codexHiddenWrite, setCodexHiddenWrite] = useState(false), + [token, setToken] = useState(''), [tokenPermission, setTokenPermission] = useState('read'), - [tokenHiddenRead, setTokenHiddenRead] = useState(false), - [tokenHiddenWrite, setTokenHiddenWrite] = useState(false), - [agentView, setAgentView] = useState('connect'), - [codexPermission, setCodexPermission] = useState('draft'), - [consentLevel, setConsentLevel] = useState('read'), - [consentHiddenRead, setConsentHiddenRead] = useState(false), - [consentHiddenWrite, setConsentHiddenWrite] = useState(false); + [hiddenRead, setHiddenRead] = useState(false), + [hiddenWrite, setHiddenWrite] = useState(false), + [search, setSearch] = useState(''); useToast(error, 'error'); - useToast(message, 'success'); const load = async () => setData(await api('/agent')); - const act = async (work: () => Promise) => { + async function act(work: () => Promise) { if (busy) return; setBusy(true); setError(''); - setMessage(''); try { await work(); await load(); @@ -160,640 +134,446 @@ export function AgentConnections() { } finally { setBusy(false); } - }; - const show = async (id: string) => setPreview(await api('/agent/operations/' + id)); + } useEffect(() => { - void act(async () => { - await load(); - const params = new URLSearchParams(location.search); - const authorization = params.get('agent_authorization'), - operation = params.get('agent_operation'); - if (authorization) setConsent(await api('/agent/authorizations/' + authorization)); - if (operation) await show(operation); - }); + void act(load); }, []); - const copy = async (text: string, label: string) => { + async function copy(text: string, label: string) { try { await navigator.clipboard.writeText(text); showToast(label, 'success'); } catch { - showToast('复制失败,请从下方可选中文本手动复制', 'error'); + showToast('复制失败,请展开文本后手动复制', 'error'); } - }; + } + const pending = + data?.operations.filter((o) => o.status === 'pending' && new Date(o.expiresAt) > new Date()) || + []; + const active = + data?.grants.filter( + (g) => !g.revokedAt && (!connectionExpiry(g) || new Date(connectionExpiry(g)!) > new Date()), + ) || []; return (
-

连接 Agent

-

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

+

AGENT CONNECTIONS

+

让助手连接你的资产空间

+

选择权限、连接助手,随时查看和管理它提出的修改。

- {data && {data.capabilities.length} 个工具 · 按连接授权} +
- {data && ( -
- - {agentView === 'connect' && ( - <> -
-

开始连接

-

远程 MCP 地址

-
- {data.mcpUrl} - -
-

- 首次接入可使用下方 Codex 配置提示词。仅复制后续操作说明不会建立连接。 -

-
- 查看连接步骤 -

- localhost 只适用于本机执行 MCP 请求的客户端;云端客户端需要可访问的 HTTPS 地址。 -

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

第一步:让本机 Codex 帮你配置连接

-

- 选择权限,复制提示词并发送给本机 Codex。它会执行配置和发起 OAuth 登录;你只需在 - WorthPath 网页完成登录授权,无需手动输入命令。 -

- + + {!data &&

{error ? '连接信息加载失败,请点击刷新重试。' : '正在读取连接信息…'}

} + {data && view === 'connect' && ( +
+
+ 1 +

选择助手能做什么

+
+ {[ + ['read', '只读查询', '只查看账户、余额和统计。'], + ['draft', '草稿修改', '每次修改先由你确认,建议从这里开始。'], + ['write', '直接写入', '普通修改立即执行,适合你信任的助手。'], + ].map(([key, title, detail]) => ( -
- 预览 Codex 配置提示词 -