agent-access.js
8.16 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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto'
import { accessRequestFromRow } from './access-requests.js'
const CLAIM_TTL_MS = 24 * 3600_000
/** 每个主人最多认领的 Agent 数;与 DB CHECK `ck_vk_access_requests_agent_count` 一致。 */
export const MAX_AGENTS_PER_OWNER = 3
export const API_KEY_PREFIX = 'vk_live_'
/**
* Agent 的注册、api_key 鉴权与认领。
*
* @param {{
* db: import('knex').Knex,
* agents: ReturnType<typeof import('@voko/exchange').createAgents>,
* grantStarter(ctx: object, trx: import('knex').Knex, input: { agent_id: string, wallet_user_id: string }): Promise<void>,
* public_url: string,
* }} deps `grantStarter` 在认领事务内给新 Agent 的钱包发启动积分,必须按 agent_id 幂等。
*/
export function createAgentAccess({ db, agents, grantStarter, public_url }) {
if (typeof db !== 'function') throw new Error('createAgentAccess: db is required')
if (typeof grantStarter !== 'function') throw new Error('createAgentAccess: grantStarter is required')
if (typeof public_url !== 'string' || public_url === '') throw new Error('createAgentAccess: public_url is required')
/**
* 注册:钱包行(users,role='agent')、Agent、api_key、认领凭证在同一事务里建立。
* api_key 与认领 token 只在这里返回一次,库里只存 sha256。
* @param {object} ctx
* @param {{ name: string, description: string|null, skills: string[], owner_email: string|null }} input
* @returns {Promise<{ agent_id: string, api_key: string, claim_url: string, verification_code: string }>}
*/
async function register(ctx, { name, description, skills, owner_email }) {
const now = new Date()
const agent_id = `ag_${randomUUID()}`
const wallet_user_id = `uw_${randomUUID()}`
const api_key = `${API_KEY_PREFIX}${randomBytes(32).toString('base64url')}`
const claim_token = randomBytes(24).toString('base64url')
const verification_code = String(randomInt(0, 1_000_000)).padStart(6, '0')
await db.transaction(async (trx) => {
await trx('users').insert({ id: wallet_user_id, email: `${agent_id}@agents.voko.world`, display_name: name, status: 'active', role: 'agent', created_at: now })
await agents.insertPending(ctx, trx, { id: agent_id, wallet_user_id, name, description, skills, owner_email, created_at: now })
await trx('vk_api_keys').insert({ id: `ak_${randomUUID()}`, agent_id, key_hash: sha256(api_key), key_prefix: api_key.slice(0, 12), created_at: now })
await trx('vk_agent_claims').insert({
id: `ac_${randomUUID()}`,
agent_id,
claim_token_hash: sha256(claim_token),
verification_code,
expires_at: new Date(now.getTime() + CLAIM_TTL_MS),
created_at: now,
})
})
return { agent_id, api_key, claim_url: `${public_url}/claim/${claim_token}`, verification_code }
}
/**
* 用 api_key 找到 Agent;key 不存在或已吊销返回 null。
* @param {object} ctx
* @param {{ api_key: string }} input
* @returns {Promise<import('@voko/exchange').Agent|null>}
*/
async function authenticate(ctx, { api_key }) {
const key = await db('vk_api_keys').where({ key_hash: sha256(api_key) }).whereNull('revoked_at').first('agent_id')
return key === undefined ? null : agents.getById(ctx, db, { agent_id: key.agent_id })
}
/**
* 主人认领 Agent:核对验证码 → 计数 +1(≤3)→ Agent 激活并加入公会 → 发启动积分,全部在一个事务里。
* 同一主人重放已成功的认领返回同一结果,不重复计数与发放。
* @param {object} ctx
* @param {{ claim_token: string, user_id: string, verification_code: string, guild_id: string, role: 'company'|'member' }} input 公司 Agent 只能由公会负责人认领,每个公会一个
* @returns {Promise<{ ok: true, agent: import('@voko/exchange').Agent } | { ok: false, code: 'NOT_FOUND'|'FORBIDDEN'|'CONFLICT'|'CODE_INVALID', message: string }>}
*/
async function claim(ctx, { claim_token, user_id, verification_code, guild_id, role }) {
const now = new Date()
return db.transaction(async (trx) => {
const claimRow = await trx('vk_agent_claims').where({ claim_token_hash: sha256(claim_token) }).forUpdate().first()
if (claimRow === undefined) return refuse('NOT_FOUND', 'Claim link not found')
if (claimRow.claimed_by_user_id !== null) {
if (claimRow.claimed_by_user_id !== user_id) return refuse('CONFLICT', 'This agent has already been claimed')
return { ok: true, agent: await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) }
}
if (claimRow.expires_at <= now) return refuse('CONFLICT', 'Claim link has expired; the agent must register again')
const requestRow = await trx('vk_access_requests').where({ user_id }).forUpdate().first()
if (requestRow === undefined) return refuse('FORBIDDEN', 'Your access request must be approved before claiming an agent')
const request = accessRequestFromRow(requestRow)
switch (request.status) {
case 'approved':
break
case 'pending':
case 'rejected':
return refuse('FORBIDDEN', 'Your access request must be approved before claiming an agent')
default:
throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`)
}
if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match')
const guild = await trx('vk_guilds').where({ id: guild_id }).forUpdate().first('lead_user_id', 'company_agent_id')
if (guild === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`)
switch (role) {
case 'company':
if (guild.lead_user_id !== user_id) return refuse('FORBIDDEN', 'Only the guild lead can claim the company agent')
if (guild.company_agent_id !== null) return refuse('CONFLICT', 'This guild already has a company agent')
await trx('vk_guilds').where({ id: guild_id }).update({ company_agent_id: claimRow.agent_id, updated_at: now })
break
case 'member':
break
default:
throw new Error(`Unhandled role: ${role}, id: ${claimRow.agent_id}`)
}
if (request.claimed_agent_count >= MAX_AGENTS_PER_OWNER) return refuse('CONFLICT', `You can claim at most ${MAX_AGENTS_PER_OWNER} agents`)
await trx('vk_access_requests').where({ id: request.id }).increment('claimed_agent_count', 1)
await trx('vk_agent_claims').where({ id: claimRow.id }).update({ claimed_by_user_id: user_id, claimed_at: now })
await agents.activate(ctx, trx, { agent_id: claimRow.agent_id, owner_user_id: user_id, guild_id, role, claimed_at: now })
await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore()
const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id })
await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id })
return { ok: true, agent }
})
}
/**
* 重置 api_key:吊销旧 key 并发一个新的,明文只在这里返回一次。调用方须已确认 Agent 属于该主人。
* @param {object} _ctx
* @param {{ agent_id: string }} input
* @returns {Promise<{ api_key: string }>}
*/
async function resetApiKey(_ctx, { agent_id }) {
const now = new Date()
const api_key = `${API_KEY_PREFIX}${randomBytes(32).toString('base64url')}`
await db.transaction(async (trx) => {
await trx('vk_api_keys').where({ agent_id }).whereNull('revoked_at').update({ revoked_at: now })
await trx('vk_api_keys').insert({ id: `ak_${randomUUID()}`, agent_id, key_hash: sha256(api_key), key_prefix: api_key.slice(0, 12), created_at: now })
})
return { api_key }
}
return { register, authenticate, claim, resetApiKey }
}
/**
* @template {string} C
* @param {C} code
* @param {string} message
* @returns {{ ok: false, code: C, message: string }}
*/
function refuse(code, message) {
return { ok: false, code, message }
}
/**
* @param {string} value
* @returns {string}
*/
function sha256(value) {
return createHash('sha256').update(value).digest('hex')
}