Commit daf313a4c5a677c18068f698592a5bc9fc4a4ff4

Authored by yibo
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>
apps/voko-server/package.json
@@ -12,7 +12,8 @@ @@ -12,7 +12,8 @@
12 "db:migrate": "node src/db/migrate-database.js", 12 "db:migrate": "node src/db/migrate-database.js",
13 "db:status": "node src/db/migration-status.js", 13 "db:status": "node src/db/migration-status.js",
14 "admin:grant": "node tools/grant-admin.mjs", 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 "dependencies": { 18 "dependencies": {
18 "@drama/auth": "workspace:*", 19 "@drama/auth": "workspace:*",
apps/voko-server/src/app.js
@@ -3,6 +3,7 @@ import { registerErrorHandler, registerTrace } from &#39;@drama/server-kit&#39; @@ -3,6 +3,7 @@ import { registerErrorHandler, registerTrace } from &#39;@drama/server-kit&#39;
3 3
4 import { registerAccessRoutes } from './routes/access.js' 4 import { registerAccessRoutes } from './routes/access.js'
5 import { registerAuthRoutes } from './routes/auth.js' 5 import { registerAuthRoutes } from './routes/auth.js'
  6 +import { registerV1Routes } from './routes/v1.js'
6 7
7 /** 8 /**
8 * 创建并注册一个尚未 listen 的 Fastify app。只消费装配好的 services,不接触连接与 env。 9 * 创建并注册一个尚未 listen 的 Fastify app。只消费装配好的 services,不接触连接与 env。
@@ -22,5 +23,6 @@ export function buildApp({ services, logger }) { @@ -22,5 +23,6 @@ export function buildApp({ services, logger }) {
22 23
23 registerAuthRoutes(app, services) 24 registerAuthRoutes(app, services)
24 registerAccessRoutes(app, services) 25 registerAccessRoutes(app, services)
  26 + registerV1Routes(app, services)
25 return app 27 return app
26 } 28 }
apps/voko-server/src/assembly/runtime.js
@@ -5,9 +5,11 @@ @@ -5,9 +5,11 @@
5 import { fileURLToPath } from 'node:url' 5 import { fileURLToPath } from 'node:url'
6 import { config as loadDotenv } from 'dotenv' 6 import { config as loadDotenv } from 'dotenv'
7 import { createAuth, createUserRepo } from '@drama/auth' 7 import { createAuth, createUserRepo } from '@drama/auth'
  8 +import { createBillingRepo, createLedger } from '@drama/billing'
8 import { createDb } from '@drama/db' 9 import { createDb } from '@drama/db'
9 import { createLogger } from '@drama/observability' 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 import { readEnv } from '../env.js' 14 import { readEnv } from '../env.js'
13 import { createEmailPorts } from './email.js' 15 import { createEmailPorts } from './email.js'
@@ -40,6 +42,26 @@ export async function createRuntime({ service }) { @@ -40,6 +42,26 @@ export async function createRuntime({ service }) {
40 const userRepo = createUserRepo(db) 42 const userRepo = createUserRepo(db)
41 const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher }) 43 const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher })
42 const access = createAccessRequests({ db, notifier: accessNotifier }) 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,7 +78,7 @@ export async function createRuntime({ service }) {
56 return { 78 return {
57 env, 79 env,
58 logger, 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 async close() { 82 async close() {
61 await db.destroy() 83 await db.destroy()
62 }, 84 },
apps/voko-server/src/routes/access.js
1 import * as v from 'valibot' 1 import * as v from 'valibot'
2 2
3 import { codedError, requireUser } from './auth.js' 3 import { codedError, requireUser } from './auth.js'
  4 +import { agentView } from './v1.js'
4 5
5 const SubmitBody = v.object({ note: v.optional(v.pipe(v.string(), v.maxLength(1000))) }) 6 const SubmitBody = v.object({ note: v.optional(v.pipe(v.string(), v.maxLength(1000))) })
6 const ListQuery = v.object({ 7 const ListQuery = v.object({
@@ -9,6 +10,14 @@ const ListQuery = v.object({ @@ -9,6 +10,14 @@ const ListQuery = v.object({
9 }) 10 })
10 const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) }) 11 const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) })
11 const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) 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 * 加入申请(申请制):用户提交申请并查看排队状态;运营在 /admin 人工审核。 23 * 加入申请(申请制):用户提交申请并查看排队状态;运营在 /admin 人工审核。
@@ -16,7 +25,7 @@ const ReviewBody = v.object({ decision: v.picklist([&#39;approved&#39;, &#39;rejected&#39;]) }) @@ -16,7 +25,7 @@ const ReviewBody = v.object({ decision: v.picklist([&#39;approved&#39;, &#39;rejected&#39;]) })
16 * @param {Record<string, any>} services 25 * @param {Record<string, any>} services
17 */ 26 */
18 export function registerAccessRoutes(app, services) { 27 export function registerAccessRoutes(app, services) {
19 - const { access } = services 28 + const { access, agentAccess, guilds } = services
20 29
21 app.get('/api/voko/me', async (request) => { 30 app.get('/api/voko/me', async (request) => {
22 const user = await requireUser(request, services) 31 const user = await requireUser(request, services)
@@ -43,6 +52,24 @@ export function registerAccessRoutes(app, services) { @@ -43,6 +52,24 @@ export function registerAccessRoutes(app, services) {
43 if (!result.ok) throw codedError(`Access request cannot be reviewed: ${params.id}`, result.code) 52 if (!result.ok) throw codedError(`Access request cannot be reviewed: ${params.id}`, result.code)
44 return { access_request: result.request } 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,6 +7,7 @@
7 - 启动、停止或排查本地应用:[本地开发](../local-development.md)。 7 - 启动、停止或排查本地应用:[本地开发](../local-development.md)。
8 - 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。 8 - 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。
9 - 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md);本地一键:`VOKO_TEST_EMAIL=… bash tools/scripts/verify-local.sh`。 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,7 +92,7 @@ Oct 10, 2026 · @lemon
92 92
93 **第 5 步:空库初始化与部署通道(半天)** 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 - [ ] stage 服务器与 stage 库(独立于 voko game 的 stage);域名 `api.voko.world`(API)与前端域名解析到位 96 - [ ] stage 服务器与 stage 库(独立于 voko game 的 stage);域名 `api.voko.world`(API)与前端域名解析到位
97 - [ ] 密钥只在各 app 的 gitignored `.env`:DATABASE\_URL、邮件、大模型、Webhook 密钥加密 key 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,10 +2,11 @@
2 * `@voko/access` —— VOKO 的准入:加入申请(申请制,运营后台人工审核)。 2 * `@voko/access` —— VOKO 的准入:加入申请(申请制,运营后台人工审核)。
3 * 3 *
4 * Alpha 不设邮箱白名单:任何邮箱都可注册并提交申请,只有 approved 的用户才能认领 Agent、 4 * Alpha 不设邮箱白名单:任何邮箱都可注册并提交申请,只有 approved 的用户才能认领 Agent、
5 - * 在网页上发任务。后续的 Agent 注册、认领、api_key 也放在本包。 5 + * 在网页上发任务。Agent 的注册、api_key 鉴权与认领也在本包。
6 * 6 *
7 * 边界:不读 `process.env`、不建连接;db 与通知端口由 composition root 注入。 7 * 边界:不读 `process.env`、不建连接;db 与通知端口由 composition root 注入。
8 */ 8 */
9 9
10 export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' 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 export { default as accessDbSchema } from './db/tables/index.cjs' 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 * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 2 * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。
3 * 3 *
4 - * 当前有表结构、任务构造点与状态机纯函数;service 在 W2–W3 加入。 4 + * 当前有表结构、Agent 与公会的读写、任务构造点与状态机纯函数;任务 service 在 W2–W3 加入。
5 * 5 *
6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 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 export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' 11 export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js'
10 export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js' 12 export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js'
11 export { default as exchangeDbSchema } from './db/tables/index.cjs' 13 export { default as exchangeDbSchema } from './db/tables/index.cjs'
tools/scripts/verify-local.sh
1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 -# 本地一键验收骨架:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑真实链路脚本 → 停 API。 2 +# 本地一键验收:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑骨架与 F0 真实链路脚本 → 停 API。
3 # 用法:VOKO_TEST_EMAIL=you@gmail.com bash tools/scripts/verify-local.sh 3 # 用法:VOKO_TEST_EMAIL=you@gmail.com bash tools/scripts/verify-local.sh
4 set -euo pipefail 4 set -euo pipefail
5 cd "$(dirname "$0")/../.." 5 cd "$(dirname "$0")/../.."
@@ -27,3 +27,5 @@ curl -sf http://localhost:9201/healthz &gt;/dev/null || { echo &quot;API 未启动,日 @@ -27,3 +27,5 @@ curl -sf http://localhost:9201/healthz &gt;/dev/null || { echo &quot;API 未启动,日
27 27
28 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ 28 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \
29 pnpm --filter @voko/server verify:skeleton 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