Commit dcf597fe730d2ec2bdd37cc36bb5a04519564438

Authored by yibo
1 parent a3cd11bd

feat: voko-server / voko-web 骨架与加入申请(初始化清单第 3–4 步)

- @voko/access:加入申请(vk_access_requests,DB 约束保证状态与审核字段组合合法),构造函数覆盖全部状态
- @voko/server:邮箱验证码注册 + 密码登录,可选 Google 登录;/api/voko/me、提交申请、/admin 审核(审核通过发邮件)
- 数据库命令 db:init / db:migrate / db:status(独立库 voko,历史表 voko_schema_migrations),admin:grant
- voko-worker 入口(骨架阶段仅心跳),PM2 ecosystem
- @voko/web:登录 / 注册、申请状态、/admin 审核队列(en / zh)
- tools/scenarios/voko-alpha/skeleton.mjs:注册 → 申请 → 审核 → 记账幂等的真实链路验收

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011X363N3XYVt41WUtnjtDZH
Showing 42 changed files with 1988 additions and 0 deletions
README.md
@@ -13,3 +13,17 @@ pnpm lint &amp;&amp; pnpm test @@ -13,3 +13,17 @@ pnpm lint &amp;&amp; pnpm test
13 ``` 13 ```
14 14
15 开发规范见 `AGENTS.md`,产品与设计文档见 `docs/voko/README.md`。 15 开发规范见 `AGENTS.md`,产品与设计文档见 `docs/voko/README.md`。
  16 +
  17 +## 第一次跑起来(骨架)
  18 +
  19 +```bash
  20 +cp apps/voko-server/.env.example apps/voko-server/.env # 填 SMTP;DATABASE_URL 留空即用本地 voko 库
  21 +pnpm mysql:up
  22 +pnpm --filter @voko/server db:init # 只对空库执行
  23 +pnpm --filter @voko/server dev # API: http://localhost:9201/healthz
  24 +pnpm --filter @voko/web dev # 前端: http://localhost:5201
  25 +```
  26 +
  27 +1. 在前端注册一个账号,再执行 `pnpm --filter @voko/server admin:grant 你的邮箱` 把它设为管理员。
  28 +2. 用另一个邮箱注册并提交加入申请,用管理员账号在 `/admin` 审核。
  29 +3. 真实链路验收:`VOKO_TEST_EMAIL=... VOKO_ADMIN_EMAIL=... VOKO_ADMIN_PASSWORD=... pnpm --filter @voko/server verify:skeleton`
apps/voko-server/.env.example 0 → 100644
  1 +# 复制为 .env 后填写真实值;.env 不入库。
  2 +NODE_ENV=development
  3 +PORT=9201
  4 +LOG_LEVEL=info
  5 +# 本地默认:pnpm mysql:up 起的 MySQL,库名 voko(与 @drama/db 的 devMysqlUrl 一致)
  6 +DATABASE_URL=
  7 +# 生产必须设置,开发缺省使用固定开发值
  8 +AUTH_TOKEN_SECRET=
  9 +# 前端地址,用于邮件里的链接
  10 +PUBLIC_URL=http://localhost:5201
  11 +# 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。
  12 +SMTP_HOST=
  13 +SMTP_PORT=465
  14 +SMTP_SECURE=true
  15 +SMTP_USER=
  16 +SMTP_PASS=
  17 +EMAIL_FROM=
  18 +# 可选:Google 登录。不填则只开放邮箱注册 + 密码登录。
  19 +GOOGLE_OAUTH_CLIENT_ID=
  20 +GOOGLE_OAUTH_CLIENT_SECRET=
apps/voko-server/package.json 0 → 100644
  1 +{
  2 + "name": "@voko/server",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "scripts": {
  7 + "start": "node src/main.js",
  8 + "dev": "node --watch src/main.js",
  9 + "worker": "node src/voko-worker-main.js",
  10 + "test": "vitest run --passWithNoTests",
  11 + "db:init": "node src/db/init-current-schema.js",
  12 + "db:migrate": "node src/db/migrate-database.js",
  13 + "db:status": "node src/db/migration-status.js",
  14 + "admin:grant": "node tools/grant-admin.mjs",
  15 + "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs"
  16 + },
  17 + "dependencies": {
  18 + "@drama/auth": "workspace:*",
  19 + "@drama/billing": "workspace:*",
  20 + "@drama/contracts": "workspace:*",
  21 + "@drama/db": "workspace:*",
  22 + "@drama/email": "workspace:*",
  23 + "@drama/observability": "workspace:*",
  24 + "@drama/server-kit": "workspace:*",
  25 + "@voko/access": "workspace:*",
  26 + "dotenv": "16.6.1",
  27 + "fastify": "^5.1.0",
  28 + "googleapis": "^173.0.0",
  29 + "valibot": "^1.4.1"
  30 + },
  31 + "devDependencies": {
  32 + "vitest": "^2.1.4"
  33 + }
  34 +}
apps/voko-server/src/app.js 0 → 100644
  1 +import Fastify from 'fastify'
  2 +import { registerErrorHandler, registerTrace } from '@drama/server-kit'
  3 +
  4 +import { registerAccessRoutes } from './routes/access.js'
  5 +import { registerAuthRoutes } from './routes/auth.js'
  6 +
  7 +/**
  8 + * 创建并注册一个尚未 listen 的 Fastify app。只消费装配好的 services,不接触连接与 env。
  9 + * @param {{ services: Record<string, any>, logger: { child: Function } }} deps
  10 + * @returns {import('fastify').FastifyInstance}
  11 + */
  12 +export function buildApp({ services, logger }) {
  13 + const app = Fastify({ logger: false })
  14 + registerTrace(app, { logger })
  15 + registerErrorHandler(app)
  16 +
  17 + app.get('/healthz', async (request) => ({
  18 + ok: true,
  19 + service: '@voko/server',
  20 + trace_id: request.ctx && request.ctx.traceId,
  21 + }))
  22 +
  23 + registerAuthRoutes(app, services)
  24 + registerAccessRoutes(app, services)
  25 + return app
  26 +}
apps/voko-server/src/assembly/email-delivery-log.js 0 → 100644
  1 +import { createHash, randomUUID } from 'node:crypto'
  2 +
  3 +/**
  4 + * @param {import('knex').Knex|undefined} db
  5 + * @param {{
  6 + * id?: string,
  7 + * provider?: string,
  8 + * messageId?: string|null,
  9 + * purpose: string,
  10 + * toEmail?: string,
  11 + * status: string,
  12 + * template?: string,
  13 + * errorMessage?: string,
  14 + * metadata?: any
  15 + * }} record
  16 + */
  17 +export async function recordEmailDelivery(db, record) {
  18 + if (!db || typeof db !== 'function') throw new Error('recordEmailDelivery requires db')
  19 + if (!(await db.schema.hasTable('email_delivery_logs'))) throw new Error('recordEmailDelivery requires email_delivery_logs table')
  20 + const toEmail = requiredString(record.toEmail, 'toEmail')
  21 + const purpose = requiredString(record.purpose, 'purpose')
  22 + const status = requiredString(record.status, 'status')
  23 + const row = {
  24 + id: record.id || deliveryLogId(record),
  25 + provider: record.provider || null,
  26 + message_id: record.messageId || null,
  27 + purpose,
  28 + to_email: toEmail,
  29 + status,
  30 + template: record.template || null,
  31 + error_message: record.errorMessage || null,
  32 + metadata_json: record.metadata ? JSON.stringify(record.metadata) : null,
  33 + updated_at: db.fn.now(),
  34 + }
  35 + await db('email_delivery_logs')
  36 + .insert(row)
  37 + .onConflict('id')
  38 + .merge({
  39 + provider: row.provider,
  40 + message_id: row.message_id,
  41 + purpose: row.purpose,
  42 + to_email: row.to_email,
  43 + status: row.status,
  44 + template: row.template,
  45 + error_message: row.error_message,
  46 + metadata_json: row.metadata_json,
  47 + updated_at: row.updated_at,
  48 + })
  49 +}
  50 +
  51 +/**
  52 + * @param {{ purpose: string, toEmail?: string }} record
  53 + */
  54 +function deliveryLogId(record) {
  55 + const purpose = String(record.purpose || 'email').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32)
  56 + const toEmail = String(record.toEmail || '').trim().toLowerCase()
  57 + const entropy = `${Date.now()}:${randomUUID()}`
  58 + const digest = createHash('sha1').update(`${purpose}:${toEmail}:${entropy}`).digest('hex').slice(0, 24)
  59 + return `email_${purpose}_${digest}`
  60 +}
  61 +
  62 +/**
  63 + * @param {unknown} value
  64 + * @param {string} field
  65 + */
  66 +function requiredString(value, field) {
  67 + if (typeof value === 'string' && value.trim().length > 0) return value
  68 + throw new Error(`recordEmailDelivery requires ${field}`)
  69 +}
apps/voko-server/src/assembly/email.js 0 → 100644
  1 +/**
  2 + * 发信端口:验证码(auth 的 EmailPort.sendCode)与加入申请通过通知。
  3 + * 每次投递写入 email_delivery_logs。本文件不读 `process.env`,配置经 env 注入。
  4 + */
  5 +
  6 +import { createEmail } from '@drama/email'
  7 +import { recordEmailDelivery } from './email-delivery-log.js'
  8 +
  9 +const EMAIL_LOCALES = Object.freeze(['en', 'zh', 'ja', 'ko', 'es', 'pt'])
  10 +
  11 +/**
  12 + * @param {{ env: import('../env.js').VokoEnv, db: import('knex').Knex, logger: { error: Function } }} deps
  13 + */
  14 +export function createEmailPorts({ env, db, logger }) {
  15 + const { host, port, secure, user, pass, from } = env.email
  16 + const email = createEmail({
  17 + provider: 'nodemailer',
  18 + credentials: { host, port, secure, auth: { user, pass }, defaultFrom: from },
  19 + })
  20 +
  21 + /** @type {import('@drama/auth').EmailPort} */
  22 + const authEmail = {
  23 + async sendCode({ to, email: address, code, purpose, locale }) {
  24 + const toEmail = to || address
  25 + const result = await email.sendTemplate('verify-code', {
  26 + to: toEmail,
  27 + from,
  28 + code,
  29 + ttlMin: 10,
  30 + locale: emailLocale(locale),
  31 + })
  32 + await recordEmailDelivery(db, {
  33 + provider: result.provider || 'nodemailer',
  34 + messageId: result.messageId || null,
  35 + purpose: purpose || 'verify',
  36 + toEmail,
  37 + status: 'sent',
  38 + template: 'verify-code',
  39 + metadata: env.nodeEnv === 'production' ? null : { code },
  40 + })
  41 + },
  42 + }
  43 +
  44 + const accessNotifier = {
  45 + /**
  46 + * 申请通过通知。暂复用 waitlist-invite 模板(文案为"邀请已就绪,完成注册"),链接指向登录页。
  47 + * @param {object} _ctx
  48 + * @param {{ to_email: string }} input
  49 + */
  50 + async sendApproved(_ctx, { to_email }) {
  51 + try {
  52 + const result = await email.sendTemplate('waitlist-invite', {
  53 + to: to_email,
  54 + from,
  55 + locale: 'en',
  56 + inviteUrl: `${env.publicUrl}/login`,
  57 + })
  58 + await recordEmailDelivery(db, {
  59 + provider: result.provider || 'nodemailer',
  60 + messageId: result.message_id || null,
  61 + purpose: 'access_approved',
  62 + toEmail: to_email,
  63 + status: 'sent',
  64 + template: 'waitlist-invite',
  65 + })
  66 + } catch (err) {
  67 + // 外部 IO 的最小包裹:审核结果已提交,通知失败只记录,不回滚审核。
  68 + const errorMessage = err instanceof Error ? err.message : String(err)
  69 + logger.error('申请通过通知投递失败', { to_email, error: errorMessage })
  70 + await recordEmailDelivery(db, {
  71 + provider: 'nodemailer',
  72 + purpose: 'access_approved',
  73 + toEmail: to_email,
  74 + status: 'failed',
  75 + template: 'waitlist-invite',
  76 + errorMessage,
  77 + })
  78 + }
  79 + },
  80 + }
  81 +
  82 + return { authEmail, accessNotifier }
  83 +}
  84 +
  85 +/**
  86 + * @param {unknown} value
  87 + * @returns {string}
  88 + */
  89 +function emailLocale(value) {
  90 + return EMAIL_LOCALES.includes(/** @type {string} */ (value)) ? /** @type {string} */ (value) : 'en'
  91 +}
apps/voko-server/src/assembly/google-oauth.js 0 → 100644
  1 +import { google } from 'googleapis'
  2 +
  3 +/**
  4 + * @param {{ clientId: string, clientSecret: string|null }} config
  5 + * @returns {import('@drama/auth').OauthPort}
  6 + */
  7 +export function createGoogleOauthPort({ clientId, clientSecret }) {
  8 + if (!clientId) throw new Error('createGoogleOauthPort: clientId is required')
  9 + const client = new google.auth.OAuth2(clientId, clientSecret || undefined)
  10 + return {
  11 + async exchange({ provider, code, oauth_flow, redirect_uri }) {
  12 + if (provider !== 'google') throw new Error(`Unsupported OAuth provider: ${provider}`)
  13 + if (oauth_flow === 'authorization_code') {
  14 + if (!clientSecret) throw new Error('Google OAuth authorization code flow requires GOOGLE_OAUTH_CLIENT_SECRET')
  15 + if (!redirect_uri) throw new Error('Google OAuth authorization code flow requires redirect_uri')
  16 + const { tokens } = await client.getToken({ code, redirect_uri })
  17 + if (!tokens.id_token) throw new Error('Google OAuth token response is missing id_token')
  18 + const ticket = await client.verifyIdToken({ idToken: tokens.id_token, audience: clientId })
  19 + const payload = ticket.getPayload()
  20 + if (!payload?.sub || !payload.email) throw new Error('Google ID token is missing account identity')
  21 + return {
  22 + provider_user_id: payload.sub,
  23 + email: payload.email,
  24 + email_verified: payload.email_verified === true,
  25 + }
  26 + }
  27 + if (oauth_flow !== 'access_token') throw new Error(`Unsupported Google OAuth flow: ${oauth_flow}`)
  28 + const tokenInfo = await client.getTokenInfo(code)
  29 + if (tokenInfo.aud !== clientId) throw new Error('Google access token audience does not match client ID')
  30 + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
  31 + headers: { authorization: `Bearer ${code}` },
  32 + })
  33 + if (!response.ok) throw new Error(`Google userinfo request failed: HTTP ${response.status}`)
  34 + const payload = await response.json()
  35 + if (!payload.sub || !payload.email) throw new Error('Google userinfo is missing account identity')
  36 + return {
  37 + provider_user_id: payload.sub,
  38 + email: payload.email,
  39 + email_verified: payload.email_verified === true,
  40 + }
  41 + },
  42 + }
  43 +}
apps/voko-server/src/assembly/runtime.js 0 → 100644
  1 +/**
  2 + * voko-server 的 composition root:读取 env、创建连接、装配服务,并统一管理资源生命周期。
  3 + */
  4 +
  5 +import { fileURLToPath } from 'node:url'
  6 +import { config as loadDotenv } from 'dotenv'
  7 +import { createAuth, createUserRepo } from '@drama/auth'
  8 +import { createDb } from '@drama/db'
  9 +import { createLogger } from '@drama/observability'
  10 +import { createAccessRequests } from '@voko/access'
  11 +
  12 +import { readEnv } from '../env.js'
  13 +import { createEmailPorts } from './email.js'
  14 +import { createGoogleOauthPort } from './google-oauth.js'
  15 +import { createDevOauthPort, createHmacTokenSigner, createScryptHasher } from './security.js'
  16 +
  17 +/**
  18 + * @param {{ service: string }} opts
  19 + */
  20 +export async function createRuntime({ service }) {
  21 + // 1. 加载 .env 并解析配置。
  22 + loadDotenv({ path: fileURLToPath(new URL('../../.env', import.meta.url)) })
  23 + const env = readEnv(process.env)
  24 + const logger = createLogger({
  25 + service,
  26 + level: env.logLevel,
  27 + format: env.nodeEnv === 'production' ? 'json' : 'pretty',
  28 + })
  29 +
  30 + // 2. 连接与基础端口。
  31 + const db = createDb({ url: env.databaseUrl })
  32 + const { authEmail, accessNotifier } = createEmailPorts({ env, db, logger })
  33 + const tokenSigner = createHmacTokenSigner({ secret: env.authTokenSecret })
  34 + const hasher = createScryptHasher()
  35 + const oauth = env.oauth.googleClientId === null
  36 + ? createDevOauthPort()
  37 + : createGoogleOauthPort({ clientId: env.oauth.googleClientId, clientSecret: env.oauth.googleClientSecret })
  38 +
  39 + // 3. 领域服务。
  40 + const userRepo = createUserRepo(db)
  41 + const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher })
  42 + const access = createAccessRequests({ db, notifier: accessNotifier })
  43 +
  44 + /**
  45 + * 注册:验证码校验与建用户在同一事务里完成。
  46 + * @param {{ email: string, password: string, verification_code: string, locale: string }} input
  47 + */
  48 + async function register(input) {
  49 + return db.transaction(async (trx) => {
  50 + const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher })
  51 + await trxAuth.verifyCode({ email: input.email, code: input.verification_code, purpose: 'register' })
  52 + return trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() })
  53 + })
  54 + }
  55 +
  56 + return {
  57 + env,
  58 + logger,
  59 + services: { db, auth, userRepo, access, register, oauthEnabled: env.oauth.googleClientId !== null },
  60 + async close() {
  61 + await db.destroy()
  62 + },
  63 + }
  64 +}
apps/voko-server/src/assembly/security.js 0 → 100644
  1 +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
  2 +
  3 +/**
  4 + * @typedef {Error & { code?: string }} CodedError
  5 + * @typedef {{ logger?: { info?: (...args: any[]) => void } }} DevEmailArgs
  6 + * @typedef {{ email?: string, to?: string, code?: string, purpose?: string }} EmailCodeArgs
  7 + */
  8 +
  9 +/**
  10 + * @param {string|Buffer} value
  11 + * @returns {string}
  12 + */
  13 +function b64url(value) {
  14 + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')
  15 + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  16 +}
  17 +
  18 +/**
  19 + * @param {string} value
  20 + * @returns {Buffer}
  21 + */
  22 +function b64urlDecode(value) {
  23 + const pad = value.length % 4 === 0 ? '' : '='.repeat(4 - (value.length % 4))
  24 + return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64')
  25 +}
  26 +
  27 +/**
  28 + * @param {string} message
  29 + * @param {string} code
  30 + * @returns {CodedError}
  31 + */
  32 +function codedError(message, code) {
  33 + const err = /** @type {CodedError} */ (new Error(message))
  34 + err.code = code
  35 + return err
  36 +}
  37 +
  38 +export function createScryptHasher() {
  39 + const keyLength = 64
  40 + return {
  41 + /**
  42 + * @param {string} plain
  43 + * @returns {Promise<string>}
  44 + */
  45 + async hash(plain) {
  46 + const salt = randomBytes(16)
  47 + const derived = scryptSync(String(plain), salt, keyLength)
  48 + return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`
  49 + },
  50 + /**
  51 + * @param {string} plain
  52 + * @param {string} stored
  53 + * @returns {Promise<boolean>}
  54 + */
  55 + async verify(plain, stored) {
  56 + if (typeof stored !== 'string') return false
  57 + const parts = stored.split('$')
  58 + if (parts.length !== 3 || parts[0] !== 'scrypt') return false
  59 + const salt = Buffer.from(parts[1], 'hex')
  60 + const expected = Buffer.from(parts[2], 'hex')
  61 + const derived = scryptSync(String(plain), salt, expected.length)
  62 + return expected.length === derived.length && timingSafeEqual(expected, derived)
  63 + },
  64 + }
  65 +}
  66 +
  67 +/**
  68 + * @param {{ secret: string }} args
  69 + * @returns {import('@drama/auth').TokenSigner}
  70 + */
  71 +export function createHmacTokenSigner({ secret }) {
  72 + if (!secret || typeof secret !== 'string') throw new Error('createHmacTokenSigner: secret required')
  73 + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
  74 +
  75 + /**
  76 + * @param {string} input
  77 + * @returns {string}
  78 + */
  79 + function signature(input) {
  80 + return b64url(createHmac('sha256', secret).update(input).digest())
  81 + }
  82 +
  83 + return {
  84 + /**
  85 + * @param {object} payload
  86 + * @param {{ ttlMs: number }} opts
  87 + * @returns {Promise<string>}
  88 + */
  89 + async sign(payload, opts) {
  90 + const nowSec = Math.floor(Date.now() / 1000)
  91 + const claims = /** @type {Record<string, unknown>} */ ({ ...payload, iat: nowSec })
  92 + if (opts?.ttlMs && opts.ttlMs > 0) claims.exp = nowSec + Math.floor(opts.ttlMs / 1000)
  93 + const body = b64url(JSON.stringify(claims))
  94 + const input = `${header}.${body}`
  95 + return `${input}.${signature(input)}`
  96 + },
  97 + /**
  98 + * @param {string} token
  99 + * @returns {Promise<object>}
  100 + */
  101 + async verify(token) {
  102 + if (typeof token !== 'string') throw codedError('token must be a string', 'TOKEN_INVALID')
  103 + const parts = token.split('.')
  104 + if (parts.length !== 3) throw codedError('malformed token', 'TOKEN_INVALID')
  105 + const [headerPart, bodyPart, sig] = parts
  106 + const expected = signature(`${headerPart}.${bodyPart}`)
  107 + const a = b64urlDecode(sig)
  108 + const b = b64urlDecode(expected)
  109 + if (a.length !== b.length || !timingSafeEqual(a, b)) throw codedError('bad signature', 'TOKEN_INVALID')
  110 + const claims = /** @type {Record<string, unknown>} */ (JSON.parse(b64urlDecode(bodyPart).toString('utf8')))
  111 + if (typeof claims.exp === 'number' && Math.floor(Date.now() / 1000) >= claims.exp) {
  112 + throw codedError('token expired', 'ERR_JWT_EXPIRED')
  113 + }
  114 + return claims
  115 + },
  116 + }
  117 +}
  118 +
  119 +export function createDevOauthPort() {
  120 + return {
  121 + async exchange() {
  122 + throw new Error('[voko] oauth.exchange not configured: set GOOGLE_OAUTH_CLIENT_ID')
  123 + },
  124 + }
  125 +}
apps/voko-server/src/db/env-file.js 0 → 100644
  1 +import fs from 'node:fs'
  2 +import path from 'node:path'
  3 +import { fileURLToPath } from 'node:url'
  4 +import dotenv from 'dotenv'
  5 +import { devMysqlUrl } from '@drama/db'
  6 +
  7 +const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
  8 +
  9 +/**
  10 + * 数据库命令读取 `--env <file>`(默认 apps/voko-server/.env)里的 DATABASE_URL;
  11 + * 文件不存在或未设置时使用本地开发库 voko。
  12 + * @param {string[]} args
  13 + */
  14 +export function databaseUrlFromArgs(args) {
  15 + const envIndex = args.indexOf('--env')
  16 + const file = envIndex === -1 ? path.join(appRoot, '.env') : path.resolve(args[envIndex + 1])
  17 + if (envIndex !== -1 && !fs.existsSync(file)) throw new Error(`--env file not found: ${file}`)
  18 + const env = fs.existsSync(file) ? dotenv.parse(fs.readFileSync(file)) : {}
  19 + return env.DATABASE_URL || devMysqlUrl('voko')
  20 +}
apps/voko-server/src/db/init-current-schema.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 只对确认为空的新库执行:按当前结构建表、建余额视图,并把现有 migration 记为已执行。
  4 + */
  5 +import { recreateBillingViews } from '@drama/billing'
  6 +import { applyCurrentSchema, createDb } from '@drama/db'
  7 +
  8 +import { databaseUrlFromArgs } from './env-file.js'
  9 +import { migrationTableName, vokoMigrationConfig } from './migration-config.js'
  10 +import { vokoTargetTables } from './schema.js'
  11 +
  12 +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } })
  13 +
  14 +try {
  15 + const [rows] = await db.raw('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() LIMIT 1')
  16 + if (rows.length > 0) throw new Error(`voko database is not empty: ${rows[0].TABLE_NAME}`)
  17 + const createdTables = await applyCurrentSchema(db, vokoTargetTables)
  18 + await recreateBillingViews(db)
  19 + const [, pending] = await db.migrate.list(vokoMigrationConfig)
  20 + const baselined = pending.map((migration) => migration.file)
  21 + if (baselined.length > 0) {
  22 + const now = new Date()
  23 + await db(migrationTableName).insert(baselined.map((name) => ({ name, batch: 1, migration_time: now })))
  24 + }
  25 + console.log(JSON.stringify({ ok: true, target: '@voko/server', mode: 'init-current-schema', created_tables: createdTables, baselined_migrations: baselined }, null, 2))
  26 +} finally {
  27 + await db.destroy()
  28 +}
apps/voko-server/src/db/migrate-database.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +import { createDb } from '@drama/db'
  3 +
  4 +import { databaseUrlFromArgs } from './env-file.js'
  5 +import { vokoMigrationConfig } from './migration-config.js'
  6 +
  7 +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } })
  8 +
  9 +try {
  10 + const [batch, executed] = await db.migrate.latest(vokoMigrationConfig)
  11 + console.log(JSON.stringify({ ok: true, target: '@voko/server', mode: 'migrate', batch, executed }, null, 2))
  12 +} finally {
  13 + await db.destroy()
  14 +}
apps/voko-server/src/db/migration-config.js 0 → 100644
  1 +import path from 'node:path'
  2 +import { fileURLToPath } from 'node:url'
  3 +
  4 +const here = path.dirname(fileURLToPath(import.meta.url))
  5 +
  6 +export const migrationTableName = 'voko_schema_migrations'
  7 +export const vokoMigrationConfig = {
  8 + directory: path.join(here, 'migrations'),
  9 + tableName: migrationTableName,
  10 + loadExtensions: ['.js'],
  11 +}
apps/voko-server/src/db/migration-status.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +import { createDb } from '@drama/db'
  3 +
  4 +import { databaseUrlFromArgs } from './env-file.js'
  5 +import { vokoMigrationConfig } from './migration-config.js'
  6 +
  7 +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } })
  8 +
  9 +try {
  10 + const [completed, pending] = await db.migrate.list(vokoMigrationConfig)
  11 + console.log(JSON.stringify({
  12 + ok: true,
  13 + target: '@voko/server',
  14 + completed: completed.map((migration) => migration.name),
  15 + pending: pending.map((migration) => migration.file),
  16 + }, null, 2))
  17 +} finally {
  18 + await db.destroy()
  19 +}
apps/voko-server/src/db/migrations/.gitkeep 0 → 100644
apps/voko-server/src/db/schema.js 0 → 100644
  1 +import { authDbSchema } from '@drama/auth'
  2 +import { billingDbSchema } from '@drama/billing'
  3 +import { dbSchema } from '@drama/db'
  4 +import { emailDbSchema } from '@drama/email'
  5 +import { accessDbSchema } from '@voko/access'
  6 +
  7 +/** voko 库的当前完整结构(新空库 init 时按此建表)。 */
  8 +export const vokoTargetTables = [
  9 + ...dbSchema.targetTables,
  10 + ...authDbSchema.targetTables,
  11 + ...billingDbSchema.targetTables,
  12 + ...emailDbSchema.targetTables,
  13 + ...accessDbSchema.targetTables,
  14 +]
apps/voko-server/src/env.js 0 → 100644
  1 +/**
  2 + * voko-server 连接与基础设施配置的唯一解析点。业务运行参数由所属模块直接读取。
  3 + */
  4 +
  5 +import { devMysqlUrl } from '@drama/db'
  6 +
  7 +/**
  8 + * @typedef {{
  9 + * nodeEnv: string,
  10 + * port: number,
  11 + * databaseUrl: string,
  12 + * logLevel: 'debug'|'info'|'warn'|'error',
  13 + * authTokenSecret: string,
  14 + * publicUrl: string,
  15 + * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string },
  16 + * oauth: { googleClientId: string|null, googleClientSecret: string|null },
  17 + * }} VokoEnv
  18 + */
  19 +
  20 +/**
  21 + * @param {Record<string, string|undefined>} rawEnv
  22 + * @returns {VokoEnv}
  23 + */
  24 +export function readEnv(rawEnv) {
  25 + const nodeEnv = rawEnv.NODE_ENV || 'development'
  26 + const databaseUrl = rawEnv.DATABASE_URL || devMysqlUrl('voko')
  27 + const scheme = new URL(databaseUrl).protocol
  28 + if (scheme !== 'mysql:' && scheme !== 'mysql2:') throw new Error(`voko-server: DATABASE_URL 必须是 MySQL,收到 ${scheme}`)
  29 + if (nodeEnv === 'production' && !rawEnv.AUTH_TOKEN_SECRET) throw new Error('voko-server: 生产环境必须设置 AUTH_TOKEN_SECRET')
  30 +
  31 + const email = {
  32 + host: rawEnv.SMTP_HOST,
  33 + port: Number(rawEnv.SMTP_PORT || 465),
  34 + secure: rawEnv.SMTP_SECURE !== 'false',
  35 + user: rawEnv.SMTP_USER,
  36 + pass: rawEnv.SMTP_PASS,
  37 + from: rawEnv.EMAIL_FROM,
  38 + }
  39 + const missing = [['SMTP_HOST', email.host], ['SMTP_USER', email.user], ['SMTP_PASS', email.pass], ['EMAIL_FROM', email.from]]
  40 + .filter(([, value]) => !value)
  41 + .map(([name]) => name)
  42 + if (missing.length > 0) throw new Error(`voko-server: 邮件缺少配置,拒绝启动(无 fake 降级):${missing.join(', ')}`)
  43 +
  44 + return {
  45 + nodeEnv,
  46 + port: parsePort(rawEnv.PORT, 9201),
  47 + databaseUrl,
  48 + logLevel: parseLogLevel(rawEnv.LOG_LEVEL),
  49 + authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret',
  50 + publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''),
  51 + email: /** @type {VokoEnv['email']} */ (email),
  52 + oauth: {
  53 + googleClientId: rawEnv.GOOGLE_OAUTH_CLIENT_ID || null,
  54 + googleClientSecret: rawEnv.GOOGLE_OAUTH_CLIENT_SECRET || null,
  55 + },
  56 + }
  57 +}
  58 +
  59 +/**
  60 + * @param {string|undefined} raw
  61 + * @param {number} fallback
  62 + */
  63 +function parsePort(raw, fallback) {
  64 + if (raw === undefined || raw === '') return fallback
  65 + const port = Number(raw)
  66 + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`voko-server: PORT 非法:${raw}`)
  67 + return port
  68 +}
  69 +
  70 +/**
  71 + * @param {string|undefined} raw
  72 + * @returns {'debug'|'info'|'warn'|'error'}
  73 + */
  74 +function parseLogLevel(raw) {
  75 + const level = raw || 'info'
  76 + switch (level) {
  77 + case 'debug':
  78 + case 'info':
  79 + case 'warn':
  80 + case 'error':
  81 + return level
  82 + default:
  83 + throw new Error(`voko-server: LOG_LEVEL 非法:${level}`)
  84 + }
  85 +}
apps/voko-server/src/main.js 0 → 100644
  1 +/**
  2 + * `@voko/server` 进程入口:runtime → app → listen → close。
  3 + */
  4 +
  5 +import { buildApp } from './app.js'
  6 +import { createRuntime } from './assembly/runtime.js'
  7 +
  8 +async function start() {
  9 + const runtime = await createRuntime({ service: '@voko/server' })
  10 + const app = buildApp({ services: runtime.services, logger: runtime.logger })
  11 + await app.listen({ port: runtime.env.port, host: '0.0.0.0' })
  12 + runtime.logger.info('服务已启动', { port: runtime.env.port, environment: runtime.env.nodeEnv })
  13 + return {
  14 + async close() {
  15 + await app.close()
  16 + await runtime.close()
  17 + },
  18 + }
  19 +}
  20 +
  21 +start()
  22 + .then(({ close }) => {
  23 + const stop = async (signal) => {
  24 + try {
  25 + await close()
  26 + process.exit(0)
  27 + } catch (err) {
  28 + console.error(`voko-server failed to stop after ${signal}:`, err)
  29 + process.exit(1)
  30 + }
  31 + }
  32 + process.once('SIGINT', () => stop('SIGINT'))
  33 + process.once('SIGTERM', () => stop('SIGTERM'))
  34 + })
  35 + .catch((err) => {
  36 + console.error('voko-server failed to start:', err)
  37 + process.exit(1)
  38 + })
apps/voko-server/src/routes/access.js 0 → 100644
  1 +import * as v from 'valibot'
  2 +
  3 +import { codedError, requireUser } from './auth.js'
  4 +
  5 +const SubmitBody = v.object({ note: v.optional(v.pipe(v.string(), v.maxLength(1000))) })
  6 +const ListQuery = v.object({
  7 + status: v.optional(v.picklist(['pending', 'approved', 'rejected']), 'pending'),
  8 + limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(200)), '50'),
  9 +})
  10 +const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) })
  11 +const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) })
  12 +
  13 +/**
  14 + * 加入申请(申请制):用户提交申请并查看排队状态;运营在 /admin 人工审核。
  15 + * @param {import('fastify').FastifyInstance} app
  16 + * @param {Record<string, any>} services
  17 + */
  18 +export function registerAccessRoutes(app, services) {
  19 + const { access } = services
  20 +
  21 + app.get('/api/voko/me', async (request) => {
  22 + const user = await requireUser(request, services)
  23 + return { user, access_request: await access.getForUser(request.ctx, { user_id: user.id }) }
  24 + })
  25 +
  26 + app.post('/api/voko/access-requests', async (request) => {
  27 + const user = await requireUser(request, services)
  28 + const body = v.parse(SubmitBody, request.body || {})
  29 + return { access_request: await access.submit(request.ctx, { user_id: user.id, email: user.email, note: body.note ?? null }) }
  30 + })
  31 +
  32 + app.get('/api/voko/admin/access-requests', async (request) => {
  33 + await requireAdmin(request, services)
  34 + const query = v.parse(ListQuery, request.query)
  35 + return { access_requests: await access.list(request.ctx, { status: query.status, limit: query.limit }) }
  36 + })
  37 +
  38 + app.post('/api/voko/admin/access-requests/:id/review', async (request) => {
  39 + const admin = await requireAdmin(request, services)
  40 + const params = v.parse(ReviewParams, request.params)
  41 + const body = v.parse(ReviewBody, request.body)
  42 + const result = await access.review(request.ctx, { request_id: params.id, reviewer_user_id: admin.id, decision: body.decision })
  43 + if (!result.ok) throw codedError(`Access request cannot be reviewed: ${params.id}`, result.code)
  44 + return { access_request: result.request }
  45 + })
  46 +}
  47 +
  48 +/**
  49 + * @param {import('fastify').FastifyRequest} request
  50 + * @param {Record<string, any>} services
  51 + */
  52 +async function requireAdmin(request, services) {
  53 + const user = await requireUser(request, services)
  54 + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN')
  55 + return user
  56 +}
apps/voko-server/src/routes/auth.js 0 → 100644
  1 +import * as v from 'valibot'
  2 +
  3 +const SendCodeBody = v.object({
  4 + email: v.pipe(v.string(), v.email()),
  5 + locale: v.optional(v.string()),
  6 +})
  7 +
  8 +const RegisterBody = v.object({
  9 + email: v.pipe(v.string(), v.email()),
  10 + password: v.pipe(v.string(), v.minLength(8)),
  11 + verification_code: v.pipe(v.string(), v.minLength(1)),
  12 + locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']),
  13 +})
  14 +
  15 +const LoginBody = v.variant('method', [
  16 + v.object({ method: v.literal('password'), email: v.pipe(v.string(), v.email()), password: v.pipe(v.string(), v.minLength(1)) }),
  17 + v.object({
  18 + method: v.literal('google'),
  19 + credential: v.pipe(v.string(), v.minLength(1)),
  20 + oauth_flow: v.picklist(['access_token', 'authorization_code']),
  21 + redirect_uri: v.optional(v.string()),
  22 + }),
  23 +])
  24 +
  25 +const RefreshBody = v.object({ refresh_token: v.pipe(v.string(), v.minLength(1)) })
  26 +
  27 +/**
  28 + * 账号:邮箱验证码注册 + 密码登录,或 Google 登录(配置了 GOOGLE_OAUTH_CLIENT_ID 时)。
  29 + * @param {import('fastify').FastifyInstance} app
  30 + * @param {Record<string, any>} services
  31 + */
  32 +export function registerAuthRoutes(app, services) {
  33 + const { auth } = services
  34 +
  35 + app.post('/api/voko/auth/send-code', async (request) => {
  36 + const body = v.parse(SendCodeBody, request.body)
  37 + await auth.sendCode({ email: body.email, purpose: 'register', locale: body.locale, ip: request.ip })
  38 + return { ok: true }
  39 + })
  40 +
  41 + app.post('/api/voko/auth/register', async (request) => {
  42 + const body = v.parse(RegisterBody, request.body)
  43 + return sessionResponse(await services.register(body))
  44 + })
  45 +
  46 + app.post('/api/voko/auth/login', async (request) => {
  47 + const body = v.parse(LoginBody, request.body)
  48 + switch (body.method) {
  49 + case 'password':
  50 + return sessionResponse(await auth.login({ email: body.email, password: body.password, ip: request.ip }))
  51 + case 'google':
  52 + if (!services.oauthEnabled) throw codedError('Google login is not configured', 'BAD_REQUEST')
  53 + return sessionResponse(await auth.oauthLogin({ provider: 'google', code: body.credential, oauth_flow: body.oauth_flow, redirect_uri: body.redirect_uri }))
  54 + default:
  55 + throw new Error(`Unhandled login method: ${/** @type {any} */ (body).method}`)
  56 + }
  57 + })
  58 +
  59 + app.post('/api/voko/auth/refresh', async (request) => {
  60 + const body = v.parse(RefreshBody, request.body)
  61 + return sessionResponse(await auth.refresh(body))
  62 + })
  63 +
  64 + app.post('/api/voko/auth/logout', async (request) => {
  65 + const body = v.parse(RefreshBody, request.body)
  66 + return auth.logout(body)
  67 + })
  68 +}
  69 +
  70 +/**
  71 + * @param {{ access_token: string, refresh_token: string, user: { id: string, email: string } }} result
  72 + */
  73 +function sessionResponse(result) {
  74 + return {
  75 + access_token: result.access_token,
  76 + refresh_token: result.refresh_token,
  77 + user: { id: result.user.id, email: result.user.email },
  78 + }
  79 +}
  80 +
  81 +/**
  82 + * @param {string} message
  83 + * @param {string} code
  84 + */
  85 +export function codedError(message, code) {
  86 + const err = /** @type {Error & { code: string }} */ (new Error(message))
  87 + err.code = code
  88 + return err
  89 +}
  90 +
  91 +/**
  92 + * 从 Bearer access token 解析当前用户;缺失或无效即 401。
  93 + * @param {import('fastify').FastifyRequest} request
  94 + * @param {Record<string, any>} services
  95 + * @returns {Promise<{ id: string, email: string, role: string|null }>}
  96 + */
  97 +export async function requireUser(request, services) {
  98 + const header = request.headers.authorization
  99 + const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim()) : null
  100 + if (match === null) throw codedError('Authorization bearer token is required', 'TOKEN_INVALID')
  101 + const claims = await services.auth.verifyAccessToken(match[1])
  102 + const user = await services.userRepo.findById(String(claims.sub))
  103 + if (!user) throw codedError(`User not found: ${claims.sub}`, 'TOKEN_INVALID')
  104 + return { id: user.id, email: user.email, role: user.role === undefined ? null : user.role }
  105 +}
apps/voko-server/src/voko-worker-main.js 0 → 100644
  1 +/**
  2 + * `voko-worker` 进程入口:领取并执行 vk_jobs(W2 起实现)。骨架阶段只做心跳,证明进程可被 PM2 托管。
  3 + */
  4 +
  5 +import { createRuntime } from './assembly/runtime.js'
  6 +
  7 +const TICK_MS = 2000
  8 +const HEARTBEAT_EVERY_TICKS = 30
  9 +
  10 +const runtime = await createRuntime({ service: '@voko/worker' })
  11 +let ticks = 0
  12 +let stopping = false
  13 +
  14 +async function tick() {
  15 + // W2:在这里用 FOR UPDATE SKIP LOCKED 领取到期 job(见 SDD「定时任务与事件」)。
  16 + await runtime.services.db.raw('select 1')
  17 + ticks += 1
  18 + if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks })
  19 +}
  20 +
  21 +async function loop() {
  22 + while (!stopping) {
  23 + await tick()
  24 + await new Promise((resolve) => setTimeout(resolve, TICK_MS))
  25 + }
  26 + await runtime.close()
  27 +}
  28 +
  29 +runtime.logger.info('worker 已启动', { tick_ms: TICK_MS })
  30 +process.once('SIGINT', () => { stopping = true })
  31 +process.once('SIGTERM', () => { stopping = true })
  32 +loop().catch((err) => {
  33 + console.error('voko-worker crashed:', err)
  34 + process.exit(1)
  35 +})
apps/voko-server/tools/grant-admin.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 把一个已注册用户设为运营管理员(role='admin'),用于登录 /admin 审核加入申请。
  4 + * 用法:pnpm --filter @voko/server admin:grant you@example.com
  5 + */
  6 +import { createDb } from '@drama/db'
  7 +
  8 +import { databaseUrlFromArgs } from '../src/db/env-file.js'
  9 +
  10 +const email = process.argv[2]
  11 +if (!email || email.startsWith('--')) throw new Error('usage: admin:grant <email> [--env file]')
  12 +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(3)), pool: { min: 1, max: 1 } })
  13 +
  14 +try {
  15 + const updated = await db('users').where({ email: email.trim().toLowerCase() }).update({ role: 'admin', updated_at: new Date() })
  16 + if (updated !== 1) throw new Error(`user not found: ${email}`)
  17 + console.log(JSON.stringify({ ok: true, email, role: 'admin' }))
  18 +} finally {
  19 + await db.destroy()
  20 +}
apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 初始化清单「验收」的真实链路脚本:注册 → 申请 → 运营审核 → 积分记账与幂等。
  4 + *
  5 + * 前提:voko-server 已启动(VOKO_API,默认 http://localhost:9201),.env 已配置真实 SMTP;
  6 + * VOKO_TEST_EMAIL:一个能收信的邮箱,脚本用 plus 地址注册新用户(如 you+voko-xxx@gmail.com);
  7 + * VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD:一个已用 admin:grant 设为管理员的账号。
  8 + * 缺任一项输出 BLOCKED 并以 2 退出,不用替身补绿。
  9 + * 结束时只清理本脚本创建的数据。
  10 + */
  11 +import { randomUUID } from 'node:crypto'
  12 +import { createBillingRepo, createLedger } from '@drama/billing'
  13 +import { createDb } from '@drama/db'
  14 +
  15 +import { databaseUrlFromArgs } from '../../../src/db/env-file.js'
  16 +
  17 +const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '')
  18 +const missing = ['VOKO_TEST_EMAIL', 'VOKO_ADMIN_EMAIL', 'VOKO_ADMIN_PASSWORD'].filter((name) => !process.env[name])
  19 +if (missing.length > 0) {
  20 + console.log(JSON.stringify({ status: 'BLOCKED', missing }))
  21 + process.exit(2)
  22 +}
  23 +
  24 +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 2 } })
  25 +const [local, domain] = String(process.env.VOKO_TEST_EMAIL).split('@')
  26 +const email = `${local}+voko-skeleton-${Date.now()}@${domain}`.toLowerCase()
  27 +const results = []
  28 +let userId = null
  29 +
  30 +/**
  31 + * @param {string} name
  32 + * @param {boolean} pass
  33 + * @param {unknown} [detail]
  34 + */
  35 +function check(name, pass, detail) {
  36 + results.push({ name, pass, ...(pass ? {} : { detail }) })
  37 + if (!pass) throw new Error(`FAILED: ${name} ${JSON.stringify(detail)}`)
  38 +}
  39 +
  40 +/**
  41 + * @param {string} method
  42 + * @param {string} path
  43 + * @param {{ body?: unknown, token?: string }} [opts]
  44 + */
  45 +async function call(method, path, opts = {}) {
  46 + const response = await fetch(`${api}${path}`, {
  47 + method,
  48 + headers: {
  49 + ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }),
  50 + ...(opts.token === undefined ? {} : { authorization: `Bearer ${opts.token}` }),
  51 + },
  52 + body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
  53 + })
  54 + return { status: response.status, json: await response.json() }
  55 +}
  56 +
  57 +try {
  58 + const health = await call('GET', '/healthz')
  59 + check('healthz', health.status === 200 && health.json.ok === true, health)
  60 +
  61 + const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } })
  62 + check('send-code', sent.status === 200, sent)
  63 + const log = await db('email_delivery_logs').where({ to_email: email, template: 'verify-code', status: 'sent' }).orderBy('created_at', 'desc').first()
  64 + check('验证码邮件已投递并记录', log !== undefined, { email })
  65 + const code = JSON.parse(log.metadata_json).code
  66 +
  67 + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en' } })
  68 + check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered)
  69 + userId = registered.json.user.id
  70 + const token = registered.json.access_token
  71 +
  72 + const me0 = await call('GET', '/api/voko/me', { token })
  73 + check('新用户没有申请', me0.status === 200 && me0.json.access_request === null, me0)
  74 +
  75 + const submitted = await call('POST', '/api/voko/access-requests', { token, body: { note: 'skeleton scenario' } })
  76 + check('提交申请 → pending', submitted.json.access_request.status === 'pending', submitted)
  77 + const again = await call('POST', '/api/voko/access-requests', { token, body: {} })
  78 + check('重复提交返回同一申请', again.json.access_request.id === submitted.json.access_request.id, again)
  79 +
  80 + const forbidden = await call('GET', '/api/voko/admin/access-requests', { token })
  81 + check('普通用户访问后台 → 403', forbidden.status === 403, forbidden)
  82 +
  83 + const admin = await call('POST', '/api/voko/auth/login', { body: { method: 'password', email: process.env.VOKO_ADMIN_EMAIL, password: process.env.VOKO_ADMIN_PASSWORD } })
  84 + check('admin login', admin.status === 200, admin)
  85 + const queue = await call('GET', '/api/voko/admin/access-requests?status=pending&limit=200', { token: admin.json.access_token })
  86 + check('排队列表里有这条申请', queue.json.access_requests.some((r) => r.id === submitted.json.access_request.id), queue.json)
  87 +
  88 + const approved = await call('POST', `/api/voko/admin/access-requests/${submitted.json.access_request.id}/review`, { token: admin.json.access_token, body: { decision: 'approved' } })
  89 + check('审核通过', approved.status === 200 && approved.json.access_request.status === 'approved', approved)
  90 + const twice = await call('POST', `/api/voko/admin/access-requests/${submitted.json.access_request.id}/review`, { token: admin.json.access_token, body: { decision: 'rejected' } })
  91 + check('重复审核 → 409', twice.status === 409, twice)
  92 + const notice = await db('email_delivery_logs').where({ to_email: email, purpose: 'access_approved' }).first()
  93 + check('通过通知邮件已投递', notice !== undefined && notice.status === 'sent', notice)
  94 +
  95 + const ledger = createLedger(createBillingRepo(db))
  96 + await ledger.grant({ user_id: userId, amount: 100, source: 'admin', reason: 'admin_adjustment', note: 'skeleton scenario', idempotency_key: `skeleton:grant:${userId}` })
  97 + await ledger.deduct({ user_id: userId, amount: 30, source: 'admin', reason: 'skeleton', idempotency_key: `skeleton:deduct:${userId}` })
  98 + const replay = await ledger.deduct({ user_id: userId, amount: 30, source: 'admin', reason: 'skeleton', idempotency_key: `skeleton:deduct:${userId}` })
  99 + check('同一幂等键重放不重复扣', replay.replayed === true && replay.balance_after === 70, replay)
  100 + const view = await db('points_balance_view').where({ user_id: userId }).first()
  101 + check('余额视图 = 70', Number(view.balance) === 70, view)
  102 +
  103 + console.log(JSON.stringify({ status: 'PASS', email, results }, null, 2))
  104 +} catch (err) {
  105 + console.log(JSON.stringify({ status: 'FAIL', email, results, error: err instanceof Error ? err.message : String(err) }, null, 2))
  106 + process.exitCode = 1
  107 +} finally {
  108 + if (userId !== null) {
  109 + await db('billing_ledger').where({ user_id: userId }).del()
  110 + await db('vk_access_requests').where({ user_id: userId }).del()
  111 + await db('auth_sessions').where({ user_id: userId }).del()
  112 + await db('users').where({ id: userId }).del()
  113 + }
  114 + await db('auth_codes').where({ email }).del()
  115 + await db('email_delivery_logs').where({ to_email: email }).del()
  116 + await db.destroy()
  117 +}
apps/voko-web/index.html 0 → 100644
  1 +<!doctype html>
  2 +<html lang="en">
  3 + <head>
  4 + <meta charset="UTF-8" />
  5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6 + <title>VOKO</title>
  7 + </head>
  8 + <body>
  9 + <div id="app"></div>
  10 + <script type="module" src="/src/main.js"></script>
  11 + </body>
  12 +</html>
apps/voko-web/package.json 0 → 100644
  1 +{
  2 + "name": "@voko/web",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "scripts": {
  7 + "dev": "vite",
  8 + "build": "vite build",
  9 + "preview": "vite preview"
  10 + },
  11 + "dependencies": {
  12 + "@drama/ui": "workspace:*",
  13 + "vue": "^3.5.0",
  14 + "vue-i18n": "^11.0.0",
  15 + "vue-router": "^4.4.0"
  16 + },
  17 + "devDependencies": {
  18 + "@drama/config": "workspace:*",
  19 + "@vitejs/plugin-vue": "^5.2.0",
  20 + "vite": "^6.0.0"
  21 + }
  22 +}
apps/voko-web/src/App.vue 0 → 100644
  1 +<script setup>
  2 +import { ToastHost } from '@drama/ui'
  3 +</script>
  4 +
  5 +<template>
  6 + <main class="voko-shell">
  7 + <router-view />
  8 + <ToastHost />
  9 + </main>
  10 +</template>
  11 +
  12 +<style>
  13 +body {
  14 + margin: 0;
  15 + background: var(--drama-color-bg);
  16 + color: var(--drama-color-text);
  17 +}
  18 +.voko-shell {
  19 + max-width: 720px;
  20 + margin: 0 auto;
  21 + padding: var(--drama-space-xl) var(--drama-space-md);
  22 +}
  23 +.voko-field {
  24 + display: flex;
  25 + flex-direction: column;
  26 + gap: var(--drama-space-sm);
  27 + margin-bottom: var(--drama-space-md);
  28 +}
  29 +</style>
apps/voko-web/src/api.js 0 → 100644
  1 +/**
  2 + * voko-server 网页接口的唯一调用点。access token 存在 sessionStorage(Alpha 内部使用)。
  3 + */
  4 +
  5 +const TOKEN_KEY = 'voko.access_token'
  6 +
  7 +export function getToken() {
  8 + return sessionStorage.getItem(TOKEN_KEY)
  9 +}
  10 +
  11 +/** @param {string|null} token */
  12 +export function setToken(token) {
  13 + if (token === null) sessionStorage.removeItem(TOKEN_KEY)
  14 + else sessionStorage.setItem(TOKEN_KEY, token)
  15 +}
  16 +
  17 +/**
  18 + * @param {string} method
  19 + * @param {string} path
  20 + * @param {unknown} [body]
  21 + */
  22 +export async function api(method, path, body) {
  23 + const token = getToken()
  24 + const response = await fetch(path, {
  25 + method,
  26 + headers: {
  27 + ...(body === undefined ? {} : { 'content-type': 'application/json' }),
  28 + ...(token === null ? {} : { authorization: `Bearer ${token}` }),
  29 + },
  30 + body: body === undefined ? undefined : JSON.stringify(body),
  31 + })
  32 + const json = await response.json()
  33 + if (!response.ok) throw new Error(json.error.message)
  34 + return json
  35 +}
apps/voko-web/src/i18n/messages.js 0 → 100644
  1 +/**
  2 + * voko-web 文案。Alpha 为内部使用,先提供 en / zh;对外前补齐 ja / ko / es / pt。
  3 + */
  4 +export const messages = {
  5 + en: {
  6 + login: {
  7 + title: 'Sign in to VOKO',
  8 + registerTitle: 'Create a VOKO account',
  9 + email: 'Email',
  10 + password: 'Password',
  11 + code: 'Verification code',
  12 + sendCode: 'Send code',
  13 + codeSent: 'Code sent. Check your inbox.',
  14 + submit: 'Sign in',
  15 + register: 'Create account',
  16 + toRegister: 'No account? Register',
  17 + toLogin: 'Have an account? Sign in',
  18 + },
  19 + home: {
  20 + title: 'Your VOKO access',
  21 + notApplied: 'Apply to join. An operator reviews each request.',
  22 + note: 'Anything we should know (optional)',
  23 + apply: 'Apply to join',
  24 + admin: 'Review access requests',
  25 + status: {
  26 + pending: 'Your request is in the queue.',
  27 + approved: 'Approved. You can claim your agents.',
  28 + rejected: 'Your request was not approved.',
  29 + },
  30 + },
  31 + admin: {
  32 + title: 'Access requests',
  33 + empty: 'No pending requests.',
  34 + approve: 'Approve',
  35 + reject: 'Reject',
  36 + },
  37 + },
  38 + zh: {
  39 + login: {
  40 + title: '登录 VOKO',
  41 + registerTitle: '注册 VOKO 账号',
  42 + email: '邮箱',
  43 + password: '密码',
  44 + code: '验证码',
  45 + sendCode: '发送验证码',
  46 + codeSent: '验证码已发送,请查收邮件。',
  47 + submit: '登录',
  48 + register: '注册',
  49 + toRegister: '没有账号?去注册',
  50 + toLogin: '已有账号?去登录',
  51 + },
  52 + home: {
  53 + title: '我的 VOKO 准入',
  54 + notApplied: '提交加入申请,运营会人工审核。',
  55 + note: '补充说明(选填)',
  56 + apply: '申请加入',
  57 + admin: '审核加入申请',
  58 + status: {
  59 + pending: '你的申请正在排队。',
  60 + approved: '已通过,可以认领 Agent 了。',
  61 + rejected: '你的申请未通过。',
  62 + },
  63 + },
  64 + admin: {
  65 + title: '加入申请',
  66 + empty: '没有待审核的申请。',
  67 + approve: '通过',
  68 + reject: '拒绝',
  69 + },
  70 + },
  71 +}
apps/voko-web/src/main.js 0 → 100644
  1 +import { createApp } from 'vue'
  2 +import { createI18n } from 'vue-i18n'
  3 +import { createRouter, createWebHistory } from 'vue-router'
  4 +import '@drama/ui/theme.css'
  5 +
  6 +import App from './App.vue'
  7 +import { messages } from './i18n/messages.js'
  8 +import AdminPage from './pages/AdminPage.vue'
  9 +import HomePage from './pages/HomePage.vue'
  10 +import LoginPage from './pages/LoginPage.vue'
  11 +
  12 +const router = createRouter({
  13 + history: createWebHistory(),
  14 + routes: [
  15 + { path: '/', component: HomePage },
  16 + { path: '/login', component: LoginPage },
  17 + { path: '/admin', component: AdminPage },
  18 + ],
  19 +})
  20 +
  21 +const locale = navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
  22 +const i18n = createI18n({ legacy: false, locale, fallbackLocale: 'en', messages })
  23 +
  24 +createApp(App).use(router).use(i18n).mount('#app')
apps/voko-web/src/pages/AdminPage.vue 0 → 100644
  1 +<script setup>
  2 +import { onMounted, ref } from 'vue'
  3 +import { useI18n } from 'vue-i18n'
  4 +import { Button, Card } from '@drama/ui'
  5 +
  6 +import { api } from '../api.js'
  7 +
  8 +const { t } = useI18n()
  9 +const requests = ref([])
  10 +const error = ref('')
  11 +
  12 +async function load() {
  13 + try {
  14 + requests.value = (await api('GET', '/api/voko/admin/access-requests?status=pending')).access_requests
  15 + } catch (err) {
  16 + error.value = err instanceof Error ? err.message : String(err)
  17 + }
  18 +}
  19 +
  20 +/**
  21 + * @param {string} id
  22 + * @param {'approved'|'rejected'} decision
  23 + */
  24 +async function review(id, decision) {
  25 + await api('POST', `/api/voko/admin/access-requests/${id}/review`, { decision })
  26 + await load()
  27 +}
  28 +
  29 +onMounted(load)
  30 +</script>
  31 +
  32 +<template>
  33 + <Card>
  34 + <h1>{{ t('admin.title') }}</h1>
  35 + <p v-if="error">{{ error }}</p>
  36 + <p v-else-if="requests.length === 0">{{ t('admin.empty') }}</p>
  37 + <div v-for="request in requests" :key="request.id" class="voko-field">
  38 + <strong>{{ request.email }}</strong>
  39 + <span>{{ request.note }}</span>
  40 + <span>{{ request.created_at }}</span>
  41 + <div>
  42 + <Button @click="review(request.id, 'approved')">{{ t('admin.approve') }}</Button>
  43 + <Button variant="ghost" @click="review(request.id, 'rejected')">{{ t('admin.reject') }}</Button>
  44 + </div>
  45 + </div>
  46 + </Card>
  47 +</template>
apps/voko-web/src/pages/HomePage.vue 0 → 100644
  1 +<script setup>
  2 +import { onMounted, ref } from 'vue'
  3 +import { useI18n } from 'vue-i18n'
  4 +import { useRouter } from 'vue-router'
  5 +import { Button, Card, Input } from '@drama/ui'
  6 +
  7 +import { api, getToken } from '../api.js'
  8 +
  9 +const { t } = useI18n()
  10 +const router = useRouter()
  11 +const me = ref(null)
  12 +const note = ref('')
  13 +
  14 +async function load() {
  15 + me.value = await api('GET', '/api/voko/me')
  16 +}
  17 +
  18 +async function apply() {
  19 + await api('POST', '/api/voko/access-requests', { note: note.value })
  20 + await load()
  21 +}
  22 +
  23 +onMounted(async () => {
  24 + if (getToken() === null) {
  25 + await router.push('/login')
  26 + return
  27 + }
  28 + await load()
  29 +})
  30 +</script>
  31 +
  32 +<template>
  33 + <Card v-if="me">
  34 + <h1>{{ t('home.title') }}</h1>
  35 + <p>{{ me.user.email }}</p>
  36 + <template v-if="me.access_request === null">
  37 + <p>{{ t('home.notApplied') }}</p>
  38 + <label class="voko-field">{{ t('home.note') }}<Input v-model="note" /></label>
  39 + <Button @click="apply">{{ t('home.apply') }}</Button>
  40 + </template>
  41 + <p v-else>{{ t(`home.status.${me.access_request.status}`) }}</p>
  42 + <router-link v-if="me.user.role === 'admin'" to="/admin">{{ t('home.admin') }}</router-link>
  43 + </Card>
  44 +</template>
apps/voko-web/src/pages/LoginPage.vue 0 → 100644
  1 +<script setup>
  2 +import { ref } from 'vue'
  3 +import { useI18n } from 'vue-i18n'
  4 +import { useRouter } from 'vue-router'
  5 +import { Button, Card, Input } from '@drama/ui'
  6 +
  7 +import { api, setToken } from '../api.js'
  8 +
  9 +const { t, locale } = useI18n()
  10 +const router = useRouter()
  11 +const mode = ref('login')
  12 +const email = ref('')
  13 +const password = ref('')
  14 +const code = ref('')
  15 +const message = ref('')
  16 +
  17 +async function sendCode() {
  18 + await api('POST', '/api/voko/auth/send-code', { email: email.value, locale: locale.value })
  19 + message.value = t('login.codeSent')
  20 +}
  21 +
  22 +async function submit() {
  23 + try {
  24 + const session = mode.value === 'login'
  25 + ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value })
  26 + : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value })
  27 + setToken(session.access_token)
  28 + await router.push('/')
  29 + } catch (err) {
  30 + message.value = err instanceof Error ? err.message : String(err)
  31 + }
  32 +}
  33 +</script>
  34 +
  35 +<template>
  36 + <Card>
  37 + <h1>{{ mode === 'login' ? t('login.title') : t('login.registerTitle') }}</h1>
  38 + <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label>
  39 + <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label>
  40 + <template v-if="mode === 'register'">
  41 + <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label>
  42 + <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button>
  43 + </template>
  44 + <p v-if="message">{{ message }}</p>
  45 + <Button @click="submit">{{ mode === 'login' ? t('login.submit') : t('login.register') }}</Button>
  46 + <Button variant="ghost" @click="mode = mode === 'login' ? 'register' : 'login'">
  47 + {{ mode === 'login' ? t('login.toRegister') : t('login.toLogin') }}
  48 + </Button>
  49 + </Card>
  50 +</template>
apps/voko-web/vite.config.js 0 → 100644
  1 +import { defineWebPreset } from '@drama/config'
  2 +import { defineConfig, loadEnv } from 'vite'
  3 +import { fileURLToPath } from 'node:url'
  4 +
  5 +const appRoot = fileURLToPath(new URL('.', import.meta.url))
  6 +
  7 +export default defineConfig(({ command, mode }) => {
  8 + const appEnv = loadEnv(mode, appRoot, '')
  9 + return defineWebPreset({
  10 + root: '.',
  11 + server: {
  12 + port: 5201,
  13 + proxy: { '/api': appEnv.VITE_API_PROXY || 'http://localhost:9201' },
  14 + },
  15 + }, { env: appEnv, command })
  16 +})
docs/voko/upstream.md
@@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
17 | `tools/scripts/mysql-dev.js` | 默认库 `voko`,默认 env 文件 `apps/voko-server/.env` | 17 | `tools/scripts/mysql-dev.js` | 默认库 `voko`,默认 env 文件 `apps/voko-server/.env` |
18 | `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) | 18 | `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) |
19 | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` | 19 | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` |
  20 +| `apps/voko-server/src/assembly/security.js`、`google-oauth.js`、`email-delivery-log.js` | 从 visual-game-server 原样复制(security.js 只改了一条报错文案) |
20 21
21 ## 待裁剪(原样搬入,后续处理) 22 ## 待裁剪(原样搬入,后续处理)
22 23
ecosystem.config.cjs 0 → 100644
  1 +// PM2:voko-server 与 voko-worker 两个常驻进程。新增或调整后执行 `pm2 save`。
  2 +module.exports = {
  3 + apps: [
  4 + { name: 'voko-server', cwd: 'apps/voko-server', script: 'src/main.js', interpreter: 'node' },
  5 + { name: 'voko-worker', cwd: 'apps/voko-server', script: 'src/voko-worker-main.js', interpreter: 'node' },
  6 + ],
  7 +}
packages/voko-access/package.json 0 → 100644
  1 +{
  2 + "name": "@voko/access",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./src/index.js"
  8 + },
  9 + "scripts": {
  10 + "test": "vitest run"
  11 + },
  12 + "dependencies": {
  13 + "valibot": "^1.4.1"
  14 + },
  15 + "devDependencies": {
  16 + "vitest": "^2.1.4"
  17 + }
  18 +}
packages/voko-access/src/access-requests.js 0 → 100644
  1 +import { randomUUID } from 'node:crypto'
  2 +
  3 +const TABLE = 'vk_access_requests'
  4 +
  5 +/** 加入申请的全部状态;消费方 switch 必须穷举。 */
  6 +export const ACCESS_REQUEST_STATUSES = Object.freeze(['pending', 'approved', 'rejected'])
  7 +
  8 +/**
  9 + * @typedef {{
  10 + * id: string,
  11 + * user_id: string,
  12 + * email: string,
  13 + * note: string|null,
  14 + * status: 'pending'|'approved'|'rejected',
  15 + * created_at: Date,
  16 + * reviewed_by_user_id: string|null,
  17 + * reviewed_at: Date|null,
  18 + * }} AccessRequest
  19 + */
  20 +
  21 +/**
  22 + * DB 行 → AccessRequest 的唯一构造点。按状态保证必有字段,非法即 throw。
  23 + * @param {Record<string, any>} row
  24 + * @returns {AccessRequest}
  25 + */
  26 +export function accessRequestFromRow(row) {
  27 + const id = requiredString(row.id, 'id', row.id)
  28 + const status = row.status
  29 + switch (status) {
  30 + case 'pending':
  31 + if (row.reviewed_at !== null || row.reviewed_by_user_id !== null) {
  32 + throw new Error(`vk_access_requests pending row has review fields, id: ${id}`)
  33 + }
  34 + break
  35 + case 'approved':
  36 + case 'rejected':
  37 + if (!(row.reviewed_at instanceof Date)) throw new Error(`vk_access_requests.reviewed_at missing, id: ${id}`)
  38 + requiredString(row.reviewed_by_user_id, 'reviewed_by_user_id', id)
  39 + break
  40 + default:
  41 + throw new Error(`Unhandled status: ${status}, id: ${id}`)
  42 + }
  43 + if (!(row.created_at instanceof Date)) throw new Error(`vk_access_requests.created_at missing, id: ${id}`)
  44 + return {
  45 + id,
  46 + user_id: requiredString(row.user_id, 'user_id', id),
  47 + email: requiredString(row.email, 'email', id),
  48 + note: row.note === null ? null : requiredString(row.note, 'note', id),
  49 + status,
  50 + created_at: row.created_at,
  51 + reviewed_by_user_id: row.reviewed_by_user_id,
  52 + reviewed_at: row.reviewed_at,
  53 + }
  54 +}
  55 +
  56 +/**
  57 + * @param {{
  58 + * db: import('knex').Knex,
  59 + * notifier: { sendApproved(ctx: object, input: { to_email: string }): Promise<void> },
  60 + * }} deps
  61 + */
  62 +export function createAccessRequests({ db, notifier }) {
  63 + if (typeof db !== 'function') throw new Error('createAccessRequests: db is required')
  64 + if (!notifier || typeof notifier.sendApproved !== 'function') throw new Error('createAccessRequests: notifier is required')
  65 +
  66 + /**
  67 + * 提交申请。同一用户只有一条申请(user_id UNIQUE),重复提交返回已有申请。
  68 + * @param {object} ctx
  69 + * @param {{ user_id: string, email: string, note: string|null }} input
  70 + * @returns {Promise<AccessRequest>}
  71 + */
  72 + async function submit(ctx, { user_id, email, note }) {
  73 + await db(TABLE)
  74 + .insert({ id: `ar_${randomUUID()}`, user_id, email, note, status: 'pending', created_at: new Date(), reviewed_by_user_id: null, reviewed_at: null })
  75 + .onConflict('user_id')
  76 + .ignore()
  77 + return getForUser(ctx, { user_id })
  78 + }
  79 +
  80 + /**
  81 + * @param {object} _ctx
  82 + * @param {{ user_id: string }} input
  83 + * @returns {Promise<AccessRequest|null>}
  84 + */
  85 + async function getForUser(_ctx, { user_id }) {
  86 + const row = await db(TABLE).where({ user_id }).first()
  87 + return row === undefined ? null : accessRequestFromRow(row)
  88 + }
  89 +
  90 + /**
  91 + * @param {object} ctx
  92 + * @param {{ user_id: string }} input
  93 + * @returns {Promise<boolean>}
  94 + */
  95 + async function isApproved(ctx, { user_id }) {
  96 + const request = await getForUser(ctx, { user_id })
  97 + if (request === null) return false
  98 + switch (request.status) {
  99 + case 'approved':
  100 + return true
  101 + case 'pending':
  102 + case 'rejected':
  103 + return false
  104 + default:
  105 + throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`)
  106 + }
  107 + }
  108 +
  109 + /**
  110 + * 按排队顺序列出申请。
  111 + * @param {object} _ctx
  112 + * @param {{ status: 'pending'|'approved'|'rejected', limit: number }} input
  113 + * @returns {Promise<AccessRequest[]>}
  114 + */
  115 + async function list(_ctx, { status, limit }) {
  116 + const rows = await db(TABLE).where({ status }).orderBy([{ column: 'created_at', order: 'asc' }, { column: 'id', order: 'asc' }]).limit(limit)
  117 + return rows.map(accessRequestFromRow)
  118 + }
  119 +
  120 + /**
  121 + * 运营审核。只有 pending 能被审核;并发或重复审核返回 CONFLICT 业务错误。
  122 + * 通过时在事务提交后发邮件通知。
  123 + * @param {object} ctx
  124 + * @param {{ request_id: string, reviewer_user_id: string, decision: 'approved'|'rejected' }} input
  125 + * @returns {Promise<{ ok: true, request: AccessRequest } | { ok: false, code: 'NOT_FOUND'|'CONFLICT' }>}
  126 + */
  127 + async function review(ctx, { request_id, reviewer_user_id, decision }) {
  128 + const reviewedAt = new Date()
  129 + const updated = await db(TABLE)
  130 + .where({ id: request_id, status: 'pending' })
  131 + .update({ status: decision, reviewed_by_user_id: reviewer_user_id, reviewed_at: reviewedAt })
  132 + const row = await db(TABLE).where({ id: request_id }).first()
  133 + if (row === undefined) return { ok: false, code: 'NOT_FOUND' }
  134 + if (updated === 0) return { ok: false, code: 'CONFLICT' }
  135 + const request = accessRequestFromRow(row)
  136 + switch (decision) {
  137 + case 'approved':
  138 + await notifier.sendApproved(ctx, { to_email: request.email })
  139 + break
  140 + case 'rejected':
  141 + break
  142 + default:
  143 + throw new Error(`Unhandled decision: ${decision}, id: ${request_id}`)
  144 + }
  145 + return { ok: true, request }
  146 + }
  147 +
  148 + return { submit, getForUser, isApproved, list, review }
  149 +}
  150 +
  151 +/**
  152 + * @param {unknown} value
  153 + * @param {string} field
  154 + * @param {unknown} id
  155 + * @returns {string}
  156 + */
  157 +function requiredString(value, field, id) {
  158 + if (typeof value === 'string' && value.length > 0) return value
  159 + throw new Error(`vk_access_requests.${field} invalid, id: ${String(id)}`)
  160 +}
packages/voko-access/src/db/tables/index.cjs 0 → 100644
  1 +const tableModules = [
  2 + require('./vk_access_requests.cjs'),
  3 +]
  4 +
  5 +const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable])
  6 +
  7 +module.exports = { targetTables }
packages/voko-access/src/db/tables/vk_access_requests.cjs 0 → 100644
  1 +const tableName = 'vk_access_requests'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('user_id', 64).notNullable()
  6 + t.string('email', 320).notNullable()
  7 + t.string('note', 1000).nullable()
  8 + t.string('status', 16).notNullable()
  9 + t.datetime('created_at', { precision: 3 }).notNullable()
  10 + t.string('reviewed_by_user_id', 64).nullable()
  11 + t.datetime('reviewed_at', { precision: 3 }).nullable()
  12 + t.unique(['user_id'], { indexName: 'uq_vk_access_requests_user' })
  13 + t.index(['status', 'created_at', 'id'], 'idx_vk_access_requests_queue')
  14 + t.check("status in ('pending','approved','rejected')", [], 'ck_vk_access_requests_status')
  15 + t.check("(status = 'pending' and reviewed_at is null and reviewed_by_user_id is null) or (status <> 'pending' and reviewed_at is not null and reviewed_by_user_id is not null)", [], 'ck_vk_access_requests_review')
  16 +}
  17 +
  18 +module.exports = { tableName, buildTable }
packages/voko-access/src/index.js 0 → 100644
  1 +/**
  2 + * `@voko/access` —— VOKO 的准入:加入申请(申请制,运营后台人工审核)。
  3 + *
  4 + * Alpha 不设邮箱白名单:任何邮箱都可注册并提交申请,只有 approved 的用户才能认领 Agent、
  5 + * 在网页上发任务。后续的 Agent 注册、认领、api_key 也放在本包。
  6 + *
  7 + * 边界:不读 `process.env`、不建连接;db 与通知端口由 composition root 注入。
  8 + */
  9 +
  10 +export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js'
  11 +export { default as accessDbSchema } from './db/tables/index.cjs'
packages/voko-access/test/access-request-from-row.test.js 0 → 100644
  1 +import { describe, expect, it } from 'vitest'
  2 +import { accessRequestFromRow, ACCESS_REQUEST_STATUSES } from '../src/access-requests.js'
  3 +
  4 +const base = {
  5 + id: 'ar_1',
  6 + user_id: 'u_1',
  7 + email: 'a@example.com',
  8 + note: null,
  9 + created_at: new Date('2026-10-10T00:00:00Z'),
  10 +}
  11 +
  12 +describe('accessRequestFromRow 覆盖全部状态', () => {
  13 + it('pending 不能带审核字段', () => {
  14 + expect(accessRequestFromRow({ ...base, status: 'pending', reviewed_at: null, reviewed_by_user_id: null }).status).toBe('pending')
  15 + expect(() => accessRequestFromRow({ ...base, status: 'pending', reviewed_at: new Date(), reviewed_by_user_id: 'u_admin' })).toThrow(/ar_1/)
  16 + })
  17 +
  18 + it.each(['approved', 'rejected'])('%s 必须有审核人和审核时间', (status) => {
  19 + expect(accessRequestFromRow({ ...base, status, reviewed_at: new Date(), reviewed_by_user_id: 'u_admin' }).status).toBe(status)
  20 + expect(() => accessRequestFromRow({ ...base, status, reviewed_at: null, reviewed_by_user_id: 'u_admin' })).toThrow(/reviewed_at/)
  21 + })
  22 +
  23 + it('未知状态直接 throw', () => {
  24 + expect(() => accessRequestFromRow({ ...base, status: 'archived', reviewed_at: null, reviewed_by_user_id: null })).toThrow(/Unhandled status: archived/)
  25 + })
  26 +
  27 + it('状态全集与 DB CHECK 一致', () => {
  28 + expect(ACCESS_REQUEST_STATUSES).toEqual(['pending', 'approved', 'rejected'])
  29 + })
  30 +})
pnpm-lock.yaml
@@ -43,6 +43,74 @@ importers: @@ -43,6 +43,74 @@ importers:
43 specifier: ^2.1.4 43 specifier: ^2.1.4
44 version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) 44 version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0)
45 45
  46 + apps/voko-server:
  47 + dependencies:
  48 + '@drama/auth':
  49 + specifier: workspace:*
  50 + version: link:../../packages/auth
  51 + '@drama/billing':
  52 + specifier: workspace:*
  53 + version: link:../../packages/billing
  54 + '@drama/contracts':
  55 + specifier: workspace:*
  56 + version: link:../../packages/contracts
  57 + '@drama/db':
  58 + specifier: workspace:*
  59 + version: link:../../packages/db
  60 + '@drama/email':
  61 + specifier: workspace:*
  62 + version: link:../../packages/email
  63 + '@drama/observability':
  64 + specifier: workspace:*
  65 + version: link:../../packages/observability
  66 + '@drama/server-kit':
  67 + specifier: workspace:*
  68 + version: link:../../packages/server-kit
  69 + '@voko/access':
  70 + specifier: workspace:*
  71 + version: link:../../packages/voko-access
  72 + dotenv:
  73 + specifier: 16.6.1
  74 + version: 16.6.1
  75 + fastify:
  76 + specifier: ^5.1.0
  77 + version: 5.8.5
  78 + googleapis:
  79 + specifier: ^173.0.0
  80 + version: 173.0.0
  81 + valibot:
  82 + specifier: ^1.4.1
  83 + version: 1.4.1(typescript@5.9.3)
  84 + devDependencies:
  85 + vitest:
  86 + specifier: ^2.1.4
  87 + version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0)
  88 +
  89 + apps/voko-web:
  90 + dependencies:
  91 + '@drama/ui':
  92 + specifier: workspace:*
  93 + version: link:../../packages/ui
  94 + vue:
  95 + specifier: ^3.5.0
  96 + version: 3.5.38(typescript@5.9.3)
  97 + vue-i18n:
  98 + specifier: ^11.0.0
  99 + version: 11.4.5(vue@3.5.38(typescript@5.9.3))
  100 + vue-router:
  101 + specifier: ^4.4.0
  102 + version: 4.6.4(vue@3.5.38(typescript@5.9.3))
  103 + devDependencies:
  104 + '@drama/config':
  105 + specifier: workspace:*
  106 + version: link:../../packages/config
  107 + '@vitejs/plugin-vue':
  108 + specifier: ^5.2.0
  109 + version: 5.2.4(vite@6.4.3(@types/node@24.13.2)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))
  110 + vite:
  111 + specifier: ^6.0.0
  112 + version: 6.4.3(@types/node@24.13.2)(sass@1.101.0)(yaml@2.9.0)
  113 +
46 packages/auth: 114 packages/auth:
47 dependencies: 115 dependencies:
48 argon2: 116 argon2:
@@ -208,6 +276,16 @@ importers: @@ -208,6 +276,16 @@ importers:
208 specifier: ^3.5.38 276 specifier: ^3.5.38
209 version: 3.5.38(typescript@5.9.3) 277 version: 3.5.38(typescript@5.9.3)
210 278
  279 + packages/voko-access:
  280 + dependencies:
  281 + valibot:
  282 + specifier: ^1.4.1
  283 + version: 1.4.1(typescript@5.9.3)
  284 + devDependencies:
  285 + vitest:
  286 + specifier: ^2.1.4
  287 + version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0)
  288 +
211 packages: 289 packages:
212 290
213 '@asamuzakjp/css-color@3.2.0': 291 '@asamuzakjp/css-color@3.2.0':
@@ -1151,6 +1229,10 @@ packages: @@ -1151,6 +1229,10 @@ packages:
1151 resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} 1229 resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
1152 engines: {node: '>= 0.4'} 1230 engines: {node: '>= 0.4'}
1153 1231
  1232 + call-bound@1.0.4:
  1233 + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
  1234 + engines: {node: '>= 0.4'}
  1235 +
1154 callsites@3.1.0: 1236 callsites@3.1.0:
1155 resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} 1237 resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
1156 engines: {node: '>=6'} 1238 engines: {node: '>=6'}
@@ -1226,6 +1308,10 @@ packages: @@ -1226,6 +1308,10 @@ packages:
1226 csstype@3.2.3: 1308 csstype@3.2.3:
1227 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 1309 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1228 1310
  1311 + data-uri-to-buffer@4.0.1:
  1312 + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
  1313 + engines: {node: '>= 12'}
  1314 +
1229 data-urls@5.0.0: 1315 data-urls@5.0.0:
1230 resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} 1316 resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
1231 engines: {node: '>=18'} 1317 engines: {node: '>=18'}
@@ -1460,6 +1546,10 @@ packages: @@ -1460,6 +1546,10 @@ packages:
1460 picomatch: 1546 picomatch:
1461 optional: true 1547 optional: true
1462 1548
  1549 + fetch-blob@3.2.0:
  1550 + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
  1551 + engines: {node: ^12.20 || >= 14.13}
  1552 +
1463 file-entry-cache@8.0.0: 1553 file-entry-cache@8.0.0:
1464 resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} 1554 resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
1465 engines: {node: '>=16.0.0'} 1555 engines: {node: '>=16.0.0'}
@@ -1487,6 +1577,10 @@ packages: @@ -1487,6 +1577,10 @@ packages:
1487 resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} 1577 resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
1488 engines: {node: '>= 6'} 1578 engines: {node: '>= 6'}
1489 1579
  1580 + formdata-polyfill@4.0.10:
  1581 + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
  1582 + engines: {node: '>=12.20.0'}
  1583 +
1490 fsevents@2.3.2: 1584 fsevents@2.3.2:
1491 resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} 1585 resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
1492 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 1586 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1504,10 +1598,26 @@ packages: @@ -1504,10 +1598,26 @@ packages:
1504 resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} 1598 resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
1505 engines: {node: '>=14'} 1599 engines: {node: '>=14'}
1506 1600
  1601 + gaxios@7.1.3:
  1602 + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
  1603 + engines: {node: '>=18'}
  1604 +
  1605 + gaxios@7.3.1:
  1606 + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==}
  1607 + engines: {node: '>=18'}
  1608 +
1507 gcp-metadata@6.1.1: 1609 gcp-metadata@6.1.1:
1508 resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} 1610 resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
1509 engines: {node: '>=14'} 1611 engines: {node: '>=14'}
1510 1612
  1613 + gcp-metadata@8.1.2:
  1614 + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
  1615 + engines: {node: '>=18'}
  1616 +
  1617 + gcp-metadata@8.1.4:
  1618 + resolution: {integrity: sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==}
  1619 + engines: {node: '>=18'}
  1620 +
1511 generate-function@2.3.1: 1621 generate-function@2.3.1:
1512 resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} 1622 resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
1513 1623
@@ -1546,6 +1656,14 @@ packages: @@ -1546,6 +1656,14 @@ packages:
1546 resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} 1656 resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
1547 engines: {node: '>=18'} 1657 engines: {node: '>=18'}
1548 1658
  1659 + google-auth-library@10.5.0:
  1660 + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==}
  1661 + engines: {node: '>=18'}
  1662 +
  1663 + google-auth-library@10.9.1:
  1664 + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==}
  1665 + engines: {node: '>=18'}
  1666 +
1549 google-auth-library@9.15.1: 1667 google-auth-library@9.15.1:
1550 resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} 1668 resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
1551 engines: {node: '>=14'} 1669 engines: {node: '>=14'}
@@ -1554,6 +1672,18 @@ packages: @@ -1554,6 +1672,18 @@ packages:
1554 resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} 1672 resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
1555 engines: {node: '>=14'} 1673 engines: {node: '>=14'}
1556 1674
  1675 + google-logging-utils@1.1.3:
  1676 + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
  1677 + engines: {node: '>=14'}
  1678 +
  1679 + googleapis-common@8.0.3:
  1680 + resolution: {integrity: sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==}
  1681 + engines: {node: '>=18'}
  1682 +
  1683 + googleapis@173.0.0:
  1684 + resolution: {integrity: sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==}
  1685 + engines: {node: '>=18'}
  1686 +
1557 gopd@1.2.0: 1687 gopd@1.2.0:
1558 resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} 1688 resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1559 engines: {node: '>= 0.4'} 1689 engines: {node: '>= 0.4'}
@@ -1562,6 +1692,10 @@ packages: @@ -1562,6 +1692,10 @@ packages:
1562 resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} 1692 resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
1563 engines: {node: '>=14.0.0'} 1693 engines: {node: '>=14.0.0'}
1564 1694
  1695 + gtoken@8.0.0:
  1696 + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
  1697 + engines: {node: '>=18'}
  1698 +
1565 has-flag@4.0.0: 1699 has-flag@4.0.0:
1566 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 1700 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1567 engines: {node: '>=8'} 1701 engines: {node: '>=8'}
@@ -1838,6 +1972,11 @@ packages: @@ -1838,6 +1972,11 @@ packages:
1838 resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} 1972 resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==}
1839 engines: {node: ^18 || ^20 || >= 21} 1973 engines: {node: ^18 || ^20 || >= 21}
1840 1974
  1975 + node-domexception@1.0.0:
  1976 + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
  1977 + engines: {node: '>=10.5.0'}
  1978 + deprecated: Use your platform's native DOMException instead
  1979 +
1841 node-fetch@2.7.0: 1980 node-fetch@2.7.0:
1842 resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} 1981 resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
1843 engines: {node: 4.x || >=6.0.0} 1982 engines: {node: 4.x || >=6.0.0}
@@ -1847,6 +1986,10 @@ packages: @@ -1847,6 +1986,10 @@ packages:
1847 encoding: 1986 encoding:
1848 optional: true 1987 optional: true
1849 1988
  1989 + node-fetch@3.3.2:
  1990 + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
  1991 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
  1992 +
1850 node-gyp-build@4.8.4: 1993 node-gyp-build@4.8.4:
1851 resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} 1994 resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
1852 hasBin: true 1995 hasBin: true
@@ -1866,6 +2009,10 @@ packages: @@ -1866,6 +2009,10 @@ packages:
1866 nwsapi@2.2.24: 2009 nwsapi@2.2.24:
1867 resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} 2010 resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
1868 2011
  2012 + object-inspect@1.13.4:
  2013 + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
  2014 + engines: {node: '>= 0.4'}
  2015 +
1869 on-exit-leak-free@2.1.2: 2016 on-exit-leak-free@2.1.2:
1870 resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} 2017 resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
1871 engines: {node: '>=14.0.0'} 2018 engines: {node: '>=14.0.0'}
@@ -1979,6 +2126,10 @@ packages: @@ -1979,6 +2126,10 @@ packages:
1979 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 2126 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1980 engines: {node: '>=6'} 2127 engines: {node: '>=6'}
1981 2128
  2129 + qs@6.16.0:
  2130 + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
  2131 + engines: {node: '>=0.6'}
  2132 +
1982 quick-format-unescaped@4.0.4: 2133 quick-format-unescaped@4.0.4:
1983 resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} 2134 resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
1984 2135
@@ -2033,6 +2184,10 @@ packages: @@ -2033,6 +2184,10 @@ packages:
2033 rfdc@1.4.1: 2184 rfdc@1.4.1:
2034 resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} 2185 resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
2035 2186
  2187 + rimraf@5.0.10:
  2188 + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
  2189 + hasBin: true
  2190 +
2036 rollup@4.62.0: 2191 rollup@4.62.0:
2037 resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} 2192 resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==}
2038 engines: {node: '>=18.0.0', npm: '>=8.0.0'} 2193 engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -2086,6 +2241,22 @@ packages: @@ -2086,6 +2241,22 @@ packages:
2086 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 2241 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
2087 engines: {node: '>=8'} 2242 engines: {node: '>=8'}
2088 2243
  2244 + side-channel-list@1.0.1:
  2245 + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
  2246 + engines: {node: '>= 0.4'}
  2247 +
  2248 + side-channel-map@1.0.1:
  2249 + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
  2250 + engines: {node: '>= 0.4'}
  2251 +
  2252 + side-channel-weakmap@1.0.2:
  2253 + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
  2254 + engines: {node: '>= 0.4'}
  2255 +
  2256 + side-channel@1.1.1:
  2257 + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
  2258 + engines: {node: '>= 0.4'}
  2259 +
2089 siginfo@2.0.0: 2260 siginfo@2.0.0:
2090 resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 2261 resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
2091 2262
@@ -2231,6 +2402,9 @@ packages: @@ -2231,6 +2402,9 @@ packages:
2231 uri-js@4.4.1: 2402 uri-js@4.4.1:
2232 resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 2403 resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
2233 2404
  2405 + url-template@2.0.8:
  2406 + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
  2407 +
2234 util-deprecate@1.0.2: 2408 util-deprecate@1.0.2:
2235 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 2409 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
2236 2410
@@ -2363,6 +2537,11 @@ packages: @@ -2363,6 +2537,11 @@ packages:
2363 peerDependencies: 2537 peerDependencies:
2364 vue: ^3.0.0 2538 vue: ^3.0.0
2365 2539
  2540 + vue-router@4.6.4:
  2541 + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
  2542 + peerDependencies:
  2543 + vue: ^3.5.0
  2544 +
2366 vue@3.5.38: 2545 vue@3.5.38:
2367 resolution: {integrity: sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==} 2546 resolution: {integrity: sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==}
2368 peerDependencies: 2547 peerDependencies:
@@ -2375,6 +2554,10 @@ packages: @@ -2375,6 +2554,10 @@ packages:
2375 resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} 2554 resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
2376 engines: {node: '>=18'} 2555 engines: {node: '>=18'}
2377 2556
  2557 + web-streams-polyfill@3.3.3:
  2558 + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
  2559 + engines: {node: '>= 8'}
  2560 +
2378 webidl-conversions@3.0.1: 2561 webidl-conversions@3.0.1:
2379 resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} 2562 resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
2380 2563
@@ -3178,6 +3361,11 @@ snapshots: @@ -3178,6 +3361,11 @@ snapshots:
3178 es-errors: 1.3.0 3361 es-errors: 1.3.0
3179 function-bind: 1.1.2 3362 function-bind: 1.1.2
3180 3363
  3364 + call-bound@1.0.4:
  3365 + dependencies:
  3366 + call-bind-apply-helpers: 1.0.2
  3367 + get-intrinsic: 1.3.0
  3368 +
3181 callsites@3.1.0: {} 3369 callsites@3.1.0: {}
3182 3370
3183 chai@5.3.3: 3371 chai@5.3.3:
@@ -3257,6 +3445,8 @@ snapshots: @@ -3257,6 +3445,8 @@ snapshots:
3257 3445
3258 csstype@3.2.3: {} 3446 csstype@3.2.3: {}
3259 3447
  3448 + data-uri-to-buffer@4.0.1: {}
  3449 +
3260 data-urls@5.0.0: 3450 data-urls@5.0.0:
3261 dependencies: 3451 dependencies:
3262 whatwg-mimetype: 4.0.0 3452 whatwg-mimetype: 4.0.0
@@ -3546,6 +3736,11 @@ snapshots: @@ -3546,6 +3736,11 @@ snapshots:
3546 optionalDependencies: 3736 optionalDependencies:
3547 picomatch: 4.0.4 3737 picomatch: 4.0.4
3548 3738
  3739 + fetch-blob@3.2.0:
  3740 + dependencies:
  3741 + node-domexception: 1.0.0
  3742 + web-streams-polyfill: 3.3.3
  3743 +
3549 file-entry-cache@8.0.0: 3744 file-entry-cache@8.0.0:
3550 dependencies: 3745 dependencies:
3551 flat-cache: 4.0.1 3746 flat-cache: 4.0.1
@@ -3581,6 +3776,10 @@ snapshots: @@ -3581,6 +3776,10 @@ snapshots:
3581 hasown: 2.0.4 3776 hasown: 2.0.4
3582 mime-types: 2.1.35 3777 mime-types: 2.1.35
3583 3778
  3779 + formdata-polyfill@4.0.10:
  3780 + dependencies:
  3781 + fetch-blob: 3.2.0
  3782 +
3584 fsevents@2.3.2: 3783 fsevents@2.3.2:
3585 optional: true 3784 optional: true
3586 3785
@@ -3600,6 +3799,23 @@ snapshots: @@ -3600,6 +3799,23 @@ snapshots:
3600 - encoding 3799 - encoding
3601 - supports-color 3800 - supports-color
3602 3801
  3802 + gaxios@7.1.3:
  3803 + dependencies:
  3804 + extend: 3.0.2
  3805 + https-proxy-agent: 7.0.6
  3806 + node-fetch: 3.3.2
  3807 + rimraf: 5.0.10
  3808 + transitivePeerDependencies:
  3809 + - supports-color
  3810 +
  3811 + gaxios@7.3.1:
  3812 + dependencies:
  3813 + extend: 3.0.2
  3814 + https-proxy-agent: 7.0.6
  3815 + node-fetch: 3.3.2
  3816 + transitivePeerDependencies:
  3817 + - supports-color
  3818 +
3603 gcp-metadata@6.1.1: 3819 gcp-metadata@6.1.1:
3604 dependencies: 3820 dependencies:
3605 gaxios: 6.7.1 3821 gaxios: 6.7.1
@@ -3609,6 +3825,22 @@ snapshots: @@ -3609,6 +3825,22 @@ snapshots:
3609 - encoding 3825 - encoding
3610 - supports-color 3826 - supports-color
3611 3827
  3828 + gcp-metadata@8.1.2:
  3829 + dependencies:
  3830 + gaxios: 7.3.1
  3831 + google-logging-utils: 1.1.3
  3832 + json-bigint: 1.0.0
  3833 + transitivePeerDependencies:
  3834 + - supports-color
  3835 +
  3836 + gcp-metadata@8.1.4:
  3837 + dependencies:
  3838 + gaxios: 7.1.3
  3839 + google-logging-utils: 1.1.3
  3840 + json-bigint: 1.0.0
  3841 + transitivePeerDependencies:
  3842 + - supports-color
  3843 +
3612 generate-function@2.3.1: 3844 generate-function@2.3.1:
3613 dependencies: 3845 dependencies:
3614 is-property: 1.0.2 3846 is-property: 1.0.2
@@ -3658,6 +3890,29 @@ snapshots: @@ -3658,6 +3890,29 @@ snapshots:
3658 3890
3659 globals@14.0.0: {} 3891 globals@14.0.0: {}
3660 3892
  3893 + google-auth-library@10.5.0:
  3894 + dependencies:
  3895 + base64-js: 1.5.1
  3896 + ecdsa-sig-formatter: 1.0.11
  3897 + gaxios: 7.1.3
  3898 + gcp-metadata: 8.1.4
  3899 + google-logging-utils: 1.1.3
  3900 + gtoken: 8.0.0
  3901 + jws: 4.0.1
  3902 + transitivePeerDependencies:
  3903 + - supports-color
  3904 +
  3905 + google-auth-library@10.9.1:
  3906 + dependencies:
  3907 + base64-js: 1.5.1
  3908 + ecdsa-sig-formatter: 1.0.11
  3909 + gaxios: 7.3.1
  3910 + gcp-metadata: 8.1.2
  3911 + google-logging-utils: 1.1.3
  3912 + jws: 4.0.1
  3913 + transitivePeerDependencies:
  3914 + - supports-color
  3915 +
3661 google-auth-library@9.15.1: 3916 google-auth-library@9.15.1:
3662 dependencies: 3917 dependencies:
3663 base64-js: 1.5.1 3918 base64-js: 1.5.1
@@ -3672,6 +3927,26 @@ snapshots: @@ -3672,6 +3927,26 @@ snapshots:
3672 3927
3673 google-logging-utils@0.0.2: {} 3928 google-logging-utils@0.0.2: {}
3674 3929
  3930 + google-logging-utils@1.1.3: {}
  3931 +
  3932 + googleapis-common@8.0.3:
  3933 + dependencies:
  3934 + extend: 3.0.2
  3935 + gaxios: 7.1.3
  3936 + google-auth-library: 10.5.0
  3937 + google-logging-utils: 1.1.3
  3938 + qs: 6.16.0
  3939 + url-template: 2.0.8
  3940 + transitivePeerDependencies:
  3941 + - supports-color
  3942 +
  3943 + googleapis@173.0.0:
  3944 + dependencies:
  3945 + google-auth-library: 10.9.1
  3946 + googleapis-common: 8.0.3
  3947 + transitivePeerDependencies:
  3948 + - supports-color
  3949 +
3675 gopd@1.2.0: {} 3950 gopd@1.2.0: {}
3676 3951
3677 gtoken@7.1.0: 3952 gtoken@7.1.0:
@@ -3682,6 +3957,13 @@ snapshots: @@ -3682,6 +3957,13 @@ snapshots:
3682 - encoding 3957 - encoding
3683 - supports-color 3958 - supports-color
3684 3959
  3960 + gtoken@8.0.0:
  3961 + dependencies:
  3962 + gaxios: 7.1.3
  3963 + jws: 4.0.1
  3964 + transitivePeerDependencies:
  3965 + - supports-color
  3966 +
3685 has-flag@4.0.0: {} 3967 has-flag@4.0.0: {}
3686 3968
3687 has-symbols@1.1.0: {} 3969 has-symbols@1.1.0: {}
@@ -3959,10 +4241,18 @@ snapshots: @@ -3959,10 +4241,18 @@ snapshots:
3959 4241
3960 node-addon-api@8.8.0: {} 4242 node-addon-api@8.8.0: {}
3961 4243
  4244 + node-domexception@1.0.0: {}
  4245 +
3962 node-fetch@2.7.0: 4246 node-fetch@2.7.0:
3963 dependencies: 4247 dependencies:
3964 whatwg-url: 5.0.0 4248 whatwg-url: 5.0.0
3965 4249
  4250 + node-fetch@3.3.2:
  4251 + dependencies:
  4252 + data-uri-to-buffer: 4.0.1
  4253 + fetch-blob: 3.2.0
  4254 + formdata-polyfill: 4.0.10
  4255 +
3966 node-gyp-build@4.8.4: {} 4256 node-gyp-build@4.8.4: {}
3967 4257
3968 nodemailer@6.10.1: {} 4258 nodemailer@6.10.1: {}
@@ -3977,6 +4267,8 @@ snapshots: @@ -3977,6 +4267,8 @@ snapshots:
3977 4267
3978 nwsapi@2.2.24: {} 4268 nwsapi@2.2.24: {}
3979 4269
  4270 + object-inspect@1.13.4: {}
  4271 +
3980 on-exit-leak-free@2.1.2: {} 4272 on-exit-leak-free@2.1.2: {}
3981 4273
3982 optionator@0.9.4: 4274 optionator@0.9.4:
@@ -4098,6 +4390,11 @@ snapshots: @@ -4098,6 +4390,11 @@ snapshots:
4098 4390
4099 punycode@2.3.1: {} 4391 punycode@2.3.1: {}
4100 4392
  4393 + qs@6.16.0:
  4394 + dependencies:
  4395 + es-define-property: 1.0.1
  4396 + side-channel: 1.1.1
  4397 +
4101 quick-format-unescaped@4.0.4: {} 4398 quick-format-unescaped@4.0.4: {}
4102 4399
4103 readdirp@5.0.0: 4400 readdirp@5.0.0:
@@ -4136,6 +4433,10 @@ snapshots: @@ -4136,6 +4433,10 @@ snapshots:
4136 4433
4137 rfdc@1.4.1: {} 4434 rfdc@1.4.1: {}
4138 4435
  4436 + rimraf@5.0.10:
  4437 + dependencies:
  4438 + glob: 10.5.0
  4439 +
4139 rollup@4.62.0: 4440 rollup@4.62.0:
4140 dependencies: 4441 dependencies:
4141 '@types/estree': 1.0.9 4442 '@types/estree': 1.0.9
@@ -4206,6 +4507,34 @@ snapshots: @@ -4206,6 +4507,34 @@ snapshots:
4206 4507
4207 shebang-regex@3.0.0: {} 4508 shebang-regex@3.0.0: {}
4208 4509
  4510 + side-channel-list@1.0.1:
  4511 + dependencies:
  4512 + es-errors: 1.3.0
  4513 + object-inspect: 1.13.4
  4514 +
  4515 + side-channel-map@1.0.1:
  4516 + dependencies:
  4517 + call-bound: 1.0.4
  4518 + es-errors: 1.3.0
  4519 + get-intrinsic: 1.3.0
  4520 + object-inspect: 1.13.4
  4521 +
  4522 + side-channel-weakmap@1.0.2:
  4523 + dependencies:
  4524 + call-bound: 1.0.4
  4525 + es-errors: 1.3.0
  4526 + get-intrinsic: 1.3.0
  4527 + object-inspect: 1.13.4
  4528 + side-channel-map: 1.0.1
  4529 +
  4530 + side-channel@1.1.1:
  4531 + dependencies:
  4532 + es-errors: 1.3.0
  4533 + object-inspect: 1.13.4
  4534 + side-channel-list: 1.0.1
  4535 + side-channel-map: 1.0.1
  4536 + side-channel-weakmap: 1.0.2
  4537 +
4209 siginfo@2.0.0: {} 4538 siginfo@2.0.0: {}
4210 4539
4211 signal-exit@4.1.0: {} 4540 signal-exit@4.1.0: {}
@@ -4323,6 +4652,8 @@ snapshots: @@ -4323,6 +4652,8 @@ snapshots:
4323 dependencies: 4652 dependencies:
4324 punycode: 2.3.1 4653 punycode: 2.3.1
4325 4654
  4655 + url-template@2.0.8: {}
  4656 +
4326 util-deprecate@1.0.2: {} 4657 util-deprecate@1.0.2: {}
4327 4658
4328 uuid@9.0.1: {} 4659 uuid@9.0.1: {}
@@ -4432,6 +4763,11 @@ snapshots: @@ -4432,6 +4763,11 @@ snapshots:
4432 '@vue/devtools-api': 6.6.4 4763 '@vue/devtools-api': 6.6.4
4433 vue: 3.5.38(typescript@5.9.3) 4764 vue: 3.5.38(typescript@5.9.3)
4434 4765
  4766 + vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)):
  4767 + dependencies:
  4768 + '@vue/devtools-api': 6.6.4
  4769 + vue: 3.5.38(typescript@5.9.3)
  4770 +
4435 vue@3.5.38(typescript@5.9.3): 4771 vue@3.5.38(typescript@5.9.3):
4436 dependencies: 4772 dependencies:
4437 '@vue/compiler-dom': 3.5.38 4773 '@vue/compiler-dom': 3.5.38
@@ -4446,6 +4782,8 @@ snapshots: @@ -4446,6 +4782,8 @@ snapshots:
4446 dependencies: 4782 dependencies:
4447 xml-name-validator: 5.0.0 4783 xml-name-validator: 5.0.0
4448 4784
  4785 + web-streams-polyfill@3.3.3: {}
  4786 +
4449 webidl-conversions@3.0.1: {} 4787 webidl-conversions@3.0.1: {}
4450 4788
4451 webidl-conversions@7.0.0: {} 4789 webidl-conversions@7.0.0: {}