import { Controller, Get, Post, Patch, NotFoundException, Delete, Req, Param, Query, Body, Res, ForbiddenException, HttpException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { compare } from 'bcryptjs'; import { Response } from 'express'; import { z } from 'zod'; import { loginInput } from '../user-access'; import { Database } from '../database'; import { AuthService, UserRequest } from '../auth'; import { AgentOAuth, urls, scopeInput, oauthDays } from './oauth'; import { AgentOperations } from './operations'; import { protocolTools, tokenDays } from './information'; @Controller('api/agent') export class AgentManagementController { constructor( private db: Database, private oauth: AgentOAuth, private operations: AgentOperations, private auth: AuthService, ) {} @Get() async list(@Req() r: UserRequest) { const grants = await this.db.agentGrant.findMany({ where: { userId: r.userId }, select: { id: true, name: true, scopes: true, expiresAt: true, refreshExpiresAt: true, createdAt: true, revokedAt: true, clientId: true, }, orderBy: { createdAt: 'desc' }, take: 100, }); const operations = await this.db.agentOperation.findMany({ where: { userId: r.userId }, select: { id: true, tool: true, status: true, expiresAt: true, createdAt: true, completedAt: true, }, orderBy: { createdAt: 'desc' }, take: 100, }); const calls = await this.db.agentCall.findMany({ where: { userId: r.userId }, select: { id: true, tool: true, status: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take: 100, }); return { mcpUrl: urls().resource.toString(), capabilities: [ ...this.operations.tools.map((t) => ({ name: t.name, description: t.description, scope: t.scope, destructive: !!t.destructive, })), ...protocolTools, ], grants, operations, calls, }; } @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.union([z.literal(null), ...tokenDays.map((d) => z.literal(d))]), 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(p.password, u.passwordHash))) throw new ForbiddenException('密码错误'); return this.db.atomic(async () => { const v = await this.oauth.issue(r.userId, p.name, p.scopes, p.days); return { id: v.grant.id, token: v.tokens.access_token, expiresAt: v.grant.expiresAt }; }); } @Patch('connections/:id') async permissions( @Req() r: UserRequest, @Param('id') id: string, @Body() raw: unknown, ) { z.string().uuid().parse(id); const p = z .object({ scopes: scopeInput, password: loginInput.shape.password }) .strict() .parse(raw); this.auth.limit(r); const verified = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); if (!(await compare(p.password, verified.passwordHash))) throw new ForbiddenException('密码错误'); return this.db.atomic(async () => { await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`); const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); if (user.banned || user.mustChangePassword || user.passwordHash !== verified.passwordHash) throw new ForbiddenException('账号状态已变化,请重新登录'); if ( user.role === 'readonly' && p.scopes.some((s) => ['draft', 'write', 'hidden_write'].includes(s)) ) throw new ForbiddenException('只读账号只能授予查询权限'); // Refresh and edits lock the same grant before reading its permissions. await this.db.$queryRaw( Prisma.sql`SELECT id FROM AgentGrant WHERE id=${id} AND userId=${r.userId} FOR UPDATE`, ); const grant = await this.db.agentGrant.findFirst({ where: { id, userId: r.userId } }); const expiry = grant?.clientId ? grant.refreshExpiresAt : grant?.expiresAt; if (!grant || grant.revokedAt || (expiry && expiry <= new Date())) throw new NotFoundException('有效连接不存在'); const previous = grant.scopes as string[]; if ( previous.length === p.scopes.length && previous.every((s) => (p.scopes as string[]).includes(s)) ) return { ok: true }; await this.db.agentGrant.update({ where: { id }, data: { scopes: p.scopes } }); // Permission edits never execute old drafts under a newly granted privilege. await this.db.agentOperation.updateMany({ where: { grantId: id, userId: r.userId, status: 'pending' }, data: { status: 'cancelled', completedAt: new Date() }, }); return { ok: true }; }); } @Delete('connections/:id') async revoke(@Req() r: UserRequest, @Param('id') id: string) { z.string().uuid().parse(id); await this.db.agentGrant.updateMany({ where: { id, userId: r.userId }, data: { revokedAt: new Date() }, }); return { ok: true }; } @Get('authorizations/:id') pending(@Param('id') id: string) { return this.oauth.pending(z.string().uuid().parse(id)); } @Post('authorizations/:id') async consent( @Req() r: UserRequest, @Param('id') id: string, @Body() raw: unknown, ) { 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, days); } @Get('drafts') drafts(@Req() r: UserRequest, @Query('cursor') cursor?: string) { return this.operations.drafts(r.userId, cursor ? z.string().uuid().parse(cursor) : undefined); } @Post('operations/confirm-batch') async confirmBatch( @Req() r: UserRequest, @Body() raw: unknown, @Res({ passthrough: true }) res: Response, ) { this.auth.limit(r); return this.operations.confirmBatch(r, raw, res); } @Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) { return this.operations.preview(r.userId, z.string().uuid().parse(id)); } @Post('operations/:id') async confirm( @Req() r: UserRequest, @Param('id') id: string, @Body() raw: unknown, @Res({ passthrough: true }) res: Response, ) { this.auth.limit(r); z.string().uuid().parse(id); try { return await this.operations.confirm(r, id, raw, res); } catch (e) { const row = await this.db.agentOperation.findFirst({ where: { id, userId: r.userId, status: 'pending' }, }); if (row) { await this.db.agentOperation.updateMany({ where: { id, userId: r.userId, status: 'pending' }, data: { result: { status: 'submission_failed', message: e instanceof HttpException ? e.message : '提交失败,账目已回滚;可重试或取消', }, }, }); await this.db.agentCall.create({ data: { userId: r.userId, grantId: row.grantId, tool: row.tool, status: 'error' }, }); } throw e; } } }