agent-access.js 8.16 KB
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')
}