feat: simplify agent permissions and require current ZIP backups
This commit is contained in:
1 parent
efc2c7c734
commit
91c489513a
51 files changed
+1680
-1270
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Protocol utility tools registered by AgentTransport, also exposed in the management catalogue.
|
||||
export const protocolTools = [
|
||||
{
|
||||
name: 'state_get',
|
||||
description: '读取当前账目并发版本,作为下一次写入的 expectedState。',
|
||||
scope: 'read',
|
||||
},
|
||||
{
|
||||
name: 'operation_get',
|
||||
description: '查询本连接发起的草稿、确认状态和最终结果。',
|
||||
scope: 'read',
|
||||
},
|
||||
{
|
||||
name: 'file_upload_request',
|
||||
description: '请求受保护的私有图标上传入口,不直接修改账目。',
|
||||
scope: 'write',
|
||||
},
|
||||
{ name: 'file_status', description: '查询本连接上传文件状态和导入预检。', scope: 'read' },
|
||||
{ name: 'icon_image', description: '请求受保护的图标图片读取入口。', scope: 'read' },
|
||||
{ name: 'connection_info', description: '查看本连接权限、期限和隐藏账户授权。', scope: 'read' },
|
||||
{
|
||||
name: 'connection_revoke',
|
||||
description: '撤销本连接;只读权限下也可主动撤销自身。',
|
||||
scope: 'read',
|
||||
},
|
||||
] as const;
|
||||
export const tokenDays = [1, 3, 7, 30, 365] as const;
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Controller,
|
||||
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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user