Commit dcf597fe730d2ec2bdd37cc36bb5a04519564438
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 && pnpm test |
| 13 | 13 | ``` |
| 14 | 14 | |
| 15 | 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 | 17 | | `tools/scripts/mysql-dev.js` | 默认库 `voko`,默认 env 文件 `apps/voko-server/.env` | |
| 18 | 18 | | `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) | |
| 19 | 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
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 | 43 | specifier: ^2.1.4 |
| 44 | 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 | 114 | packages/auth: |
| 47 | 115 | dependencies: |
| 48 | 116 | argon2: |
| ... | ... | @@ -208,6 +276,16 @@ importers: |
| 208 | 276 | specifier: ^3.5.38 |
| 209 | 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 | 289 | packages: |
| 212 | 290 | |
| 213 | 291 | '@asamuzakjp/css-color@3.2.0': |
| ... | ... | @@ -1151,6 +1229,10 @@ packages: |
| 1151 | 1229 | resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} |
| 1152 | 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 | 1236 | callsites@3.1.0: |
| 1155 | 1237 | resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} |
| 1156 | 1238 | engines: {node: '>=6'} |
| ... | ... | @@ -1226,6 +1308,10 @@ packages: |
| 1226 | 1308 | csstype@3.2.3: |
| 1227 | 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 | 1315 | data-urls@5.0.0: |
| 1230 | 1316 | resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} |
| 1231 | 1317 | engines: {node: '>=18'} |
| ... | ... | @@ -1460,6 +1546,10 @@ packages: |
| 1460 | 1546 | picomatch: |
| 1461 | 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 | 1553 | file-entry-cache@8.0.0: |
| 1464 | 1554 | resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} |
| 1465 | 1555 | engines: {node: '>=16.0.0'} |
| ... | ... | @@ -1487,6 +1577,10 @@ packages: |
| 1487 | 1577 | resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} |
| 1488 | 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 | 1584 | fsevents@2.3.2: |
| 1491 | 1585 | resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} |
| 1492 | 1586 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} |
| ... | ... | @@ -1504,10 +1598,26 @@ packages: |
| 1504 | 1598 | resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} |
| 1505 | 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 | 1609 | gcp-metadata@6.1.1: |
| 1508 | 1610 | resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} |
| 1509 | 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 | 1621 | generate-function@2.3.1: |
| 1512 | 1622 | resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} |
| 1513 | 1623 | |
| ... | ... | @@ -1546,6 +1656,14 @@ packages: |
| 1546 | 1656 | resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} |
| 1547 | 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 | 1667 | google-auth-library@9.15.1: |
| 1550 | 1668 | resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} |
| 1551 | 1669 | engines: {node: '>=14'} |
| ... | ... | @@ -1554,6 +1672,18 @@ packages: |
| 1554 | 1672 | resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} |
| 1555 | 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 | 1687 | gopd@1.2.0: |
| 1558 | 1688 | resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} |
| 1559 | 1689 | engines: {node: '>= 0.4'} |
| ... | ... | @@ -1562,6 +1692,10 @@ packages: |
| 1562 | 1692 | resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} |
| 1563 | 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 | 1699 | has-flag@4.0.0: |
| 1566 | 1700 | resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} |
| 1567 | 1701 | engines: {node: '>=8'} |
| ... | ... | @@ -1838,6 +1972,11 @@ packages: |
| 1838 | 1972 | resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} |
| 1839 | 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 | 1980 | node-fetch@2.7.0: |
| 1842 | 1981 | resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} |
| 1843 | 1982 | engines: {node: 4.x || >=6.0.0} |
| ... | ... | @@ -1847,6 +1986,10 @@ packages: |
| 1847 | 1986 | encoding: |
| 1848 | 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 | 1993 | node-gyp-build@4.8.4: |
| 1851 | 1994 | resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} |
| 1852 | 1995 | hasBin: true |
| ... | ... | @@ -1866,6 +2009,10 @@ packages: |
| 1866 | 2009 | nwsapi@2.2.24: |
| 1867 | 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 | 2016 | on-exit-leak-free@2.1.2: |
| 1870 | 2017 | resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} |
| 1871 | 2018 | engines: {node: '>=14.0.0'} |
| ... | ... | @@ -1979,6 +2126,10 @@ packages: |
| 1979 | 2126 | resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} |
| 1980 | 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 | 2133 | quick-format-unescaped@4.0.4: |
| 1983 | 2134 | resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} |
| 1984 | 2135 | |
| ... | ... | @@ -2033,6 +2184,10 @@ packages: |
| 2033 | 2184 | rfdc@1.4.1: |
| 2034 | 2185 | resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} |
| 2035 | 2186 | |
| 2187 | + rimraf@5.0.10: | |
| 2188 | + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} | |
| 2189 | + hasBin: true | |
| 2190 | + | |
| 2036 | 2191 | rollup@4.62.0: |
| 2037 | 2192 | resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} |
| 2038 | 2193 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} |
| ... | ... | @@ -2086,6 +2241,22 @@ packages: |
| 2086 | 2241 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} |
| 2087 | 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 | 2260 | siginfo@2.0.0: |
| 2090 | 2261 | resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} |
| 2091 | 2262 | |
| ... | ... | @@ -2231,6 +2402,9 @@ packages: |
| 2231 | 2402 | uri-js@4.4.1: |
| 2232 | 2403 | resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} |
| 2233 | 2404 | |
| 2405 | + url-template@2.0.8: | |
| 2406 | + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} | |
| 2407 | + | |
| 2234 | 2408 | util-deprecate@1.0.2: |
| 2235 | 2409 | resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} |
| 2236 | 2410 | |
| ... | ... | @@ -2363,6 +2537,11 @@ packages: |
| 2363 | 2537 | peerDependencies: |
| 2364 | 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 | 2545 | vue@3.5.38: |
| 2367 | 2546 | resolution: {integrity: sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==} |
| 2368 | 2547 | peerDependencies: |
| ... | ... | @@ -2375,6 +2554,10 @@ packages: |
| 2375 | 2554 | resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} |
| 2376 | 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 | 2561 | webidl-conversions@3.0.1: |
| 2379 | 2562 | resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} |
| 2380 | 2563 | |
| ... | ... | @@ -3178,6 +3361,11 @@ snapshots: |
| 3178 | 3361 | es-errors: 1.3.0 |
| 3179 | 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 | 3369 | callsites@3.1.0: {} |
| 3182 | 3370 | |
| 3183 | 3371 | chai@5.3.3: |
| ... | ... | @@ -3257,6 +3445,8 @@ snapshots: |
| 3257 | 3445 | |
| 3258 | 3446 | csstype@3.2.3: {} |
| 3259 | 3447 | |
| 3448 | + data-uri-to-buffer@4.0.1: {} | |
| 3449 | + | |
| 3260 | 3450 | data-urls@5.0.0: |
| 3261 | 3451 | dependencies: |
| 3262 | 3452 | whatwg-mimetype: 4.0.0 |
| ... | ... | @@ -3546,6 +3736,11 @@ snapshots: |
| 3546 | 3736 | optionalDependencies: |
| 3547 | 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 | 3744 | file-entry-cache@8.0.0: |
| 3550 | 3745 | dependencies: |
| 3551 | 3746 | flat-cache: 4.0.1 |
| ... | ... | @@ -3581,6 +3776,10 @@ snapshots: |
| 3581 | 3776 | hasown: 2.0.4 |
| 3582 | 3777 | mime-types: 2.1.35 |
| 3583 | 3778 | |
| 3779 | + formdata-polyfill@4.0.10: | |
| 3780 | + dependencies: | |
| 3781 | + fetch-blob: 3.2.0 | |
| 3782 | + | |
| 3584 | 3783 | fsevents@2.3.2: |
| 3585 | 3784 | optional: true |
| 3586 | 3785 | |
| ... | ... | @@ -3600,6 +3799,23 @@ snapshots: |
| 3600 | 3799 | - encoding |
| 3601 | 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 | 3819 | gcp-metadata@6.1.1: |
| 3604 | 3820 | dependencies: |
| 3605 | 3821 | gaxios: 6.7.1 |
| ... | ... | @@ -3609,6 +3825,22 @@ snapshots: |
| 3609 | 3825 | - encoding |
| 3610 | 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 | 3844 | generate-function@2.3.1: |
| 3613 | 3845 | dependencies: |
| 3614 | 3846 | is-property: 1.0.2 |
| ... | ... | @@ -3658,6 +3890,29 @@ snapshots: |
| 3658 | 3890 | |
| 3659 | 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 | 3916 | google-auth-library@9.15.1: |
| 3662 | 3917 | dependencies: |
| 3663 | 3918 | base64-js: 1.5.1 |
| ... | ... | @@ -3672,6 +3927,26 @@ snapshots: |
| 3672 | 3927 | |
| 3673 | 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 | 3950 | gopd@1.2.0: {} |
| 3676 | 3951 | |
| 3677 | 3952 | gtoken@7.1.0: |
| ... | ... | @@ -3682,6 +3957,13 @@ snapshots: |
| 3682 | 3957 | - encoding |
| 3683 | 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 | 3967 | has-flag@4.0.0: {} |
| 3686 | 3968 | |
| 3687 | 3969 | has-symbols@1.1.0: {} |
| ... | ... | @@ -3959,10 +4241,18 @@ snapshots: |
| 3959 | 4241 | |
| 3960 | 4242 | node-addon-api@8.8.0: {} |
| 3961 | 4243 | |
| 4244 | + node-domexception@1.0.0: {} | |
| 4245 | + | |
| 3962 | 4246 | node-fetch@2.7.0: |
| 3963 | 4247 | dependencies: |
| 3964 | 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 | 4256 | node-gyp-build@4.8.4: {} |
| 3967 | 4257 | |
| 3968 | 4258 | nodemailer@6.10.1: {} |
| ... | ... | @@ -3977,6 +4267,8 @@ snapshots: |
| 3977 | 4267 | |
| 3978 | 4268 | nwsapi@2.2.24: {} |
| 3979 | 4269 | |
| 4270 | + object-inspect@1.13.4: {} | |
| 4271 | + | |
| 3980 | 4272 | on-exit-leak-free@2.1.2: {} |
| 3981 | 4273 | |
| 3982 | 4274 | optionator@0.9.4: |
| ... | ... | @@ -4098,6 +4390,11 @@ snapshots: |
| 4098 | 4390 | |
| 4099 | 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 | 4398 | quick-format-unescaped@4.0.4: {} |
| 4102 | 4399 | |
| 4103 | 4400 | readdirp@5.0.0: |
| ... | ... | @@ -4136,6 +4433,10 @@ snapshots: |
| 4136 | 4433 | |
| 4137 | 4434 | rfdc@1.4.1: {} |
| 4138 | 4435 | |
| 4436 | + rimraf@5.0.10: | |
| 4437 | + dependencies: | |
| 4438 | + glob: 10.5.0 | |
| 4439 | + | |
| 4139 | 4440 | rollup@4.62.0: |
| 4140 | 4441 | dependencies: |
| 4141 | 4442 | '@types/estree': 1.0.9 |
| ... | ... | @@ -4206,6 +4507,34 @@ snapshots: |
| 4206 | 4507 | |
| 4207 | 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 | 4538 | siginfo@2.0.0: {} |
| 4210 | 4539 | |
| 4211 | 4540 | signal-exit@4.1.0: {} |
| ... | ... | @@ -4323,6 +4652,8 @@ snapshots: |
| 4323 | 4652 | dependencies: |
| 4324 | 4653 | punycode: 2.3.1 |
| 4325 | 4654 | |
| 4655 | + url-template@2.0.8: {} | |
| 4656 | + | |
| 4326 | 4657 | util-deprecate@1.0.2: {} |
| 4327 | 4658 | |
| 4328 | 4659 | uuid@9.0.1: {} |
| ... | ... | @@ -4432,6 +4763,11 @@ snapshots: |
| 4432 | 4763 | '@vue/devtools-api': 6.6.4 |
| 4433 | 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 | 4771 | vue@3.5.38(typescript@5.9.3): |
| 4436 | 4772 | dependencies: |
| 4437 | 4773 | '@vue/compiler-dom': 3.5.38 |
| ... | ... | @@ -4446,6 +4782,8 @@ snapshots: |
| 4446 | 4782 | dependencies: |
| 4447 | 4783 | xml-name-validator: 5.0.0 |
| 4448 | 4784 | |
| 4785 | + web-streams-polyfill@3.3.3: {} | |
| 4786 | + | |
| 4449 | 4787 | webidl-conversions@3.0.1: {} |
| 4450 | 4788 | |
| 4451 | 4789 | webidl-conversions@7.0.0: {} | ... | ... |