From daf313a4c5a677c18068f698592a5bc9fc4a4ff4 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 10 Oct 2026 14:09:58 +0800 Subject: [PATCH] feat(access): F0 入驻——Agent 注册、api_key 鉴权、认领与启动积分 --- apps/voko-server/package.json | 3 ++- apps/voko-server/src/app.js | 2 ++ apps/voko-server/src/assembly/runtime.js | 26 ++++++++++++++++++++++++-- apps/voko-server/src/routes/access.js | 29 ++++++++++++++++++++++++++++- apps/voko-server/src/routes/v1.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/runbooks/README.md | 1 + docs/runbooks/verify-f0.md | 26 ++++++++++++++++++++++++++ docs/voko/repo-init-checklist.md | 2 +- packages/voko-access/src/agent-access.js | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/voko-access/src/index.js | 3 ++- packages/voko-exchange/src/agents.js | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/voko-exchange/src/guilds.js | 33 +++++++++++++++++++++++++++++++++ packages/voko-exchange/src/index.js | 4 +++- tools/scripts/verify-local.sh | 4 +++- 15 files changed, 684 insertions(+), 8 deletions(-) create mode 100644 apps/voko-server/src/routes/v1.js create mode 100644 apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs create mode 100644 docs/runbooks/verify-f0.md create mode 100644 packages/voko-access/src/agent-access.js create mode 100644 packages/voko-exchange/src/agents.js create mode 100644 packages/voko-exchange/src/guilds.js diff --git a/apps/voko-server/package.json b/apps/voko-server/package.json index 7f09913..3c1f852 100644 --- a/apps/voko-server/package.json +++ b/apps/voko-server/package.json @@ -12,7 +12,8 @@ "db:migrate": "node src/db/migrate-database.js", "db:status": "node src/db/migration-status.js", "admin:grant": "node tools/grant-admin.mjs", - "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs" + "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs", + "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs" }, "dependencies": { "@drama/auth": "workspace:*", diff --git a/apps/voko-server/src/app.js b/apps/voko-server/src/app.js index 95c09f8..7afb16c 100644 --- a/apps/voko-server/src/app.js +++ b/apps/voko-server/src/app.js @@ -3,6 +3,7 @@ import { registerErrorHandler, registerTrace } from '@drama/server-kit' import { registerAccessRoutes } from './routes/access.js' import { registerAuthRoutes } from './routes/auth.js' +import { registerV1Routes } from './routes/v1.js' /** * 创建并注册一个尚未 listen 的 Fastify app。只消费装配好的 services,不接触连接与 env。 @@ -22,5 +23,6 @@ export function buildApp({ services, logger }) { registerAuthRoutes(app, services) registerAccessRoutes(app, services) + registerV1Routes(app, services) return app } diff --git a/apps/voko-server/src/assembly/runtime.js b/apps/voko-server/src/assembly/runtime.js index e071c18..7640b37 100644 --- a/apps/voko-server/src/assembly/runtime.js +++ b/apps/voko-server/src/assembly/runtime.js @@ -5,9 +5,11 @@ import { fileURLToPath } from 'node:url' import { config as loadDotenv } from 'dotenv' import { createAuth, createUserRepo } from '@drama/auth' +import { createBillingRepo, createLedger } from '@drama/billing' import { createDb } from '@drama/db' import { createLogger } from '@drama/observability' -import { createAccessRequests } from '@voko/access' +import { createAccessRequests, createAgentAccess } from '@voko/access' +import { createAgents, createGuilds } from '@voko/exchange' import { readEnv } from '../env.js' import { createEmailPorts } from './email.js' @@ -40,6 +42,26 @@ export async function createRuntime({ service }) { const userRepo = createUserRepo(db) const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher }) const access = createAccessRequests({ db, notifier: accessNotifier }) + const agents = createAgents() + const guilds = createGuilds({ db }) + const agentAccess = createAgentAccess({ + db, + agents, + public_url: env.publicUrl, + // 启动积分 100:在认领事务里记账,幂等键由 agent_id 唯一决定。 + async grantStarter(_ctx, trx, { agent_id, wallet_user_id }) { + await createLedger(createBillingRepo(db, trx)).grant({ + user_id: wallet_user_id, + amount: 100, + source: 'voko', + credit_kind: 'admin', + reason: 'vk_starter', + ref_type: 'vk_agent', + ref_id: agent_id, + idempotency_key: `vk:starter:${agent_id}`, + }) + }, + }) /** * 注册:验证码校验与建用户在同一事务里完成。 @@ -56,7 +78,7 @@ export async function createRuntime({ service }) { return { env, logger, - services: { db, auth, userRepo, access, register, oauthEnabled: env.oauth.googleClientId !== null }, + services: { db, auth, userRepo, access, agents, guilds, agentAccess, register, oauthEnabled: env.oauth.googleClientId !== null }, async close() { await db.destroy() }, diff --git a/apps/voko-server/src/routes/access.js b/apps/voko-server/src/routes/access.js index 89eda0c..9c60d02 100644 --- a/apps/voko-server/src/routes/access.js +++ b/apps/voko-server/src/routes/access.js @@ -1,6 +1,7 @@ import * as v from 'valibot' import { codedError, requireUser } from './auth.js' +import { agentView } from './v1.js' const SubmitBody = v.object({ note: v.optional(v.pipe(v.string(), v.maxLength(1000))) }) const ListQuery = v.object({ @@ -9,6 +10,14 @@ const ListQuery = v.object({ }) const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) }) const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) +const ClaimParams = v.object({ token: v.pipe(v.string(), v.minLength(1)) }) +const ClaimBody = v.object({ verification_code: v.pipe(v.string(), v.minLength(1)), guild_id: v.pipe(v.string(), v.minLength(1)) }) +const GuildBody = v.object({ + name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + slug: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-]{1,62}$/)), + lead_user_id: v.pipe(v.string(), v.minLength(1)), + require_lead_approval: v.boolean(), +}) /** * 加入申请(申请制):用户提交申请并查看排队状态;运营在 /admin 人工审核。 @@ -16,7 +25,7 @@ const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) * @param {Record} services */ export function registerAccessRoutes(app, services) { - const { access } = services + const { access, agentAccess, guilds } = services app.get('/api/voko/me', async (request) => { const user = await requireUser(request, services) @@ -43,6 +52,24 @@ export function registerAccessRoutes(app, services) { if (!result.ok) throw codedError(`Access request cannot be reviewed: ${params.id}`, result.code) return { access_request: result.request } }) + + // 主人在 claim_url 落地页认领 Agent:须已通过加入申请,核对验证码并选择公会。 + app.post('/api/voko/claims/:token', async (request) => { + const user = await requireUser(request, services) + const params = v.parse(ClaimParams, request.params) + const body = v.parse(ClaimBody, request.body) + const result = await agentAccess.claim(request.ctx, { claim_token: params.token, user_id: user.id, verification_code: body.verification_code, guild_id: body.guild_id }) + if (!result.ok) throw codedError(result.message, result.code) + return { agent: agentView(result.agent) } + }) + + app.post('/api/voko/admin/guilds', async (request) => { + await requireAdmin(request, services) + const body = v.parse(GuildBody, request.body) + const result = await guilds.create(request.ctx, body) + if (!result.ok) throw codedError(`Guild slug already exists: ${body.slug}`, result.code) + return { guild: { ...result.guild, created_at: result.guild.created_at.toISOString() } } + }) } /** diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js new file mode 100644 index 0000000..620f07d --- /dev/null +++ b/apps/voko-server/src/routes/v1.js @@ -0,0 +1,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} 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 } }) +} diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs new file mode 100644 index 0000000..6ccc2ec --- /dev/null +++ b/apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/** + * F0 入驻的真实链路脚本(Planning 用例 F0-01 – F0-05): + * Agent 注册 → 未认领只读 → 主人申请未通过不能认领 → 审核通过后认领、发 100 启动积分 → 第 4 个 Agent 被拒。 + * + * 前提同 skeleton.mjs:voko-server 已启动(VOKO_API,默认 http://localhost:9201),.env 已配置真实 SMTP; + * VOKO_TEST_EMAIL、VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD。缺任一项输出 BLOCKED 并以 2 退出。 + * 结束时只清理本脚本创建的数据。 + */ +import { createHash, randomUUID } from 'node:crypto' +import { createDb } from '@drama/db' + +import { databaseUrlFromArgs } from '../../../src/db/env-file.js' + +const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') +const missing = ['VOKO_TEST_EMAIL', 'VOKO_ADMIN_EMAIL', 'VOKO_ADMIN_PASSWORD'].filter((name) => !process.env[name]) +if (missing.length > 0) { + console.log(JSON.stringify({ status: 'BLOCKED', missing })) + process.exit(2) +} + +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 2 } }) +const [local, domain] = String(process.env.VOKO_TEST_EMAIL).split('@') +const run = Date.now() +const email = `${local}+voko-f0-${run}@${domain}`.toLowerCase() +const results = [] +const agentIds = [] +let userId = null +let guildId = null + +/** + * @param {string} name + * @param {boolean} pass + * @param {unknown} [detail] + */ +function check(name, pass, detail) { + results.push({ name, pass, ...(pass ? {} : { detail }) }) + if (!pass) throw new Error(`FAILED: ${name} ${JSON.stringify(detail)}`) +} + +/** + * @param {string} method + * @param {string} path + * @param {{ body?: unknown, token?: string }} [opts] + */ +async function call(method, path, opts = {}) { + const response = await fetch(`${api}${path}`, { + method, + headers: { + ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }), + ...(opts.token === undefined ? {} : { authorization: `Bearer ${opts.token}` }), + }, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }) + return { status: response.status, json: await response.json() } +} + +/** 等一封真实投递的邮件落库(发信不阻塞接口返回),最多 30 秒。 */ +async function waitForEmail(where) { + for (let i = 0; i < 30; i += 1) { + const log = await db('email_delivery_logs').where({ to_email: email, ...where }).orderBy('created_at', 'desc').first() + if (log !== undefined) return log + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + return undefined +} + +async function registerAgent(name) { + const registered = await call('POST', '/v1/agents/register', { body: { name, description: 'f0 scenario agent', skills: ['writing'] } }) + if (registered.status === 200) agentIds.push(registered.json.data.agent_id) + return registered +} + +const claimToken = (claimUrl) => claimUrl.split('/claim/')[1] +const walletBalance = async (agentId) => { + const agent = await db('vk_agents').where({ id: agentId }).first('wallet_user_id') + const view = await db('points_balance_view').where({ user_id: agent.wallet_user_id }).first() + return view === undefined ? 0 : Number(view.balance) +} + +try { + // 主人:注册并提交加入申请(先不审核)。 + const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } }) + check('send-code', sent.status === 200, sent) + const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' }) + check('验证码邮件已投递', codeLog !== undefined, { email }) + const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } }) + check('主人注册', owner.status === 200, owner) + userId = owner.json.user.id + const token = owner.json.access_token + const request = await call('POST', '/api/voko/access-requests', { token, body: { note: 'f0 scenario' } }) + check('提交加入申请 → pending', request.json.access_request.status === 'pending', request) + + // 运营:建一个公会。 + const admin = await call('POST', '/api/voko/auth/login', { body: { method: 'password', email: process.env.VOKO_ADMIN_EMAIL, password: process.env.VOKO_ADMIN_PASSWORD } }) + check('admin login', admin.status === 200, admin) + const adminToken = admin.json.access_token + const guild = await call('POST', '/api/voko/admin/guilds', { token: adminToken, body: { name: 'F0 Scenario Guild', slug: `f0-${run}`, lead_user_id: userId, require_lead_approval: false } }) + check('建公会', guild.status === 200 && typeof guild.json.guild.id === 'string', guild) + guildId = guild.json.guild.id + const duplicate = await call('POST', '/api/voko/admin/guilds', { token: adminToken, body: { name: 'dup', slug: `f0-${run}`, lead_user_id: userId, require_lead_approval: false } }) + check('重复 slug → 409', duplicate.status === 409, duplicate) + + // F0-01 注册返回四项;明文 key 之后不再出现,库里只有哈希。 + const first = await registerAgent(`f0-agent-${run}-1`) + const data = first.json.data + check('F0-01 注册返回 agent_id / api_key / claim_url / verification_code', first.status === 200 && first.json.ok === true && data.agent_id.startsWith('ag_') && data.api_key.startsWith('vk_live_') && data.claim_url.includes('/claim/') && /^\d{6}$/.test(data.verification_code), first) + const me = await call('GET', '/v1/agents/me', { token: data.api_key }) + check('F0-01 /agents/me 不返回明文 key', me.status === 200 && me.json.data.id === data.agent_id && me.json.data.status === 'pending_claim' && !JSON.stringify(me.json).includes(data.api_key), me) + const keyRow = await db('vk_api_keys').where({ agent_id: data.agent_id }).first() + check('F0-01 库里只存 sha256', keyRow.key_hash === createHash('sha256').update(data.api_key).digest('hex') && !JSON.stringify(keyRow).includes(data.api_key), { id: keyRow.id }) + const noKey = await call('GET', '/v1/agents/me') + check('无 key → 401 统一错误格式', noKey.status === 401 && noKey.json.ok === false && noKey.json.error.code === 'unauthorized' && typeof noKey.json.error.hint === 'string', noKey) + const badBody = await call('POST', '/v1/agents/register', { body: { name: '' } }) + check('注册参数错误 → 400 invalid_request', badBody.status === 400 && badBody.json.error.code === 'invalid_request', badBody) + + // F0-02 未认领:写 403,读正常。 + const write = await call('PATCH', '/v1/agents/me', { token: data.api_key, body: { description: 'changed' } }) + check('F0-02 未认领 Agent 写接口 → 403', write.status === 403 && write.json.error.code === 'forbidden', write) + + // F0-03 申请未通过不能认领;通过后可以。 + const claimPath = `/api/voko/claims/${claimToken(data.claim_url)}` + const early = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) + check('F0-03 申请未通过认领 → 403', early.status === 403, early) + const approved = await call('POST', `/api/voko/admin/access-requests/${request.json.access_request.id}/review`, { token: adminToken, body: { decision: 'approved' } }) + check('运营审核通过', approved.status === 200, approved) + const notice = await waitForEmail({ purpose: 'access_approved' }) + check('F0-03 通过通知邮件已投递', notice !== undefined && notice.status === 'sent', notice) + + // F0-04 验证码核对、激活、100 启动积分、重放不重复发放。 + const wrongCode = data.verification_code === '000000' ? '111111' : '000000' + const wrong = await call('POST', claimPath, { token, body: { verification_code: wrongCode, guild_id: guildId } }) + check('F0-04 验证码错误 → 400', wrong.status === 400 && wrong.json.error.code === 'CODE_INVALID', wrong) + const claimed = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) + check('F0-04 认领成功 → active', claimed.status === 200 && claimed.json.agent.status === 'active' && claimed.json.agent.guild_id === guildId, claimed) + check('F0-04 钱包收到 100 启动积分', (await walletBalance(data.agent_id)) === 100, { agent_id: data.agent_id }) + const replay = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) + const starterRows = await db('billing_ledger').where({ ref_type: 'vk_agent', ref_id: data.agent_id }) + check('F0-04 重放认领不重复发放', replay.status === 200 && replay.json.agent.id === data.agent_id && starterRows.length === 1 && (await walletBalance(data.agent_id)) === 100, { replay, ledger_rows: starterRows.length }) + const writeAfter = await call('PATCH', '/v1/agents/me', { token: data.api_key, body: { description: 'claimed and updated', skills: ['writing', 'code'] } }) + check('认领后写接口可用', writeAfter.status === 200 && writeAfter.json.data.description === 'claimed and updated' && writeAfter.json.data.skills.length === 2, writeAfter) + + // F0-05 同一主人第 4 个 Agent 被拒。 + for (const n of [2, 3]) { + const next = await registerAgent(`f0-agent-${run}-${n}`) + const nextClaim = await call('POST', `/api/voko/claims/${claimToken(next.json.data.claim_url)}`, { token, body: { verification_code: next.json.data.verification_code, guild_id: guildId } }) + check(`第 ${n} 个 Agent 认领成功`, nextClaim.status === 200, nextClaim) + } + const fourth = await registerAgent(`f0-agent-${run}-4`) + const fourthClaim = await call('POST', `/api/voko/claims/${claimToken(fourth.json.data.claim_url)}`, { token, body: { verification_code: fourth.json.data.verification_code, guild_id: guildId } }) + const fourthRow = await db('vk_agents').where({ id: fourth.json.data.agent_id }).first('status') + check('F0-05 第 4 个 Agent 认领 → 409,仍为 pending_claim、无启动积分', fourthClaim.status === 409 && fourthRow.status === 'pending_claim' && (await walletBalance(fourth.json.data.agent_id)) === 0, fourthClaim) + const counted = await db('vk_access_requests').where({ user_id: userId }).first('claimed_agent_count') + check('认领计数 = 3', counted.claimed_agent_count === 3, counted) + + console.log(JSON.stringify({ status: 'PASS', email, results }, null, 2)) +} catch (err) { + console.log(JSON.stringify({ status: 'FAIL', email, results, error: err instanceof Error ? err.message : String(err) }, null, 2)) + process.exitCode = 1 +} finally { + if (agentIds.length > 0) { + const wallets = (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) + await db('billing_ledger').whereIn('user_id', wallets).del() + await db('vk_api_keys').whereIn('agent_id', agentIds).del() + await db('vk_agent_claims').whereIn('agent_id', agentIds).del() + await db('vk_agents').whereIn('id', agentIds).del() + await db('users').whereIn('id', wallets).del() + } + if (guildId !== null) { + await db('vk_guild_members').where({ guild_id: guildId }).del() + await db('vk_guilds').where({ id: guildId }).del() + } + if (userId !== null) { + await db('vk_access_requests').where({ user_id: userId }).del() + await db('auth_sessions').where({ user_id: userId }).del() + await db('users').where({ id: userId }).del() + } + await db('auth_codes').where({ email }).del() + await db('email_delivery_logs').where({ to_email: email }).del() + await db.destroy() +} diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md index d1865a5..9e82423 100644 --- a/docs/runbooks/README.md +++ b/docs/runbooks/README.md @@ -7,6 +7,7 @@ - 启动、停止或排查本地应用:[本地开发](../local-development.md)。 - 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。 - 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md);本地一键:`VOKO_TEST_EMAIL=… bash tools/scripts/verify-local.sh`。 +- 验收 F0 入驻(Agent 注册 → 认领 → 启动积分 → 3 个上限):[F0 入驻真实链路验收](verify-f0.md)。 ## 部署 diff --git a/docs/runbooks/verify-f0.md b/docs/runbooks/verify-f0.md new file mode 100644 index 0000000..63e77b7 --- /dev/null +++ b/docs/runbooks/verify-f0.md @@ -0,0 +1,26 @@ +# F0 入驻真实链路验收 + +验证 Agent 注册、api_key 只存哈希、未认领只读、主人申请通过后认领、100 启动积分与幂等、每人最多 3 个 Agent(Planning 用例 F0-01 – F0-05)。 + +## 前提 + +与[骨架真实链路验收](verify-skeleton.md)相同:voko-server 已启动,`apps/voko-server/.env` 配置了真实 SMTP,本地库已 `db:migrate` 到最新;需要 `VOKO_TEST_EMAIL` 与一个管理员账号。 + +## 执行 + +```bash +VOKO_TEST_EMAIL=you@gmail.com \ +VOKO_ADMIN_EMAIL=admin@example.test \ +VOKO_ADMIN_PASSWORD=admin123 \ +pnpm --filter @voko/server verify:f0 +``` + +本地一键(`tools/scripts/verify-local.sh`)会在骨架验收之后接着跑这一份。 + +## 完成判据 + +- 输出 `"status": "PASS"`,25 项检查全部通过;测试邮箱收到验证码邮件与审核通过邮件。 +- 缺少环境变量输出 `BLOCKED` 并以 2 退出,不算通过。 +- 脚本结束只删除本次创建的主人、申请、公会、Agent、钱包行、api_key、认领凭证、积分流水与邮件日志。 + +F0-06(认领链接 24 小时失效)依赖 worker 的 `claim_expire`,在 W2 的 `timers.mjs` 里验收。 diff --git a/docs/voko/repo-init-checklist.md b/docs/voko/repo-init-checklist.md index b25140f..fa16de6 100644 --- a/docs/voko/repo-init-checklist.md +++ b/docs/voko/repo-init-checklist.md @@ -92,7 +92,7 @@ Oct 10, 2026 · @lemon **第 5 步:空库初始化与部署通道(半天)** -- [ ] 本地 `pnpm mysql:up` → `init-current-schema.js` 建出平台表与余额视图 +- [x] 本地 `pnpm mysql:up` → `init-current-schema.js` 建出平台表与余额视图(2026-10-10,`verify:skeleton` 15 项通过) - [ ] stage 服务器与 stage 库(独立于 voko game 的 stage);域名 `api.voko.world`(API)与前端域名解析到位 - [ ] 密钥只在各 app 的 gitignored `.env`:DATABASE\_URL、邮件、大模型、Webhook 密钥加密 key diff --git a/packages/voko-access/src/agent-access.js b/packages/voko-access/src/agent-access.js new file mode 100644 index 0000000..efe2d9b --- /dev/null +++ b/packages/voko-access/src/agent-access.js @@ -0,0 +1,129 @@ +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') +} diff --git a/packages/voko-access/src/index.js b/packages/voko-access/src/index.js index cdec300..ef93521 100644 --- a/packages/voko-access/src/index.js +++ b/packages/voko-access/src/index.js @@ -2,10 +2,11 @@ * `@voko/access` —— VOKO 的准入:加入申请(申请制,运营后台人工审核)。 * * Alpha 不设邮箱白名单:任何邮箱都可注册并提交申请,只有 approved 的用户才能认领 Agent、 - * 在网页上发任务。后续的 Agent 注册、认领、api_key 也放在本包。 + * 在网页上发任务。Agent 的注册、api_key 鉴权与认领也在本包。 * * 边界:不读 `process.env`、不建连接;db 与通知端口由 composition root 注入。 */ export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' +export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js' export { default as accessDbSchema } from './db/tables/index.cjs' diff --git a/packages/voko-exchange/src/agents.js b/packages/voko-exchange/src/agents.js new file mode 100644 index 0000000..f4246d8 --- /dev/null +++ b/packages/voko-exchange/src/agents.js @@ -0,0 +1,142 @@ +const TABLE = 'vk_agents' + +/** Agent 的全部状态;消费方 switch 必须穷举。与 DB CHECK `ck_vk_agents_status` 一致。 */ +export const AGENT_STATUSES = Object.freeze(['pending_claim', 'active', 'suspended']) +export const AGENT_ROLES = Object.freeze(['company', 'member']) + +/** + * @typedef {{ + * id: string, + * wallet_user_id: string, + * owner_user_id: string|null, + * guild_id: string|null, + * role: 'company'|'member', + * status: 'pending_claim'|'active'|'suspended', + * name: string, + * description: string|null, + * skills: string[], + * level: number, + * xp: number, + * claimed_at: Date|null, + * created_at: Date, + * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at 为 null,其余状态三者必有。 + */ + +/** + * DB 行 → Agent 的唯一构造点。按状态保证必有字段,非法即 throw。 + * @param {Record} row + * @returns {Agent} + */ +export function agentFromRow(row) { + const id = requiredString(row.id, 'id', row.id) + if (!AGENT_ROLES.includes(row.role)) throw new Error(`vk_agents.role invalid: ${row.role}, id: ${id}`) + switch (row.status) { + case 'pending_claim': + if (row.owner_user_id !== null || row.guild_id !== null || row.claimed_at !== null) throw new Error(`vk_agents pending_claim row has claim fields, id: ${id}`) + break + case 'active': + case 'suspended': + requiredString(row.owner_user_id, 'owner_user_id', id) + requiredString(row.guild_id, 'guild_id', id) + if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`) + break + default: + throw new Error(`Unhandled status: ${row.status}, id: ${id}`) + } + if (!(row.created_at instanceof Date)) throw new Error(`vk_agents.created_at missing, id: ${id}`) + if (!Array.isArray(row.skills) || row.skills.some((skill) => typeof skill !== 'string')) throw new Error(`vk_agents.skills invalid, id: ${id}`) + if (!Number.isSafeInteger(row.level) || row.level < 1) throw new Error(`vk_agents.level invalid, id: ${id}`) + if (!Number.isSafeInteger(row.xp) || row.xp < 0) throw new Error(`vk_agents.xp invalid, id: ${id}`) + return { + id, + wallet_user_id: requiredString(row.wallet_user_id, 'wallet_user_id', id), + owner_user_id: row.owner_user_id, + guild_id: row.guild_id, + role: row.role, + status: row.status, + name: requiredString(row.name, 'name', id), + description: row.description === null ? null : requiredString(row.description, 'description', id), + skills: row.skills, + level: row.level, + xp: row.xp, + claimed_at: row.claimed_at, + created_at: row.created_at, + } +} + +/** + * vk_agents 的读写。写方法的 `runner` 传调用方的事务,使 Agent 行与认领、记账一起提交。 + */ +export function createAgents() { + /** + * 新注册的 Agent:待认领、员工角色、1 级。 + * @param {object} _ctx + * @param {import('knex').Knex} runner + * @param {{ id: string, wallet_user_id: string, name: string, description: string|null, skills: string[], owner_email: string|null, created_at: Date }} input + */ + async function insertPending(_ctx, runner, input) { + await runner(TABLE).insert({ + id: input.id, + wallet_user_id: input.wallet_user_id, + role: 'member', + status: 'pending_claim', + name: input.name, + description: input.description, + skills: JSON.stringify(input.skills), + owner_email: input.owner_email, + level: 1, + xp: 0, + events_cursor: 0, + created_at: input.created_at, + }) + } + + /** + * @param {object} _ctx + * @param {import('knex').Knex} runner + * @param {{ agent_id: string }} input + * @returns {Promise} + */ + async function getById(_ctx, runner, { agent_id }) { + const row = await runner(TABLE).where({ id: agent_id }).first() + if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`) + return agentFromRow(row) + } + + /** + * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。 + * @param {object} _ctx + * @param {import('knex').Knex} runner + * @param {{ agent_id: string, owner_user_id: string, guild_id: string, claimed_at: Date }} input + */ + async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, claimed_at }) { + const updated = await runner(TABLE).where({ id: agent_id, status: 'pending_claim' }).update({ status: 'active', owner_user_id, guild_id, claimed_at, updated_at: claimed_at }) + if (updated !== 1) throw new Error(`vk_agents activate expected pending_claim row, id: ${agent_id}`) + } + + /** + * @param {object} _ctx + * @param {import('knex').Knex} runner + * @param {{ agent_id: string, description?: string, skills?: string[] }} input + */ + async function updateProfile(_ctx, runner, { agent_id, description, skills }) { + await runner(TABLE).where({ id: agent_id }).update({ + ...(description === undefined ? {} : { description }), + ...(skills === undefined ? {} : { skills: JSON.stringify(skills) }), + updated_at: new Date(), + }) + } + + return { insertPending, getById, activate, updateProfile } +} + +/** + * @param {unknown} value + * @param {string} field + * @param {unknown} id + * @returns {string} + */ +function requiredString(value, field, id) { + if (typeof value === 'string' && value.length > 0) return value + throw new Error(`vk_agents.${field} invalid, id: ${String(id)}`) +} diff --git a/packages/voko-exchange/src/guilds.js b/packages/voko-exchange/src/guilds.js new file mode 100644 index 0000000..82dd6d9 --- /dev/null +++ b/packages/voko-exchange/src/guilds.js @@ -0,0 +1,33 @@ +import { randomUUID } from 'node:crypto' + +const TABLE = 'vk_guilds' + +/** + * @typedef {{ id: string, name: string, slug: string, lead_user_id: string, require_lead_approval: boolean, created_at: Date }} Guild + */ + +/** + * @param {{ db: import('knex').Knex }} deps + */ +export function createGuilds({ db }) { + if (typeof db !== 'function') throw new Error('createGuilds: db is required') + + /** + * 建公会并把负责人登记为成员。slug 已被占用返回 CONFLICT(由 UNIQUE 约束裁决)。 + * @param {object} _ctx + * @param {{ name: string, slug: string, lead_user_id: string, require_lead_approval: boolean }} input + * @returns {Promise<{ ok: true, guild: Guild } | { ok: false, code: 'CONFLICT' }>} + */ + async function create(_ctx, { name, slug, lead_user_id, require_lead_approval }) { + const guild = { id: `g_${randomUUID()}`, name, slug, lead_user_id, require_lead_approval, created_at: new Date() } + return db.transaction(async (trx) => { + // slug 冲突由 UNIQUE 裁决:被忽略的插入读不回本次 id。 + await trx(TABLE).insert(guild).onConflict('slug').ignore() + if ((await trx(TABLE).where({ id: guild.id }).first()) === undefined) return { ok: false, code: 'CONFLICT' } + await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id: guild.id, user_id: lead_user_id, role: 'lead', created_at: guild.created_at }) + return { ok: true, guild } + }) + } + + return { create } +} diff --git a/packages/voko-exchange/src/index.js b/packages/voko-exchange/src/index.js index 1feafe4..752b633 100644 --- a/packages/voko-exchange/src/index.js +++ b/packages/voko-exchange/src/index.js @@ -1,11 +1,13 @@ /** * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 * - * 当前有表结构、任务构造点与状态机纯函数;service 在 W2–W3 加入。 + * 当前有表结构、Agent 与公会的读写、任务构造点与状态机纯函数;任务 service 在 W2–W3 加入。 * * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 */ +export { createAgents, agentFromRow, AGENT_STATUSES, AGENT_ROLES } from './agents.js' +export { createGuilds } from './guilds.js' export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js' export { default as exchangeDbSchema } from './db/tables/index.cjs' diff --git a/tools/scripts/verify-local.sh b/tools/scripts/verify-local.sh index 6f343cc..517c60c 100755 --- a/tools/scripts/verify-local.sh +++ b/tools/scripts/verify-local.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 本地一键验收骨架:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑真实链路脚本 → 停 API。 +# 本地一键验收:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑骨架与 F0 真实链路脚本 → 停 API。 # 用法:VOKO_TEST_EMAIL=you@gmail.com bash tools/scripts/verify-local.sh set -euo pipefail cd "$(dirname "$0")/../.." @@ -27,3 +27,5 @@ curl -sf http://localhost:9201/healthz >/dev/null || { echo "API 未启动,日 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ pnpm --filter @voko/server verify:skeleton +VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ + pnpm --filter @voko/server verify:f0 -- libgit2 0.21.4