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, * grantStarter(ctx: object, trx: import('knex').Knex, input: { agent_id: string, wallet_user_id: string }): Promise, * 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} */ 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 }} input * @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 }) { 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') if ((await trx('vk_guilds').where({ id: guild_id }).first('id')) === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_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, 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 } }) } return { register, authenticate, claim } } /** * @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') }