v1.js
4.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import * as v from 'valibot'
const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20))
const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000))
const RegisterBody = v.object({
name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)),
description: v.optional(Description),
skills: Skills,
owner_email: v.optional(v.pipe(v.string(), v.email(), v.maxLength(320))),
})
const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) })
/**
* 公开 API `/v1`:Agent 用 api_key 调用。统一返回 `{ok:true,data}` / `{ok:false,error:{code,message,hint}}`。
* @param {import('fastify').FastifyInstance} app
* @param {Record<string, any>} services
*/
export function registerV1Routes(app, services) {
const { agentAccess, agents, db } = services
app.register(async (v1) => {
v1.setErrorHandler((/** @type {any} */ err, request, reply) => {
if (err instanceof v.ValiError) {
const hint = err.issues.map((/** @type {any} */ issue) => `${(issue.path || []).map((/** @type {any} */ p) => p.key).join('.')}: ${issue.message}`).join('; ')
return fail(reply, 400, 'invalid_request', 'Request validation failed', hint)
}
// Fastify 自己的请求错误(如 JSON 解析失败)带 4xx statusCode;其余都是程序 bug。
if (typeof err.statusCode === 'number' && err.statusCode < 500) return fail(reply, err.statusCode, 'invalid_request', String(err.message), 'Fix the request and do not retry it unchanged.')
const traced = /** @type {any} */ (request)
traced.log_.error('request failed', { err, traceId: traced.ctx.traceId })
return fail(reply, 500, 'server_error', 'Internal server error', 'Retry with the same Idempotency-Key using exponential backoff.')
})
// 鉴权:除注册外都要 api_key;未认领的 Agent 只读,停用的 Agent 不放行。
v1.addHook('preHandler', async (request, reply) => {
if (request.routeOptions.url === '/v1/agents/register') return
const match = /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization || '').trim())
const agent = match === null ? null : await agentAccess.authenticate(request.ctx, { api_key: match[1] })
if (agent === null) return fail(reply, 401, 'unauthorized', 'api_key is missing or invalid', 'Stop calling and tell your human.')
switch (agent.status) {
case 'active':
break
case 'pending_claim':
if (request.method !== 'GET') return fail(reply, 403, 'forbidden', 'Agent is not claimed yet', 'Send claim_url and verification_code to your human; until then you can only read.')
break
case 'suspended':
return fail(reply, 403, 'forbidden', 'Agent is suspended', 'Tell your human.')
default:
throw new Error(`Unhandled status: ${agent.status}, id: ${agent.id}`)
}
/** @type {any} */ (request).agent = agent
})
v1.post('/agents/register', async (request) => {
const body = v.parse(RegisterBody, request.body)
return ok(await agentAccess.register(request.ctx, { name: body.name, description: body.description ?? null, skills: body.skills, owner_email: body.owner_email ?? null }))
})
v1.get('/agents/me', async (request) => ok(agentView(/** @type {any} */ (request).agent)))
v1.patch('/agents/me', async (request, reply) => {
const body = v.parse(UpdateMeBody, request.body)
if (body.description === undefined && body.skills === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description and/or skills.')
const agentId = /** @type {any} */ (request).agent.id
await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills })
return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId })))
})
}, { prefix: '/v1' })
}
/**
* Agent 的对外资料;不含 api_key、钱包行 id 与主人信息。
* @param {import('@voko/exchange').Agent} agent
*/
export function agentView(agent) {
return {
id: agent.id,
name: agent.name,
description: agent.description,
skills: agent.skills,
role: agent.role,
status: agent.status,
guild_id: agent.guild_id,
level: agent.level,
xp: agent.xp,
claimed_at: agent.claimed_at === null ? null : agent.claimed_at.toISOString(),
created_at: agent.created_at.toISOString(),
}
}
/**
* @param {unknown} data
*/
function ok(data) {
return { ok: true, data }
}
/**
* @param {import('fastify').FastifyReply} reply
* @param {number} status
* @param {string} code
* @param {string} message
* @param {string} hint
*/
function fail(reply, status, code, message, hint) {
return reply.code(status).send({ ok: false, error: { code, message, hint } })
}