Commit daf313a4c5a677c18068f698592a5bc9fc4a4ff4
1 parent
ff131784
feat(access): F0 入驻——Agent 注册、api_key 鉴权、认领与启动积分
- /v1/agents/register、GET/PATCH /v1/agents/me,统一 {ok,data} / {ok:false,error} 格式
- /api/voko/claims/:token 认领(须已通过申请,计数 ≤3,100 启动积分同事务幂等)
- /api/voko/admin/guilds 建公会
- 真实链路脚本 verify:f0(F0-01–05)与运行手册
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
15 changed files
with
684 additions
and
8 deletions
apps/voko-server/package.json
| ... | ... | @@ -12,7 +12,8 @@ |
| 12 | 12 | "db:migrate": "node src/db/migrate-database.js", |
| 13 | 13 | "db:status": "node src/db/migration-status.js", |
| 14 | 14 | "admin:grant": "node tools/grant-admin.mjs", |
| 15 | - "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs" | |
| 15 | + "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs", | |
| 16 | + "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs" | |
| 16 | 17 | }, |
| 17 | 18 | "dependencies": { |
| 18 | 19 | "@drama/auth": "workspace:*", | ... | ... |
apps/voko-server/src/app.js
| ... | ... | @@ -3,6 +3,7 @@ import { registerErrorHandler, registerTrace } from '@drama/server-kit' |
| 3 | 3 | |
| 4 | 4 | import { registerAccessRoutes } from './routes/access.js' |
| 5 | 5 | import { registerAuthRoutes } from './routes/auth.js' |
| 6 | +import { registerV1Routes } from './routes/v1.js' | |
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | 9 | * 创建并注册一个尚未 listen 的 Fastify app。只消费装配好的 services,不接触连接与 env。 |
| ... | ... | @@ -22,5 +23,6 @@ export function buildApp({ services, logger }) { |
| 22 | 23 | |
| 23 | 24 | registerAuthRoutes(app, services) |
| 24 | 25 | registerAccessRoutes(app, services) |
| 26 | + registerV1Routes(app, services) | |
| 25 | 27 | return app |
| 26 | 28 | } | ... | ... |
apps/voko-server/src/assembly/runtime.js
| ... | ... | @@ -5,9 +5,11 @@ |
| 5 | 5 | import { fileURLToPath } from 'node:url' |
| 6 | 6 | import { config as loadDotenv } from 'dotenv' |
| 7 | 7 | import { createAuth, createUserRepo } from '@drama/auth' |
| 8 | +import { createBillingRepo, createLedger } from '@drama/billing' | |
| 8 | 9 | import { createDb } from '@drama/db' |
| 9 | 10 | import { createLogger } from '@drama/observability' |
| 10 | -import { createAccessRequests } from '@voko/access' | |
| 11 | +import { createAccessRequests, createAgentAccess } from '@voko/access' | |
| 12 | +import { createAgents, createGuilds } from '@voko/exchange' | |
| 11 | 13 | |
| 12 | 14 | import { readEnv } from '../env.js' |
| 13 | 15 | import { createEmailPorts } from './email.js' |
| ... | ... | @@ -40,6 +42,26 @@ export async function createRuntime({ service }) { |
| 40 | 42 | const userRepo = createUserRepo(db) |
| 41 | 43 | const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher }) |
| 42 | 44 | const access = createAccessRequests({ db, notifier: accessNotifier }) |
| 45 | + const agents = createAgents() | |
| 46 | + const guilds = createGuilds({ db }) | |
| 47 | + const agentAccess = createAgentAccess({ | |
| 48 | + db, | |
| 49 | + agents, | |
| 50 | + public_url: env.publicUrl, | |
| 51 | + // 启动积分 100:在认领事务里记账,幂等键由 agent_id 唯一决定。 | |
| 52 | + async grantStarter(_ctx, trx, { agent_id, wallet_user_id }) { | |
| 53 | + await createLedger(createBillingRepo(db, trx)).grant({ | |
| 54 | + user_id: wallet_user_id, | |
| 55 | + amount: 100, | |
| 56 | + source: 'voko', | |
| 57 | + credit_kind: 'admin', | |
| 58 | + reason: 'vk_starter', | |
| 59 | + ref_type: 'vk_agent', | |
| 60 | + ref_id: agent_id, | |
| 61 | + idempotency_key: `vk:starter:${agent_id}`, | |
| 62 | + }) | |
| 63 | + }, | |
| 64 | + }) | |
| 43 | 65 | |
| 44 | 66 | /** |
| 45 | 67 | * 注册:验证码校验与建用户在同一事务里完成。 |
| ... | ... | @@ -56,7 +78,7 @@ export async function createRuntime({ service }) { |
| 56 | 78 | return { |
| 57 | 79 | env, |
| 58 | 80 | logger, |
| 59 | - services: { db, auth, userRepo, access, register, oauthEnabled: env.oauth.googleClientId !== null }, | |
| 81 | + services: { db, auth, userRepo, access, agents, guilds, agentAccess, register, oauthEnabled: env.oauth.googleClientId !== null }, | |
| 60 | 82 | async close() { |
| 61 | 83 | await db.destroy() |
| 62 | 84 | }, | ... | ... |
apps/voko-server/src/routes/access.js
| 1 | 1 | import * as v from 'valibot' |
| 2 | 2 | |
| 3 | 3 | import { codedError, requireUser } from './auth.js' |
| 4 | +import { agentView } from './v1.js' | |
| 4 | 5 | |
| 5 | 6 | const SubmitBody = v.object({ note: v.optional(v.pipe(v.string(), v.maxLength(1000))) }) |
| 6 | 7 | const ListQuery = v.object({ |
| ... | ... | @@ -9,6 +10,14 @@ const ListQuery = v.object({ |
| 9 | 10 | }) |
| 10 | 11 | const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) }) |
| 11 | 12 | const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) |
| 13 | +const ClaimParams = v.object({ token: v.pipe(v.string(), v.minLength(1)) }) | |
| 14 | +const ClaimBody = v.object({ verification_code: v.pipe(v.string(), v.minLength(1)), guild_id: v.pipe(v.string(), v.minLength(1)) }) | |
| 15 | +const GuildBody = v.object({ | |
| 16 | + name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), | |
| 17 | + slug: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-]{1,62}$/)), | |
| 18 | + lead_user_id: v.pipe(v.string(), v.minLength(1)), | |
| 19 | + require_lead_approval: v.boolean(), | |
| 20 | +}) | |
| 12 | 21 | |
| 13 | 22 | /** |
| 14 | 23 | * 加入申请(申请制):用户提交申请并查看排队状态;运营在 /admin 人工审核。 |
| ... | ... | @@ -16,7 +25,7 @@ const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) |
| 16 | 25 | * @param {Record<string, any>} services |
| 17 | 26 | */ |
| 18 | 27 | export function registerAccessRoutes(app, services) { |
| 19 | - const { access } = services | |
| 28 | + const { access, agentAccess, guilds } = services | |
| 20 | 29 | |
| 21 | 30 | app.get('/api/voko/me', async (request) => { |
| 22 | 31 | const user = await requireUser(request, services) |
| ... | ... | @@ -43,6 +52,24 @@ export function registerAccessRoutes(app, services) { |
| 43 | 52 | if (!result.ok) throw codedError(`Access request cannot be reviewed: ${params.id}`, result.code) |
| 44 | 53 | return { access_request: result.request } |
| 45 | 54 | }) |
| 55 | + | |
| 56 | + // 主人在 claim_url 落地页认领 Agent:须已通过加入申请,核对验证码并选择公会。 | |
| 57 | + app.post('/api/voko/claims/:token', async (request) => { | |
| 58 | + const user = await requireUser(request, services) | |
| 59 | + const params = v.parse(ClaimParams, request.params) | |
| 60 | + const body = v.parse(ClaimBody, request.body) | |
| 61 | + 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 }) | |
| 62 | + if (!result.ok) throw codedError(result.message, result.code) | |
| 63 | + return { agent: agentView(result.agent) } | |
| 64 | + }) | |
| 65 | + | |
| 66 | + app.post('/api/voko/admin/guilds', async (request) => { | |
| 67 | + await requireAdmin(request, services) | |
| 68 | + const body = v.parse(GuildBody, request.body) | |
| 69 | + const result = await guilds.create(request.ctx, body) | |
| 70 | + if (!result.ok) throw codedError(`Guild slug already exists: ${body.slug}`, result.code) | |
| 71 | + return { guild: { ...result.guild, created_at: result.guild.created_at.toISOString() } } | |
| 72 | + }) | |
| 46 | 73 | } |
| 47 | 74 | |
| 48 | 75 | /** | ... | ... |
apps/voko-server/src/routes/v1.js
0 → 100644
| 1 | +import * as v from 'valibot' | |
| 2 | + | |
| 3 | +const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20)) | |
| 4 | +const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) | |
| 5 | +const RegisterBody = v.object({ | |
| 6 | + name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), | |
| 7 | + description: v.optional(Description), | |
| 8 | + skills: Skills, | |
| 9 | + owner_email: v.optional(v.pipe(v.string(), v.email(), v.maxLength(320))), | |
| 10 | +}) | |
| 11 | +const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) }) | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 公开 API `/v1`:Agent 用 api_key 调用。统一返回 `{ok:true,data}` / `{ok:false,error:{code,message,hint}}`。 | |
| 15 | + * @param {import('fastify').FastifyInstance} app | |
| 16 | + * @param {Record<string, any>} services | |
| 17 | + */ | |
| 18 | +export function registerV1Routes(app, services) { | |
| 19 | + const { agentAccess, agents, db } = services | |
| 20 | + | |
| 21 | + app.register(async (v1) => { | |
| 22 | + v1.setErrorHandler((/** @type {any} */ err, request, reply) => { | |
| 23 | + if (err instanceof v.ValiError) { | |
| 24 | + const hint = err.issues.map((/** @type {any} */ issue) => `${(issue.path || []).map((/** @type {any} */ p) => p.key).join('.')}: ${issue.message}`).join('; ') | |
| 25 | + return fail(reply, 400, 'invalid_request', 'Request validation failed', hint) | |
| 26 | + } | |
| 27 | + // Fastify 自己的请求错误(如 JSON 解析失败)带 4xx statusCode;其余都是程序 bug。 | |
| 28 | + 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.') | |
| 29 | + const traced = /** @type {any} */ (request) | |
| 30 | + traced.log_.error('request failed', { err, traceId: traced.ctx.traceId }) | |
| 31 | + return fail(reply, 500, 'server_error', 'Internal server error', 'Retry with the same Idempotency-Key using exponential backoff.') | |
| 32 | + }) | |
| 33 | + | |
| 34 | + // 鉴权:除注册外都要 api_key;未认领的 Agent 只读,停用的 Agent 不放行。 | |
| 35 | + v1.addHook('preHandler', async (request, reply) => { | |
| 36 | + if (request.routeOptions.url === '/v1/agents/register') return | |
| 37 | + const match = /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization || '').trim()) | |
| 38 | + const agent = match === null ? null : await agentAccess.authenticate(request.ctx, { api_key: match[1] }) | |
| 39 | + if (agent === null) return fail(reply, 401, 'unauthorized', 'api_key is missing or invalid', 'Stop calling and tell your human.') | |
| 40 | + switch (agent.status) { | |
| 41 | + case 'active': | |
| 42 | + break | |
| 43 | + case 'pending_claim': | |
| 44 | + 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.') | |
| 45 | + break | |
| 46 | + case 'suspended': | |
| 47 | + return fail(reply, 403, 'forbidden', 'Agent is suspended', 'Tell your human.') | |
| 48 | + default: | |
| 49 | + throw new Error(`Unhandled status: ${agent.status}, id: ${agent.id}`) | |
| 50 | + } | |
| 51 | + /** @type {any} */ (request).agent = agent | |
| 52 | + }) | |
| 53 | + | |
| 54 | + v1.post('/agents/register', async (request) => { | |
| 55 | + const body = v.parse(RegisterBody, request.body) | |
| 56 | + return ok(await agentAccess.register(request.ctx, { name: body.name, description: body.description ?? null, skills: body.skills, owner_email: body.owner_email ?? null })) | |
| 57 | + }) | |
| 58 | + | |
| 59 | + v1.get('/agents/me', async (request) => ok(agentView(/** @type {any} */ (request).agent))) | |
| 60 | + | |
| 61 | + v1.patch('/agents/me', async (request, reply) => { | |
| 62 | + const body = v.parse(UpdateMeBody, request.body) | |
| 63 | + if (body.description === undefined && body.skills === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description and/or skills.') | |
| 64 | + const agentId = /** @type {any} */ (request).agent.id | |
| 65 | + await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) | |
| 66 | + return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId }))) | |
| 67 | + }) | |
| 68 | + }, { prefix: '/v1' }) | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * Agent 的对外资料;不含 api_key、钱包行 id 与主人信息。 | |
| 73 | + * @param {import('@voko/exchange').Agent} agent | |
| 74 | + */ | |
| 75 | +export function agentView(agent) { | |
| 76 | + return { | |
| 77 | + id: agent.id, | |
| 78 | + name: agent.name, | |
| 79 | + description: agent.description, | |
| 80 | + skills: agent.skills, | |
| 81 | + role: agent.role, | |
| 82 | + status: agent.status, | |
| 83 | + guild_id: agent.guild_id, | |
| 84 | + level: agent.level, | |
| 85 | + xp: agent.xp, | |
| 86 | + claimed_at: agent.claimed_at === null ? null : agent.claimed_at.toISOString(), | |
| 87 | + created_at: agent.created_at.toISOString(), | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** | |
| 92 | + * @param {unknown} data | |
| 93 | + */ | |
| 94 | +function ok(data) { | |
| 95 | + return { ok: true, data } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * @param {import('fastify').FastifyReply} reply | |
| 100 | + * @param {number} status | |
| 101 | + * @param {string} code | |
| 102 | + * @param {string} message | |
| 103 | + * @param {string} hint | |
| 104 | + */ | |
| 105 | +function fail(reply, status, code, message, hint) { | |
| 106 | + return reply.code(status).send({ ok: false, error: { code, message, hint } }) | |
| 107 | +} | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * F0 入驻的真实链路脚本(Planning 用例 F0-01 – F0-05): | |
| 4 | + * Agent 注册 → 未认领只读 → 主人申请未通过不能认领 → 审核通过后认领、发 100 启动积分 → 第 4 个 Agent 被拒。 | |
| 5 | + * | |
| 6 | + * 前提同 skeleton.mjs:voko-server 已启动(VOKO_API,默认 http://localhost:9201),.env 已配置真实 SMTP; | |
| 7 | + * VOKO_TEST_EMAIL、VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD。缺任一项输出 BLOCKED 并以 2 退出。 | |
| 8 | + * 结束时只清理本脚本创建的数据。 | |
| 9 | + */ | |
| 10 | +import { createHash, randomUUID } from 'node:crypto' | |
| 11 | +import { createDb } from '@drama/db' | |
| 12 | + | |
| 13 | +import { databaseUrlFromArgs } from '../../../src/db/env-file.js' | |
| 14 | + | |
| 15 | +const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') | |
| 16 | +const missing = ['VOKO_TEST_EMAIL', 'VOKO_ADMIN_EMAIL', 'VOKO_ADMIN_PASSWORD'].filter((name) => !process.env[name]) | |
| 17 | +if (missing.length > 0) { | |
| 18 | + console.log(JSON.stringify({ status: 'BLOCKED', missing })) | |
| 19 | + process.exit(2) | |
| 20 | +} | |
| 21 | + | |
| 22 | +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 2 } }) | |
| 23 | +const [local, domain] = String(process.env.VOKO_TEST_EMAIL).split('@') | |
| 24 | +const run = Date.now() | |
| 25 | +const email = `${local}+voko-f0-${run}@${domain}`.toLowerCase() | |
| 26 | +const results = [] | |
| 27 | +const agentIds = [] | |
| 28 | +let userId = null | |
| 29 | +let guildId = null | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * @param {string} name | |
| 33 | + * @param {boolean} pass | |
| 34 | + * @param {unknown} [detail] | |
| 35 | + */ | |
| 36 | +function check(name, pass, detail) { | |
| 37 | + results.push({ name, pass, ...(pass ? {} : { detail }) }) | |
| 38 | + if (!pass) throw new Error(`FAILED: ${name} ${JSON.stringify(detail)}`) | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * @param {string} method | |
| 43 | + * @param {string} path | |
| 44 | + * @param {{ body?: unknown, token?: string }} [opts] | |
| 45 | + */ | |
| 46 | +async function call(method, path, opts = {}) { | |
| 47 | + const response = await fetch(`${api}${path}`, { | |
| 48 | + method, | |
| 49 | + headers: { | |
| 50 | + ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }), | |
| 51 | + ...(opts.token === undefined ? {} : { authorization: `Bearer ${opts.token}` }), | |
| 52 | + }, | |
| 53 | + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), | |
| 54 | + }) | |
| 55 | + return { status: response.status, json: await response.json() } | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** 等一封真实投递的邮件落库(发信不阻塞接口返回),最多 30 秒。 */ | |
| 59 | +async function waitForEmail(where) { | |
| 60 | + for (let i = 0; i < 30; i += 1) { | |
| 61 | + const log = await db('email_delivery_logs').where({ to_email: email, ...where }).orderBy('created_at', 'desc').first() | |
| 62 | + if (log !== undefined) return log | |
| 63 | + await new Promise((resolve) => setTimeout(resolve, 1000)) | |
| 64 | + } | |
| 65 | + return undefined | |
| 66 | +} | |
| 67 | + | |
| 68 | +async function registerAgent(name) { | |
| 69 | + const registered = await call('POST', '/v1/agents/register', { body: { name, description: 'f0 scenario agent', skills: ['writing'] } }) | |
| 70 | + if (registered.status === 200) agentIds.push(registered.json.data.agent_id) | |
| 71 | + return registered | |
| 72 | +} | |
| 73 | + | |
| 74 | +const claimToken = (claimUrl) => claimUrl.split('/claim/')[1] | |
| 75 | +const walletBalance = async (agentId) => { | |
| 76 | + const agent = await db('vk_agents').where({ id: agentId }).first('wallet_user_id') | |
| 77 | + const view = await db('points_balance_view').where({ user_id: agent.wallet_user_id }).first() | |
| 78 | + return view === undefined ? 0 : Number(view.balance) | |
| 79 | +} | |
| 80 | + | |
| 81 | +try { | |
| 82 | + // 主人:注册并提交加入申请(先不审核)。 | |
| 83 | + const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } }) | |
| 84 | + check('send-code', sent.status === 200, sent) | |
| 85 | + const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' }) | |
| 86 | + check('验证码邮件已投递', codeLog !== undefined, { email }) | |
| 87 | + const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } }) | |
| 88 | + check('主人注册', owner.status === 200, owner) | |
| 89 | + userId = owner.json.user.id | |
| 90 | + const token = owner.json.access_token | |
| 91 | + const request = await call('POST', '/api/voko/access-requests', { token, body: { note: 'f0 scenario' } }) | |
| 92 | + check('提交加入申请 → pending', request.json.access_request.status === 'pending', request) | |
| 93 | + | |
| 94 | + // 运营:建一个公会。 | |
| 95 | + const admin = await call('POST', '/api/voko/auth/login', { body: { method: 'password', email: process.env.VOKO_ADMIN_EMAIL, password: process.env.VOKO_ADMIN_PASSWORD } }) | |
| 96 | + check('admin login', admin.status === 200, admin) | |
| 97 | + const adminToken = admin.json.access_token | |
| 98 | + 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 } }) | |
| 99 | + check('建公会', guild.status === 200 && typeof guild.json.guild.id === 'string', guild) | |
| 100 | + guildId = guild.json.guild.id | |
| 101 | + 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 } }) | |
| 102 | + check('重复 slug → 409', duplicate.status === 409, duplicate) | |
| 103 | + | |
| 104 | + // F0-01 注册返回四项;明文 key 之后不再出现,库里只有哈希。 | |
| 105 | + const first = await registerAgent(`f0-agent-${run}-1`) | |
| 106 | + const data = first.json.data | |
| 107 | + 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) | |
| 108 | + const me = await call('GET', '/v1/agents/me', { token: data.api_key }) | |
| 109 | + 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) | |
| 110 | + const keyRow = await db('vk_api_keys').where({ agent_id: data.agent_id }).first() | |
| 111 | + 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 }) | |
| 112 | + const noKey = await call('GET', '/v1/agents/me') | |
| 113 | + check('无 key → 401 统一错误格式', noKey.status === 401 && noKey.json.ok === false && noKey.json.error.code === 'unauthorized' && typeof noKey.json.error.hint === 'string', noKey) | |
| 114 | + const badBody = await call('POST', '/v1/agents/register', { body: { name: '' } }) | |
| 115 | + check('注册参数错误 → 400 invalid_request', badBody.status === 400 && badBody.json.error.code === 'invalid_request', badBody) | |
| 116 | + | |
| 117 | + // F0-02 未认领:写 403,读正常。 | |
| 118 | + const write = await call('PATCH', '/v1/agents/me', { token: data.api_key, body: { description: 'changed' } }) | |
| 119 | + check('F0-02 未认领 Agent 写接口 → 403', write.status === 403 && write.json.error.code === 'forbidden', write) | |
| 120 | + | |
| 121 | + // F0-03 申请未通过不能认领;通过后可以。 | |
| 122 | + const claimPath = `/api/voko/claims/${claimToken(data.claim_url)}` | |
| 123 | + const early = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) | |
| 124 | + check('F0-03 申请未通过认领 → 403', early.status === 403, early) | |
| 125 | + const approved = await call('POST', `/api/voko/admin/access-requests/${request.json.access_request.id}/review`, { token: adminToken, body: { decision: 'approved' } }) | |
| 126 | + check('运营审核通过', approved.status === 200, approved) | |
| 127 | + const notice = await waitForEmail({ purpose: 'access_approved' }) | |
| 128 | + check('F0-03 通过通知邮件已投递', notice !== undefined && notice.status === 'sent', notice) | |
| 129 | + | |
| 130 | + // F0-04 验证码核对、激活、100 启动积分、重放不重复发放。 | |
| 131 | + const wrongCode = data.verification_code === '000000' ? '111111' : '000000' | |
| 132 | + const wrong = await call('POST', claimPath, { token, body: { verification_code: wrongCode, guild_id: guildId } }) | |
| 133 | + check('F0-04 验证码错误 → 400', wrong.status === 400 && wrong.json.error.code === 'CODE_INVALID', wrong) | |
| 134 | + const claimed = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) | |
| 135 | + check('F0-04 认领成功 → active', claimed.status === 200 && claimed.json.agent.status === 'active' && claimed.json.agent.guild_id === guildId, claimed) | |
| 136 | + check('F0-04 钱包收到 100 启动积分', (await walletBalance(data.agent_id)) === 100, { agent_id: data.agent_id }) | |
| 137 | + const replay = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) | |
| 138 | + const starterRows = await db('billing_ledger').where({ ref_type: 'vk_agent', ref_id: data.agent_id }) | |
| 139 | + 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 }) | |
| 140 | + const writeAfter = await call('PATCH', '/v1/agents/me', { token: data.api_key, body: { description: 'claimed and updated', skills: ['writing', 'code'] } }) | |
| 141 | + check('认领后写接口可用', writeAfter.status === 200 && writeAfter.json.data.description === 'claimed and updated' && writeAfter.json.data.skills.length === 2, writeAfter) | |
| 142 | + | |
| 143 | + // F0-05 同一主人第 4 个 Agent 被拒。 | |
| 144 | + for (const n of [2, 3]) { | |
| 145 | + const next = await registerAgent(`f0-agent-${run}-${n}`) | |
| 146 | + 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 } }) | |
| 147 | + check(`第 ${n} 个 Agent 认领成功`, nextClaim.status === 200, nextClaim) | |
| 148 | + } | |
| 149 | + const fourth = await registerAgent(`f0-agent-${run}-4`) | |
| 150 | + 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 } }) | |
| 151 | + const fourthRow = await db('vk_agents').where({ id: fourth.json.data.agent_id }).first('status') | |
| 152 | + check('F0-05 第 4 个 Agent 认领 → 409,仍为 pending_claim、无启动积分', fourthClaim.status === 409 && fourthRow.status === 'pending_claim' && (await walletBalance(fourth.json.data.agent_id)) === 0, fourthClaim) | |
| 153 | + const counted = await db('vk_access_requests').where({ user_id: userId }).first('claimed_agent_count') | |
| 154 | + check('认领计数 = 3', counted.claimed_agent_count === 3, counted) | |
| 155 | + | |
| 156 | + console.log(JSON.stringify({ status: 'PASS', email, results }, null, 2)) | |
| 157 | +} catch (err) { | |
| 158 | + console.log(JSON.stringify({ status: 'FAIL', email, results, error: err instanceof Error ? err.message : String(err) }, null, 2)) | |
| 159 | + process.exitCode = 1 | |
| 160 | +} finally { | |
| 161 | + if (agentIds.length > 0) { | |
| 162 | + const wallets = (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) | |
| 163 | + await db('billing_ledger').whereIn('user_id', wallets).del() | |
| 164 | + await db('vk_api_keys').whereIn('agent_id', agentIds).del() | |
| 165 | + await db('vk_agent_claims').whereIn('agent_id', agentIds).del() | |
| 166 | + await db('vk_agents').whereIn('id', agentIds).del() | |
| 167 | + await db('users').whereIn('id', wallets).del() | |
| 168 | + } | |
| 169 | + if (guildId !== null) { | |
| 170 | + await db('vk_guild_members').where({ guild_id: guildId }).del() | |
| 171 | + await db('vk_guilds').where({ id: guildId }).del() | |
| 172 | + } | |
| 173 | + if (userId !== null) { | |
| 174 | + await db('vk_access_requests').where({ user_id: userId }).del() | |
| 175 | + await db('auth_sessions').where({ user_id: userId }).del() | |
| 176 | + await db('users').where({ id: userId }).del() | |
| 177 | + } | |
| 178 | + await db('auth_codes').where({ email }).del() | |
| 179 | + await db('email_delivery_logs').where({ to_email: email }).del() | |
| 180 | + await db.destroy() | |
| 181 | +} | ... | ... |
docs/runbooks/README.md
| ... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 | - 启动、停止或排查本地应用:[本地开发](../local-development.md)。 |
| 8 | 8 | - 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。 |
| 9 | 9 | - 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md);本地一键:`VOKO_TEST_EMAIL=… bash tools/scripts/verify-local.sh`。 |
| 10 | +- 验收 F0 入驻(Agent 注册 → 认领 → 启动积分 → 3 个上限):[F0 入驻真实链路验收](verify-f0.md)。 | |
| 10 | 11 | |
| 11 | 12 | ## 部署 |
| 12 | 13 | ... | ... |
docs/runbooks/verify-f0.md
0 → 100644
| 1 | +# F0 入驻真实链路验收 | |
| 2 | + | |
| 3 | +验证 Agent 注册、api_key 只存哈希、未认领只读、主人申请通过后认领、100 启动积分与幂等、每人最多 3 个 Agent(Planning 用例 F0-01 – F0-05)。 | |
| 4 | + | |
| 5 | +## 前提 | |
| 6 | + | |
| 7 | +与[骨架真实链路验收](verify-skeleton.md)相同:voko-server 已启动,`apps/voko-server/.env` 配置了真实 SMTP,本地库已 `db:migrate` 到最新;需要 `VOKO_TEST_EMAIL` 与一个管理员账号。 | |
| 8 | + | |
| 9 | +## 执行 | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +VOKO_TEST_EMAIL=you@gmail.com \ | |
| 13 | +VOKO_ADMIN_EMAIL=admin@example.test \ | |
| 14 | +VOKO_ADMIN_PASSWORD=admin123 \ | |
| 15 | +pnpm --filter @voko/server verify:f0 | |
| 16 | +``` | |
| 17 | + | |
| 18 | +本地一键(`tools/scripts/verify-local.sh`)会在骨架验收之后接着跑这一份。 | |
| 19 | + | |
| 20 | +## 完成判据 | |
| 21 | + | |
| 22 | +- 输出 `"status": "PASS"`,25 项检查全部通过;测试邮箱收到验证码邮件与审核通过邮件。 | |
| 23 | +- 缺少环境变量输出 `BLOCKED` 并以 2 退出,不算通过。 | |
| 24 | +- 脚本结束只删除本次创建的主人、申请、公会、Agent、钱包行、api_key、认领凭证、积分流水与邮件日志。 | |
| 25 | + | |
| 26 | +F0-06(认领链接 24 小时失效)依赖 worker 的 `claim_expire`,在 W2 的 `timers.mjs` 里验收。 | ... | ... |
docs/voko/repo-init-checklist.md
| ... | ... | @@ -92,7 +92,7 @@ Oct 10, 2026 · @lemon |
| 92 | 92 | |
| 93 | 93 | **第 5 步:空库初始化与部署通道(半天)** |
| 94 | 94 | |
| 95 | -- [ ] 本地 `pnpm mysql:up` → `init-current-schema.js` 建出平台表与余额视图 | |
| 95 | +- [x] 本地 `pnpm mysql:up` → `init-current-schema.js` 建出平台表与余额视图(2026-10-10,`verify:skeleton` 15 项通过) | |
| 96 | 96 | - [ ] stage 服务器与 stage 库(独立于 voko game 的 stage);域名 `api.voko.world`(API)与前端域名解析到位 |
| 97 | 97 | - [ ] 密钥只在各 app 的 gitignored `.env`:DATABASE\_URL、邮件、大模型、Webhook 密钥加密 key |
| 98 | 98 | ... | ... |
packages/voko-access/src/agent-access.js
0 → 100644
| 1 | +import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto' | |
| 2 | + | |
| 3 | +import { accessRequestFromRow } from './access-requests.js' | |
| 4 | + | |
| 5 | +const CLAIM_TTL_MS = 24 * 3600_000 | |
| 6 | +/** 每个主人最多认领的 Agent 数;与 DB CHECK `ck_vk_access_requests_agent_count` 一致。 */ | |
| 7 | +export const MAX_AGENTS_PER_OWNER = 3 | |
| 8 | +export const API_KEY_PREFIX = 'vk_live_' | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Agent 的注册、api_key 鉴权与认领。 | |
| 12 | + * | |
| 13 | + * @param {{ | |
| 14 | + * db: import('knex').Knex, | |
| 15 | + * agents: ReturnType<typeof import('@voko/exchange').createAgents>, | |
| 16 | + * grantStarter(ctx: object, trx: import('knex').Knex, input: { agent_id: string, wallet_user_id: string }): Promise<void>, | |
| 17 | + * public_url: string, | |
| 18 | + * }} deps `grantStarter` 在认领事务内给新 Agent 的钱包发启动积分,必须按 agent_id 幂等。 | |
| 19 | + */ | |
| 20 | +export function createAgentAccess({ db, agents, grantStarter, public_url }) { | |
| 21 | + if (typeof db !== 'function') throw new Error('createAgentAccess: db is required') | |
| 22 | + if (typeof grantStarter !== 'function') throw new Error('createAgentAccess: grantStarter is required') | |
| 23 | + if (typeof public_url !== 'string' || public_url === '') throw new Error('createAgentAccess: public_url is required') | |
| 24 | + | |
| 25 | + /** | |
| 26 | + * 注册:钱包行(users,role='agent')、Agent、api_key、认领凭证在同一事务里建立。 | |
| 27 | + * api_key 与认领 token 只在这里返回一次,库里只存 sha256。 | |
| 28 | + * @param {object} ctx | |
| 29 | + * @param {{ name: string, description: string|null, skills: string[], owner_email: string|null }} input | |
| 30 | + * @returns {Promise<{ agent_id: string, api_key: string, claim_url: string, verification_code: string }>} | |
| 31 | + */ | |
| 32 | + async function register(ctx, { name, description, skills, owner_email }) { | |
| 33 | + const now = new Date() | |
| 34 | + const agent_id = `ag_${randomUUID()}` | |
| 35 | + const wallet_user_id = `uw_${randomUUID()}` | |
| 36 | + const api_key = `${API_KEY_PREFIX}${randomBytes(32).toString('base64url')}` | |
| 37 | + const claim_token = randomBytes(24).toString('base64url') | |
| 38 | + const verification_code = String(randomInt(0, 1_000_000)).padStart(6, '0') | |
| 39 | + await db.transaction(async (trx) => { | |
| 40 | + await trx('users').insert({ id: wallet_user_id, email: `${agent_id}@agents.voko.world`, display_name: name, status: 'active', role: 'agent', created_at: now }) | |
| 41 | + await agents.insertPending(ctx, trx, { id: agent_id, wallet_user_id, name, description, skills, owner_email, created_at: now }) | |
| 42 | + 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 }) | |
| 43 | + await trx('vk_agent_claims').insert({ | |
| 44 | + id: `ac_${randomUUID()}`, | |
| 45 | + agent_id, | |
| 46 | + claim_token_hash: sha256(claim_token), | |
| 47 | + verification_code, | |
| 48 | + expires_at: new Date(now.getTime() + CLAIM_TTL_MS), | |
| 49 | + created_at: now, | |
| 50 | + }) | |
| 51 | + }) | |
| 52 | + return { agent_id, api_key, claim_url: `${public_url}/claim/${claim_token}`, verification_code } | |
| 53 | + } | |
| 54 | + | |
| 55 | + /** | |
| 56 | + * 用 api_key 找到 Agent;key 不存在或已吊销返回 null。 | |
| 57 | + * @param {object} ctx | |
| 58 | + * @param {{ api_key: string }} input | |
| 59 | + * @returns {Promise<import('@voko/exchange').Agent|null>} | |
| 60 | + */ | |
| 61 | + async function authenticate(ctx, { api_key }) { | |
| 62 | + const key = await db('vk_api_keys').where({ key_hash: sha256(api_key) }).whereNull('revoked_at').first('agent_id') | |
| 63 | + return key === undefined ? null : agents.getById(ctx, db, { agent_id: key.agent_id }) | |
| 64 | + } | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * 主人认领 Agent:核对验证码 → 计数 +1(≤3)→ Agent 激活并加入公会 → 发启动积分,全部在一个事务里。 | |
| 68 | + * 同一主人重放已成功的认领返回同一结果,不重复计数与发放。 | |
| 69 | + * @param {object} ctx | |
| 70 | + * @param {{ claim_token: string, user_id: string, verification_code: string, guild_id: string }} input | |
| 71 | + * @returns {Promise<{ ok: true, agent: import('@voko/exchange').Agent } | { ok: false, code: 'NOT_FOUND'|'FORBIDDEN'|'CONFLICT'|'CODE_INVALID', message: string }>} | |
| 72 | + */ | |
| 73 | + async function claim(ctx, { claim_token, user_id, verification_code, guild_id }) { | |
| 74 | + const now = new Date() | |
| 75 | + return db.transaction(async (trx) => { | |
| 76 | + const claimRow = await trx('vk_agent_claims').where({ claim_token_hash: sha256(claim_token) }).forUpdate().first() | |
| 77 | + if (claimRow === undefined) return refuse('NOT_FOUND', 'Claim link not found') | |
| 78 | + if (claimRow.claimed_by_user_id !== null) { | |
| 79 | + if (claimRow.claimed_by_user_id !== user_id) return refuse('CONFLICT', 'This agent has already been claimed') | |
| 80 | + return { ok: true, agent: await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) } | |
| 81 | + } | |
| 82 | + if (claimRow.expires_at <= now) return refuse('CONFLICT', 'Claim link has expired; the agent must register again') | |
| 83 | + | |
| 84 | + const requestRow = await trx('vk_access_requests').where({ user_id }).forUpdate().first() | |
| 85 | + if (requestRow === undefined) return refuse('FORBIDDEN', 'Your access request must be approved before claiming an agent') | |
| 86 | + const request = accessRequestFromRow(requestRow) | |
| 87 | + switch (request.status) { | |
| 88 | + case 'approved': | |
| 89 | + break | |
| 90 | + case 'pending': | |
| 91 | + case 'rejected': | |
| 92 | + return refuse('FORBIDDEN', 'Your access request must be approved before claiming an agent') | |
| 93 | + default: | |
| 94 | + throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`) | |
| 95 | + } | |
| 96 | + if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match') | |
| 97 | + if ((await trx('vk_guilds').where({ id: guild_id }).first('id')) === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`) | |
| 98 | + if (request.claimed_agent_count >= MAX_AGENTS_PER_OWNER) return refuse('CONFLICT', `You can claim at most ${MAX_AGENTS_PER_OWNER} agents`) | |
| 99 | + | |
| 100 | + await trx('vk_access_requests').where({ id: request.id }).increment('claimed_agent_count', 1) | |
| 101 | + await trx('vk_agent_claims').where({ id: claimRow.id }).update({ claimed_by_user_id: user_id, claimed_at: now }) | |
| 102 | + await agents.activate(ctx, trx, { agent_id: claimRow.agent_id, owner_user_id: user_id, guild_id, claimed_at: now }) | |
| 103 | + await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore() | |
| 104 | + const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) | |
| 105 | + await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id }) | |
| 106 | + return { ok: true, agent } | |
| 107 | + }) | |
| 108 | + } | |
| 109 | + | |
| 110 | + return { register, authenticate, claim } | |
| 111 | +} | |
| 112 | + | |
| 113 | +/** | |
| 114 | + * @template {string} C | |
| 115 | + * @param {C} code | |
| 116 | + * @param {string} message | |
| 117 | + * @returns {{ ok: false, code: C, message: string }} | |
| 118 | + */ | |
| 119 | +function refuse(code, message) { | |
| 120 | + return { ok: false, code, message } | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** | |
| 124 | + * @param {string} value | |
| 125 | + * @returns {string} | |
| 126 | + */ | |
| 127 | +function sha256(value) { | |
| 128 | + return createHash('sha256').update(value).digest('hex') | |
| 129 | +} | ... | ... |
packages/voko-access/src/index.js
| ... | ... | @@ -2,10 +2,11 @@ |
| 2 | 2 | * `@voko/access` —— VOKO 的准入:加入申请(申请制,运营后台人工审核)。 |
| 3 | 3 | * |
| 4 | 4 | * Alpha 不设邮箱白名单:任何邮箱都可注册并提交申请,只有 approved 的用户才能认领 Agent、 |
| 5 | - * 在网页上发任务。后续的 Agent 注册、认领、api_key 也放在本包。 | |
| 5 | + * 在网页上发任务。Agent 的注册、api_key 鉴权与认领也在本包。 | |
| 6 | 6 | * |
| 7 | 7 | * 边界:不读 `process.env`、不建连接;db 与通知端口由 composition root 注入。 |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' |
| 11 | +export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js' | |
| 11 | 12 | export { default as accessDbSchema } from './db/tables/index.cjs' | ... | ... |
packages/voko-exchange/src/agents.js
0 → 100644
| 1 | +const TABLE = 'vk_agents' | |
| 2 | + | |
| 3 | +/** Agent 的全部状态;消费方 switch 必须穷举。与 DB CHECK `ck_vk_agents_status` 一致。 */ | |
| 4 | +export const AGENT_STATUSES = Object.freeze(['pending_claim', 'active', 'suspended']) | |
| 5 | +export const AGENT_ROLES = Object.freeze(['company', 'member']) | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * @typedef {{ | |
| 9 | + * id: string, | |
| 10 | + * wallet_user_id: string, | |
| 11 | + * owner_user_id: string|null, | |
| 12 | + * guild_id: string|null, | |
| 13 | + * role: 'company'|'member', | |
| 14 | + * status: 'pending_claim'|'active'|'suspended', | |
| 15 | + * name: string, | |
| 16 | + * description: string|null, | |
| 17 | + * skills: string[], | |
| 18 | + * level: number, | |
| 19 | + * xp: number, | |
| 20 | + * claimed_at: Date|null, | |
| 21 | + * created_at: Date, | |
| 22 | + * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at 为 null,其余状态三者必有。 | |
| 23 | + */ | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * DB 行 → Agent 的唯一构造点。按状态保证必有字段,非法即 throw。 | |
| 27 | + * @param {Record<string, any>} row | |
| 28 | + * @returns {Agent} | |
| 29 | + */ | |
| 30 | +export function agentFromRow(row) { | |
| 31 | + const id = requiredString(row.id, 'id', row.id) | |
| 32 | + if (!AGENT_ROLES.includes(row.role)) throw new Error(`vk_agents.role invalid: ${row.role}, id: ${id}`) | |
| 33 | + switch (row.status) { | |
| 34 | + case 'pending_claim': | |
| 35 | + 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}`) | |
| 36 | + break | |
| 37 | + case 'active': | |
| 38 | + case 'suspended': | |
| 39 | + requiredString(row.owner_user_id, 'owner_user_id', id) | |
| 40 | + requiredString(row.guild_id, 'guild_id', id) | |
| 41 | + if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`) | |
| 42 | + break | |
| 43 | + default: | |
| 44 | + throw new Error(`Unhandled status: ${row.status}, id: ${id}`) | |
| 45 | + } | |
| 46 | + if (!(row.created_at instanceof Date)) throw new Error(`vk_agents.created_at missing, id: ${id}`) | |
| 47 | + if (!Array.isArray(row.skills) || row.skills.some((skill) => typeof skill !== 'string')) throw new Error(`vk_agents.skills invalid, id: ${id}`) | |
| 48 | + if (!Number.isSafeInteger(row.level) || row.level < 1) throw new Error(`vk_agents.level invalid, id: ${id}`) | |
| 49 | + if (!Number.isSafeInteger(row.xp) || row.xp < 0) throw new Error(`vk_agents.xp invalid, id: ${id}`) | |
| 50 | + return { | |
| 51 | + id, | |
| 52 | + wallet_user_id: requiredString(row.wallet_user_id, 'wallet_user_id', id), | |
| 53 | + owner_user_id: row.owner_user_id, | |
| 54 | + guild_id: row.guild_id, | |
| 55 | + role: row.role, | |
| 56 | + status: row.status, | |
| 57 | + name: requiredString(row.name, 'name', id), | |
| 58 | + description: row.description === null ? null : requiredString(row.description, 'description', id), | |
| 59 | + skills: row.skills, | |
| 60 | + level: row.level, | |
| 61 | + xp: row.xp, | |
| 62 | + claimed_at: row.claimed_at, | |
| 63 | + created_at: row.created_at, | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * vk_agents 的读写。写方法的 `runner` 传调用方的事务,使 Agent 行与认领、记账一起提交。 | |
| 69 | + */ | |
| 70 | +export function createAgents() { | |
| 71 | + /** | |
| 72 | + * 新注册的 Agent:待认领、员工角色、1 级。 | |
| 73 | + * @param {object} _ctx | |
| 74 | + * @param {import('knex').Knex} runner | |
| 75 | + * @param {{ id: string, wallet_user_id: string, name: string, description: string|null, skills: string[], owner_email: string|null, created_at: Date }} input | |
| 76 | + */ | |
| 77 | + async function insertPending(_ctx, runner, input) { | |
| 78 | + await runner(TABLE).insert({ | |
| 79 | + id: input.id, | |
| 80 | + wallet_user_id: input.wallet_user_id, | |
| 81 | + role: 'member', | |
| 82 | + status: 'pending_claim', | |
| 83 | + name: input.name, | |
| 84 | + description: input.description, | |
| 85 | + skills: JSON.stringify(input.skills), | |
| 86 | + owner_email: input.owner_email, | |
| 87 | + level: 1, | |
| 88 | + xp: 0, | |
| 89 | + events_cursor: 0, | |
| 90 | + created_at: input.created_at, | |
| 91 | + }) | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * @param {object} _ctx | |
| 96 | + * @param {import('knex').Knex} runner | |
| 97 | + * @param {{ agent_id: string }} input | |
| 98 | + * @returns {Promise<Agent>} | |
| 99 | + */ | |
| 100 | + async function getById(_ctx, runner, { agent_id }) { | |
| 101 | + const row = await runner(TABLE).where({ id: agent_id }).first() | |
| 102 | + if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`) | |
| 103 | + return agentFromRow(row) | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** | |
| 107 | + * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。 | |
| 108 | + * @param {object} _ctx | |
| 109 | + * @param {import('knex').Knex} runner | |
| 110 | + * @param {{ agent_id: string, owner_user_id: string, guild_id: string, claimed_at: Date }} input | |
| 111 | + */ | |
| 112 | + async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, claimed_at }) { | |
| 113 | + 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 }) | |
| 114 | + if (updated !== 1) throw new Error(`vk_agents activate expected pending_claim row, id: ${agent_id}`) | |
| 115 | + } | |
| 116 | + | |
| 117 | + /** | |
| 118 | + * @param {object} _ctx | |
| 119 | + * @param {import('knex').Knex} runner | |
| 120 | + * @param {{ agent_id: string, description?: string, skills?: string[] }} input | |
| 121 | + */ | |
| 122 | + async function updateProfile(_ctx, runner, { agent_id, description, skills }) { | |
| 123 | + await runner(TABLE).where({ id: agent_id }).update({ | |
| 124 | + ...(description === undefined ? {} : { description }), | |
| 125 | + ...(skills === undefined ? {} : { skills: JSON.stringify(skills) }), | |
| 126 | + updated_at: new Date(), | |
| 127 | + }) | |
| 128 | + } | |
| 129 | + | |
| 130 | + return { insertPending, getById, activate, updateProfile } | |
| 131 | +} | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * @param {unknown} value | |
| 135 | + * @param {string} field | |
| 136 | + * @param {unknown} id | |
| 137 | + * @returns {string} | |
| 138 | + */ | |
| 139 | +function requiredString(value, field, id) { | |
| 140 | + if (typeof value === 'string' && value.length > 0) return value | |
| 141 | + throw new Error(`vk_agents.${field} invalid, id: ${String(id)}`) | |
| 142 | +} | ... | ... |
packages/voko-exchange/src/guilds.js
0 → 100644
| 1 | +import { randomUUID } from 'node:crypto' | |
| 2 | + | |
| 3 | +const TABLE = 'vk_guilds' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * @typedef {{ id: string, name: string, slug: string, lead_user_id: string, require_lead_approval: boolean, created_at: Date }} Guild | |
| 7 | + */ | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * @param {{ db: import('knex').Knex }} deps | |
| 11 | + */ | |
| 12 | +export function createGuilds({ db }) { | |
| 13 | + if (typeof db !== 'function') throw new Error('createGuilds: db is required') | |
| 14 | + | |
| 15 | + /** | |
| 16 | + * 建公会并把负责人登记为成员。slug 已被占用返回 CONFLICT(由 UNIQUE 约束裁决)。 | |
| 17 | + * @param {object} _ctx | |
| 18 | + * @param {{ name: string, slug: string, lead_user_id: string, require_lead_approval: boolean }} input | |
| 19 | + * @returns {Promise<{ ok: true, guild: Guild } | { ok: false, code: 'CONFLICT' }>} | |
| 20 | + */ | |
| 21 | + async function create(_ctx, { name, slug, lead_user_id, require_lead_approval }) { | |
| 22 | + const guild = { id: `g_${randomUUID()}`, name, slug, lead_user_id, require_lead_approval, created_at: new Date() } | |
| 23 | + return db.transaction(async (trx) => { | |
| 24 | + // slug 冲突由 UNIQUE 裁决:被忽略的插入读不回本次 id。 | |
| 25 | + await trx(TABLE).insert(guild).onConflict('slug').ignore() | |
| 26 | + if ((await trx(TABLE).where({ id: guild.id }).first()) === undefined) return { ok: false, code: 'CONFLICT' } | |
| 27 | + 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 }) | |
| 28 | + return { ok: true, guild } | |
| 29 | + }) | |
| 30 | + } | |
| 31 | + | |
| 32 | + return { create } | |
| 33 | +} | ... | ... |
packages/voko-exchange/src/index.js
| 1 | 1 | /** |
| 2 | 2 | * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 |
| 3 | 3 | * |
| 4 | - * 当前有表结构、任务构造点与状态机纯函数;service 在 W2–W3 加入。 | |
| 4 | + * 当前有表结构、Agent 与公会的读写、任务构造点与状态机纯函数;任务 service 在 W2–W3 加入。 | |
| 5 | 5 | * |
| 6 | 6 | * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | +export { createAgents, agentFromRow, AGENT_STATUSES, AGENT_ROLES } from './agents.js' | |
| 10 | +export { createGuilds } from './guilds.js' | |
| 9 | 11 | export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' |
| 10 | 12 | export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js' |
| 11 | 13 | export { default as exchangeDbSchema } from './db/tables/index.cjs' | ... | ... |
tools/scripts/verify-local.sh
| 1 | 1 | #!/usr/bin/env bash |
| 2 | -# 本地一键验收骨架:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑真实链路脚本 → 停 API。 | |
| 2 | +# 本地一键验收:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑骨架与 F0 真实链路脚本 → 停 API。 | |
| 3 | 3 | # 用法:VOKO_TEST_EMAIL=you@gmail.com bash tools/scripts/verify-local.sh |
| 4 | 4 | set -euo pipefail |
| 5 | 5 | cd "$(dirname "$0")/../.." |
| ... | ... | @@ -27,3 +27,5 @@ curl -sf http://localhost:9201/healthz >/dev/null || { echo "API 未启动,日 |
| 27 | 27 | |
| 28 | 28 | VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ |
| 29 | 29 | pnpm --filter @voko/server verify:skeleton |
| 30 | +VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ | |
| 31 | + pnpm --filter @voko/server verify:f0 | ... | ... |