220 lines
7.4 KiB
TypeScript
220 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|