From dcf597fe730d2ec2bdd37cc36bb5a04519564438 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 10 Oct 2026 03:32:32 +0000 Subject: [PATCH] feat: voko-server / voko-web 骨架与加入申请(初始化清单第 3–4 步) --- README.md | 14 ++++++++++++++ apps/voko-server/.env.example | 20 ++++++++++++++++++++ apps/voko-server/package.json | 34 ++++++++++++++++++++++++++++++++++ apps/voko-server/src/app.js | 26 ++++++++++++++++++++++++++ apps/voko-server/src/assembly/email-delivery-log.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/assembly/email.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/assembly/google-oauth.js | 43 +++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/assembly/runtime.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/assembly/security.js | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/db/env-file.js | 20 ++++++++++++++++++++ apps/voko-server/src/db/init-current-schema.js | 28 ++++++++++++++++++++++++++++ apps/voko-server/src/db/migrate-database.js | 14 ++++++++++++++ apps/voko-server/src/db/migration-config.js | 11 +++++++++++ apps/voko-server/src/db/migration-status.js | 19 +++++++++++++++++++ apps/voko-server/src/db/migrations/.gitkeep | 0 apps/voko-server/src/db/schema.js | 14 ++++++++++++++ apps/voko-server/src/env.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/main.js | 38 ++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/routes/access.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/routes/auth.js | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/voko-worker-main.js | 35 +++++++++++++++++++++++++++++++++++ apps/voko-server/tools/grant-admin.mjs | 20 ++++++++++++++++++++ apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/index.html | 12 ++++++++++++ apps/voko-web/package.json | 22 ++++++++++++++++++++++ apps/voko-web/src/App.vue | 29 +++++++++++++++++++++++++++++ apps/voko-web/src/api.js | 35 +++++++++++++++++++++++++++++++++++ apps/voko-web/src/i18n/messages.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/main.js | 24 ++++++++++++++++++++++++ apps/voko-web/src/pages/AdminPage.vue | 47 +++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/pages/HomePage.vue | 44 ++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/pages/LoginPage.vue | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/vite.config.js | 16 ++++++++++++++++ docs/voko/upstream.md | 1 + ecosystem.config.cjs | 7 +++++++ packages/voko-access/package.json | 18 ++++++++++++++++++ packages/voko-access/src/access-requests.js | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/voko-access/src/db/tables/index.cjs | 7 +++++++ packages/voko-access/src/db/tables/vk_access_requests.cjs | 18 ++++++++++++++++++ packages/voko-access/src/index.js | 11 +++++++++++ packages/voko-access/test/access-request-from-row.test.js | 30 ++++++++++++++++++++++++++++++ pnpm-lock.yaml | 338 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 42 files changed, 1988 insertions(+), 0 deletions(-) create mode 100644 apps/voko-server/.env.example create mode 100644 apps/voko-server/package.json create mode 100644 apps/voko-server/src/app.js create mode 100644 apps/voko-server/src/assembly/email-delivery-log.js create mode 100644 apps/voko-server/src/assembly/email.js create mode 100644 apps/voko-server/src/assembly/google-oauth.js create mode 100644 apps/voko-server/src/assembly/runtime.js create mode 100644 apps/voko-server/src/assembly/security.js create mode 100644 apps/voko-server/src/db/env-file.js create mode 100644 apps/voko-server/src/db/init-current-schema.js create mode 100644 apps/voko-server/src/db/migrate-database.js create mode 100644 apps/voko-server/src/db/migration-config.js create mode 100644 apps/voko-server/src/db/migration-status.js create mode 100644 apps/voko-server/src/db/migrations/.gitkeep create mode 100644 apps/voko-server/src/db/schema.js create mode 100644 apps/voko-server/src/env.js create mode 100644 apps/voko-server/src/main.js create mode 100644 apps/voko-server/src/routes/access.js create mode 100644 apps/voko-server/src/routes/auth.js create mode 100644 apps/voko-server/src/voko-worker-main.js create mode 100644 apps/voko-server/tools/grant-admin.mjs create mode 100644 apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs create mode 100644 apps/voko-web/index.html create mode 100644 apps/voko-web/package.json create mode 100644 apps/voko-web/src/App.vue create mode 100644 apps/voko-web/src/api.js create mode 100644 apps/voko-web/src/i18n/messages.js create mode 100644 apps/voko-web/src/main.js create mode 100644 apps/voko-web/src/pages/AdminPage.vue create mode 100644 apps/voko-web/src/pages/HomePage.vue create mode 100644 apps/voko-web/src/pages/LoginPage.vue create mode 100644 apps/voko-web/vite.config.js create mode 100644 ecosystem.config.cjs create mode 100644 packages/voko-access/package.json create mode 100644 packages/voko-access/src/access-requests.js create mode 100644 packages/voko-access/src/db/tables/index.cjs create mode 100644 packages/voko-access/src/db/tables/vk_access_requests.cjs create mode 100644 packages/voko-access/src/index.js create mode 100644 packages/voko-access/test/access-request-from-row.test.js diff --git a/README.md b/README.md index 04aca35..c6aeeb9 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,17 @@ pnpm lint && pnpm test ``` 开发规范见 `AGENTS.md`,产品与设计文档见 `docs/voko/README.md`。 + +## 第一次跑起来(骨架) + +```bash +cp apps/voko-server/.env.example apps/voko-server/.env # 填 SMTP;DATABASE_URL 留空即用本地 voko 库 +pnpm mysql:up +pnpm --filter @voko/server db:init # 只对空库执行 +pnpm --filter @voko/server dev # API: http://localhost:9201/healthz +pnpm --filter @voko/web dev # 前端: http://localhost:5201 +``` + +1. 在前端注册一个账号,再执行 `pnpm --filter @voko/server admin:grant 你的邮箱` 把它设为管理员。 +2. 用另一个邮箱注册并提交加入申请,用管理员账号在 `/admin` 审核。 +3. 真实链路验收:`VOKO_TEST_EMAIL=... VOKO_ADMIN_EMAIL=... VOKO_ADMIN_PASSWORD=... pnpm --filter @voko/server verify:skeleton` diff --git a/apps/voko-server/.env.example b/apps/voko-server/.env.example new file mode 100644 index 0000000..68bf7bf --- /dev/null +++ b/apps/voko-server/.env.example @@ -0,0 +1,20 @@ +# 复制为 .env 后填写真实值;.env 不入库。 +NODE_ENV=development +PORT=9201 +LOG_LEVEL=info +# 本地默认:pnpm mysql:up 起的 MySQL,库名 voko(与 @drama/db 的 devMysqlUrl 一致) +DATABASE_URL= +# 生产必须设置,开发缺省使用固定开发值 +AUTH_TOKEN_SECRET= +# 前端地址,用于邮件里的链接 +PUBLIC_URL=http://localhost:5201 +# 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。 +SMTP_HOST= +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER= +SMTP_PASS= +EMAIL_FROM= +# 可选:Google 登录。不填则只开放邮箱注册 + 密码登录。 +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= diff --git a/apps/voko-server/package.json b/apps/voko-server/package.json new file mode 100644 index 0000000..7c0c3a3 --- /dev/null +++ b/apps/voko-server/package.json @@ -0,0 +1,34 @@ +{ + "name": "@voko/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/main.js", + "dev": "node --watch src/main.js", + "worker": "node src/voko-worker-main.js", + "test": "vitest run --passWithNoTests", + "db:init": "node src/db/init-current-schema.js", + "db:migrate": "node src/db/migrate-database.js", + "db:status": "node src/db/migration-status.js", + "admin:grant": "node tools/grant-admin.mjs", + "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs" + }, + "dependencies": { + "@drama/auth": "workspace:*", + "@drama/billing": "workspace:*", + "@drama/contracts": "workspace:*", + "@drama/db": "workspace:*", + "@drama/email": "workspace:*", + "@drama/observability": "workspace:*", + "@drama/server-kit": "workspace:*", + "@voko/access": "workspace:*", + "dotenv": "16.6.1", + "fastify": "^5.1.0", + "googleapis": "^173.0.0", + "valibot": "^1.4.1" + }, + "devDependencies": { + "vitest": "^2.1.4" + } +} diff --git a/apps/voko-server/src/app.js b/apps/voko-server/src/app.js new file mode 100644 index 0000000..95c09f8 --- /dev/null +++ b/apps/voko-server/src/app.js @@ -0,0 +1,26 @@ +import Fastify from 'fastify' +import { registerErrorHandler, registerTrace } from '@drama/server-kit' + +import { registerAccessRoutes } from './routes/access.js' +import { registerAuthRoutes } from './routes/auth.js' + +/** + * 创建并注册一个尚未 listen 的 Fastify app。只消费装配好的 services,不接触连接与 env。 + * @param {{ services: Record, logger: { child: Function } }} deps + * @returns {import('fastify').FastifyInstance} + */ +export function buildApp({ services, logger }) { + const app = Fastify({ logger: false }) + registerTrace(app, { logger }) + registerErrorHandler(app) + + app.get('/healthz', async (request) => ({ + ok: true, + service: '@voko/server', + trace_id: request.ctx && request.ctx.traceId, + })) + + registerAuthRoutes(app, services) + registerAccessRoutes(app, services) + return app +} diff --git a/apps/voko-server/src/assembly/email-delivery-log.js b/apps/voko-server/src/assembly/email-delivery-log.js new file mode 100644 index 0000000..49d6f1d --- /dev/null +++ b/apps/voko-server/src/assembly/email-delivery-log.js @@ -0,0 +1,69 @@ +import { createHash, randomUUID } from 'node:crypto' + +/** + * @param {import('knex').Knex|undefined} db + * @param {{ + * id?: string, + * provider?: string, + * messageId?: string|null, + * purpose: string, + * toEmail?: string, + * status: string, + * template?: string, + * errorMessage?: string, + * metadata?: any + * }} record + */ +export async function recordEmailDelivery(db, record) { + if (!db || typeof db !== 'function') throw new Error('recordEmailDelivery requires db') + if (!(await db.schema.hasTable('email_delivery_logs'))) throw new Error('recordEmailDelivery requires email_delivery_logs table') + const toEmail = requiredString(record.toEmail, 'toEmail') + const purpose = requiredString(record.purpose, 'purpose') + const status = requiredString(record.status, 'status') + const row = { + id: record.id || deliveryLogId(record), + provider: record.provider || null, + message_id: record.messageId || null, + purpose, + to_email: toEmail, + status, + template: record.template || null, + error_message: record.errorMessage || null, + metadata_json: record.metadata ? JSON.stringify(record.metadata) : null, + updated_at: db.fn.now(), + } + await db('email_delivery_logs') + .insert(row) + .onConflict('id') + .merge({ + provider: row.provider, + message_id: row.message_id, + purpose: row.purpose, + to_email: row.to_email, + status: row.status, + template: row.template, + error_message: row.error_message, + metadata_json: row.metadata_json, + updated_at: row.updated_at, + }) +} + +/** + * @param {{ purpose: string, toEmail?: string }} record + */ +function deliveryLogId(record) { + const purpose = String(record.purpose || 'email').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32) + const toEmail = String(record.toEmail || '').trim().toLowerCase() + const entropy = `${Date.now()}:${randomUUID()}` + const digest = createHash('sha1').update(`${purpose}:${toEmail}:${entropy}`).digest('hex').slice(0, 24) + return `email_${purpose}_${digest}` +} + +/** + * @param {unknown} value + * @param {string} field + */ +function requiredString(value, field) { + if (typeof value === 'string' && value.trim().length > 0) return value + throw new Error(`recordEmailDelivery requires ${field}`) +} diff --git a/apps/voko-server/src/assembly/email.js b/apps/voko-server/src/assembly/email.js new file mode 100644 index 0000000..c31f814 --- /dev/null +++ b/apps/voko-server/src/assembly/email.js @@ -0,0 +1,91 @@ +/** + * 发信端口:验证码(auth 的 EmailPort.sendCode)与加入申请通过通知。 + * 每次投递写入 email_delivery_logs。本文件不读 `process.env`,配置经 env 注入。 + */ + +import { createEmail } from '@drama/email' +import { recordEmailDelivery } from './email-delivery-log.js' + +const EMAIL_LOCALES = Object.freeze(['en', 'zh', 'ja', 'ko', 'es', 'pt']) + +/** + * @param {{ env: import('../env.js').VokoEnv, db: import('knex').Knex, logger: { error: Function } }} deps + */ +export function createEmailPorts({ env, db, logger }) { + const { host, port, secure, user, pass, from } = env.email + const email = createEmail({ + provider: 'nodemailer', + credentials: { host, port, secure, auth: { user, pass }, defaultFrom: from }, + }) + + /** @type {import('@drama/auth').EmailPort} */ + const authEmail = { + async sendCode({ to, email: address, code, purpose, locale }) { + const toEmail = to || address + const result = await email.sendTemplate('verify-code', { + to: toEmail, + from, + code, + ttlMin: 10, + locale: emailLocale(locale), + }) + await recordEmailDelivery(db, { + provider: result.provider || 'nodemailer', + messageId: result.messageId || null, + purpose: purpose || 'verify', + toEmail, + status: 'sent', + template: 'verify-code', + metadata: env.nodeEnv === 'production' ? null : { code }, + }) + }, + } + + const accessNotifier = { + /** + * 申请通过通知。暂复用 waitlist-invite 模板(文案为"邀请已就绪,完成注册"),链接指向登录页。 + * @param {object} _ctx + * @param {{ to_email: string }} input + */ + async sendApproved(_ctx, { to_email }) { + try { + const result = await email.sendTemplate('waitlist-invite', { + to: to_email, + from, + locale: 'en', + inviteUrl: `${env.publicUrl}/login`, + }) + await recordEmailDelivery(db, { + provider: result.provider || 'nodemailer', + messageId: result.message_id || null, + purpose: 'access_approved', + toEmail: to_email, + status: 'sent', + template: 'waitlist-invite', + }) + } catch (err) { + // 外部 IO 的最小包裹:审核结果已提交,通知失败只记录,不回滚审核。 + const errorMessage = err instanceof Error ? err.message : String(err) + logger.error('申请通过通知投递失败', { to_email, error: errorMessage }) + await recordEmailDelivery(db, { + provider: 'nodemailer', + purpose: 'access_approved', + toEmail: to_email, + status: 'failed', + template: 'waitlist-invite', + errorMessage, + }) + } + }, + } + + return { authEmail, accessNotifier } +} + +/** + * @param {unknown} value + * @returns {string} + */ +function emailLocale(value) { + return EMAIL_LOCALES.includes(/** @type {string} */ (value)) ? /** @type {string} */ (value) : 'en' +} diff --git a/apps/voko-server/src/assembly/google-oauth.js b/apps/voko-server/src/assembly/google-oauth.js new file mode 100644 index 0000000..4575bdb --- /dev/null +++ b/apps/voko-server/src/assembly/google-oauth.js @@ -0,0 +1,43 @@ +import { google } from 'googleapis' + +/** + * @param {{ clientId: string, clientSecret: string|null }} config + * @returns {import('@drama/auth').OauthPort} + */ +export function createGoogleOauthPort({ clientId, clientSecret }) { + if (!clientId) throw new Error('createGoogleOauthPort: clientId is required') + const client = new google.auth.OAuth2(clientId, clientSecret || undefined) + return { + async exchange({ provider, code, oauth_flow, redirect_uri }) { + if (provider !== 'google') throw new Error(`Unsupported OAuth provider: ${provider}`) + if (oauth_flow === 'authorization_code') { + if (!clientSecret) throw new Error('Google OAuth authorization code flow requires GOOGLE_OAUTH_CLIENT_SECRET') + if (!redirect_uri) throw new Error('Google OAuth authorization code flow requires redirect_uri') + const { tokens } = await client.getToken({ code, redirect_uri }) + if (!tokens.id_token) throw new Error('Google OAuth token response is missing id_token') + const ticket = await client.verifyIdToken({ idToken: tokens.id_token, audience: clientId }) + const payload = ticket.getPayload() + if (!payload?.sub || !payload.email) throw new Error('Google ID token is missing account identity') + return { + provider_user_id: payload.sub, + email: payload.email, + email_verified: payload.email_verified === true, + } + } + if (oauth_flow !== 'access_token') throw new Error(`Unsupported Google OAuth flow: ${oauth_flow}`) + const tokenInfo = await client.getTokenInfo(code) + if (tokenInfo.aud !== clientId) throw new Error('Google access token audience does not match client ID') + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { authorization: `Bearer ${code}` }, + }) + if (!response.ok) throw new Error(`Google userinfo request failed: HTTP ${response.status}`) + const payload = await response.json() + if (!payload.sub || !payload.email) throw new Error('Google userinfo is missing account identity') + return { + provider_user_id: payload.sub, + email: payload.email, + email_verified: payload.email_verified === true, + } + }, + } +} diff --git a/apps/voko-server/src/assembly/runtime.js b/apps/voko-server/src/assembly/runtime.js new file mode 100644 index 0000000..e071c18 --- /dev/null +++ b/apps/voko-server/src/assembly/runtime.js @@ -0,0 +1,64 @@ +/** + * voko-server 的 composition root:读取 env、创建连接、装配服务,并统一管理资源生命周期。 + */ + +import { fileURLToPath } from 'node:url' +import { config as loadDotenv } from 'dotenv' +import { createAuth, createUserRepo } from '@drama/auth' +import { createDb } from '@drama/db' +import { createLogger } from '@drama/observability' +import { createAccessRequests } from '@voko/access' + +import { readEnv } from '../env.js' +import { createEmailPorts } from './email.js' +import { createGoogleOauthPort } from './google-oauth.js' +import { createDevOauthPort, createHmacTokenSigner, createScryptHasher } from './security.js' + +/** + * @param {{ service: string }} opts + */ +export async function createRuntime({ service }) { + // 1. 加载 .env 并解析配置。 + loadDotenv({ path: fileURLToPath(new URL('../../.env', import.meta.url)) }) + const env = readEnv(process.env) + const logger = createLogger({ + service, + level: env.logLevel, + format: env.nodeEnv === 'production' ? 'json' : 'pretty', + }) + + // 2. 连接与基础端口。 + const db = createDb({ url: env.databaseUrl }) + const { authEmail, accessNotifier } = createEmailPorts({ env, db, logger }) + const tokenSigner = createHmacTokenSigner({ secret: env.authTokenSecret }) + const hasher = createScryptHasher() + const oauth = env.oauth.googleClientId === null + ? createDevOauthPort() + : createGoogleOauthPort({ clientId: env.oauth.googleClientId, clientSecret: env.oauth.googleClientSecret }) + + // 3. 领域服务。 + const userRepo = createUserRepo(db) + const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher }) + const access = createAccessRequests({ db, notifier: accessNotifier }) + + /** + * 注册:验证码校验与建用户在同一事务里完成。 + * @param {{ email: string, password: string, verification_code: string, locale: string }} input + */ + async function register(input) { + return db.transaction(async (trx) => { + const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher }) + await trxAuth.verifyCode({ email: input.email, code: input.verification_code, purpose: 'register' }) + return trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() }) + }) + } + + return { + env, + logger, + services: { db, auth, userRepo, access, register, oauthEnabled: env.oauth.googleClientId !== null }, + async close() { + await db.destroy() + }, + } +} diff --git a/apps/voko-server/src/assembly/security.js b/apps/voko-server/src/assembly/security.js new file mode 100644 index 0000000..61dbd96 --- /dev/null +++ b/apps/voko-server/src/assembly/security.js @@ -0,0 +1,125 @@ +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' + +/** + * @typedef {Error & { code?: string }} CodedError + * @typedef {{ logger?: { info?: (...args: any[]) => void } }} DevEmailArgs + * @typedef {{ email?: string, to?: string, code?: string, purpose?: string }} EmailCodeArgs + */ + +/** + * @param {string|Buffer} value + * @returns {string} + */ +function b64url(value) { + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8') + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * @param {string} value + * @returns {Buffer} + */ +function b64urlDecode(value) { + const pad = value.length % 4 === 0 ? '' : '='.repeat(4 - (value.length % 4)) + return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64') +} + +/** + * @param {string} message + * @param {string} code + * @returns {CodedError} + */ +function codedError(message, code) { + const err = /** @type {CodedError} */ (new Error(message)) + err.code = code + return err +} + +export function createScryptHasher() { + const keyLength = 64 + return { + /** + * @param {string} plain + * @returns {Promise} + */ + async hash(plain) { + const salt = randomBytes(16) + const derived = scryptSync(String(plain), salt, keyLength) + return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}` + }, + /** + * @param {string} plain + * @param {string} stored + * @returns {Promise} + */ + async verify(plain, stored) { + if (typeof stored !== 'string') return false + const parts = stored.split('$') + if (parts.length !== 3 || parts[0] !== 'scrypt') return false + const salt = Buffer.from(parts[1], 'hex') + const expected = Buffer.from(parts[2], 'hex') + const derived = scryptSync(String(plain), salt, expected.length) + return expected.length === derived.length && timingSafeEqual(expected, derived) + }, + } +} + +/** + * @param {{ secret: string }} args + * @returns {import('@drama/auth').TokenSigner} + */ +export function createHmacTokenSigner({ secret }) { + if (!secret || typeof secret !== 'string') throw new Error('createHmacTokenSigner: secret required') + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + + /** + * @param {string} input + * @returns {string} + */ + function signature(input) { + return b64url(createHmac('sha256', secret).update(input).digest()) + } + + return { + /** + * @param {object} payload + * @param {{ ttlMs: number }} opts + * @returns {Promise} + */ + async sign(payload, opts) { + const nowSec = Math.floor(Date.now() / 1000) + const claims = /** @type {Record} */ ({ ...payload, iat: nowSec }) + if (opts?.ttlMs && opts.ttlMs > 0) claims.exp = nowSec + Math.floor(opts.ttlMs / 1000) + const body = b64url(JSON.stringify(claims)) + const input = `${header}.${body}` + return `${input}.${signature(input)}` + }, + /** + * @param {string} token + * @returns {Promise} + */ + async verify(token) { + if (typeof token !== 'string') throw codedError('token must be a string', 'TOKEN_INVALID') + const parts = token.split('.') + if (parts.length !== 3) throw codedError('malformed token', 'TOKEN_INVALID') + const [headerPart, bodyPart, sig] = parts + const expected = signature(`${headerPart}.${bodyPart}`) + const a = b64urlDecode(sig) + const b = b64urlDecode(expected) + if (a.length !== b.length || !timingSafeEqual(a, b)) throw codedError('bad signature', 'TOKEN_INVALID') + const claims = /** @type {Record} */ (JSON.parse(b64urlDecode(bodyPart).toString('utf8'))) + if (typeof claims.exp === 'number' && Math.floor(Date.now() / 1000) >= claims.exp) { + throw codedError('token expired', 'ERR_JWT_EXPIRED') + } + return claims + }, + } +} + +export function createDevOauthPort() { + return { + async exchange() { + throw new Error('[voko] oauth.exchange not configured: set GOOGLE_OAUTH_CLIENT_ID') + }, + } +} diff --git a/apps/voko-server/src/db/env-file.js b/apps/voko-server/src/db/env-file.js new file mode 100644 index 0000000..94f1cbf --- /dev/null +++ b/apps/voko-server/src/db/env-file.js @@ -0,0 +1,20 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import dotenv from 'dotenv' +import { devMysqlUrl } from '@drama/db' + +const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + +/** + * 数据库命令读取 `--env `(默认 apps/voko-server/.env)里的 DATABASE_URL; + * 文件不存在或未设置时使用本地开发库 voko。 + * @param {string[]} args + */ +export function databaseUrlFromArgs(args) { + const envIndex = args.indexOf('--env') + const file = envIndex === -1 ? path.join(appRoot, '.env') : path.resolve(args[envIndex + 1]) + if (envIndex !== -1 && !fs.existsSync(file)) throw new Error(`--env file not found: ${file}`) + const env = fs.existsSync(file) ? dotenv.parse(fs.readFileSync(file)) : {} + return env.DATABASE_URL || devMysqlUrl('voko') +} diff --git a/apps/voko-server/src/db/init-current-schema.js b/apps/voko-server/src/db/init-current-schema.js new file mode 100644 index 0000000..9f6ab93 --- /dev/null +++ b/apps/voko-server/src/db/init-current-schema.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * 只对确认为空的新库执行:按当前结构建表、建余额视图,并把现有 migration 记为已执行。 + */ +import { recreateBillingViews } from '@drama/billing' +import { applyCurrentSchema, createDb } from '@drama/db' + +import { databaseUrlFromArgs } from './env-file.js' +import { migrationTableName, vokoMigrationConfig } from './migration-config.js' +import { vokoTargetTables } from './schema.js' + +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } }) + +try { + const [rows] = await db.raw('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() LIMIT 1') + if (rows.length > 0) throw new Error(`voko database is not empty: ${rows[0].TABLE_NAME}`) + const createdTables = await applyCurrentSchema(db, vokoTargetTables) + await recreateBillingViews(db) + const [, pending] = await db.migrate.list(vokoMigrationConfig) + const baselined = pending.map((migration) => migration.file) + if (baselined.length > 0) { + const now = new Date() + await db(migrationTableName).insert(baselined.map((name) => ({ name, batch: 1, migration_time: now }))) + } + console.log(JSON.stringify({ ok: true, target: '@voko/server', mode: 'init-current-schema', created_tables: createdTables, baselined_migrations: baselined }, null, 2)) +} finally { + await db.destroy() +} diff --git a/apps/voko-server/src/db/migrate-database.js b/apps/voko-server/src/db/migrate-database.js new file mode 100644 index 0000000..143c156 --- /dev/null +++ b/apps/voko-server/src/db/migrate-database.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { createDb } from '@drama/db' + +import { databaseUrlFromArgs } from './env-file.js' +import { vokoMigrationConfig } from './migration-config.js' + +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } }) + +try { + const [batch, executed] = await db.migrate.latest(vokoMigrationConfig) + console.log(JSON.stringify({ ok: true, target: '@voko/server', mode: 'migrate', batch, executed }, null, 2)) +} finally { + await db.destroy() +} diff --git a/apps/voko-server/src/db/migration-config.js b/apps/voko-server/src/db/migration-config.js new file mode 100644 index 0000000..4f64d98 --- /dev/null +++ b/apps/voko-server/src/db/migration-config.js @@ -0,0 +1,11 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) + +export const migrationTableName = 'voko_schema_migrations' +export const vokoMigrationConfig = { + directory: path.join(here, 'migrations'), + tableName: migrationTableName, + loadExtensions: ['.js'], +} diff --git a/apps/voko-server/src/db/migration-status.js b/apps/voko-server/src/db/migration-status.js new file mode 100644 index 0000000..38f9c8b --- /dev/null +++ b/apps/voko-server/src/db/migration-status.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import { createDb } from '@drama/db' + +import { databaseUrlFromArgs } from './env-file.js' +import { vokoMigrationConfig } from './migration-config.js' + +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } }) + +try { + const [completed, pending] = await db.migrate.list(vokoMigrationConfig) + console.log(JSON.stringify({ + ok: true, + target: '@voko/server', + completed: completed.map((migration) => migration.name), + pending: pending.map((migration) => migration.file), + }, null, 2)) +} finally { + await db.destroy() +} diff --git a/apps/voko-server/src/db/migrations/.gitkeep b/apps/voko-server/src/db/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/apps/voko-server/src/db/migrations/.gitkeep diff --git a/apps/voko-server/src/db/schema.js b/apps/voko-server/src/db/schema.js new file mode 100644 index 0000000..4a8359a --- /dev/null +++ b/apps/voko-server/src/db/schema.js @@ -0,0 +1,14 @@ +import { authDbSchema } from '@drama/auth' +import { billingDbSchema } from '@drama/billing' +import { dbSchema } from '@drama/db' +import { emailDbSchema } from '@drama/email' +import { accessDbSchema } from '@voko/access' + +/** voko 库的当前完整结构(新空库 init 时按此建表)。 */ +export const vokoTargetTables = [ + ...dbSchema.targetTables, + ...authDbSchema.targetTables, + ...billingDbSchema.targetTables, + ...emailDbSchema.targetTables, + ...accessDbSchema.targetTables, +] diff --git a/apps/voko-server/src/env.js b/apps/voko-server/src/env.js new file mode 100644 index 0000000..afcda40 --- /dev/null +++ b/apps/voko-server/src/env.js @@ -0,0 +1,85 @@ +/** + * voko-server 连接与基础设施配置的唯一解析点。业务运行参数由所属模块直接读取。 + */ + +import { devMysqlUrl } from '@drama/db' + +/** + * @typedef {{ + * nodeEnv: string, + * port: number, + * databaseUrl: string, + * logLevel: 'debug'|'info'|'warn'|'error', + * authTokenSecret: string, + * publicUrl: string, + * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string }, + * oauth: { googleClientId: string|null, googleClientSecret: string|null }, + * }} VokoEnv + */ + +/** + * @param {Record} rawEnv + * @returns {VokoEnv} + */ +export function readEnv(rawEnv) { + const nodeEnv = rawEnv.NODE_ENV || 'development' + const databaseUrl = rawEnv.DATABASE_URL || devMysqlUrl('voko') + const scheme = new URL(databaseUrl).protocol + if (scheme !== 'mysql:' && scheme !== 'mysql2:') throw new Error(`voko-server: DATABASE_URL 必须是 MySQL,收到 ${scheme}`) + if (nodeEnv === 'production' && !rawEnv.AUTH_TOKEN_SECRET) throw new Error('voko-server: 生产环境必须设置 AUTH_TOKEN_SECRET') + + const email = { + host: rawEnv.SMTP_HOST, + port: Number(rawEnv.SMTP_PORT || 465), + secure: rawEnv.SMTP_SECURE !== 'false', + user: rawEnv.SMTP_USER, + pass: rawEnv.SMTP_PASS, + from: rawEnv.EMAIL_FROM, + } + const missing = [['SMTP_HOST', email.host], ['SMTP_USER', email.user], ['SMTP_PASS', email.pass], ['EMAIL_FROM', email.from]] + .filter(([, value]) => !value) + .map(([name]) => name) + if (missing.length > 0) throw new Error(`voko-server: 邮件缺少配置,拒绝启动(无 fake 降级):${missing.join(', ')}`) + + return { + nodeEnv, + port: parsePort(rawEnv.PORT, 9201), + databaseUrl, + logLevel: parseLogLevel(rawEnv.LOG_LEVEL), + authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret', + publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''), + email: /** @type {VokoEnv['email']} */ (email), + oauth: { + googleClientId: rawEnv.GOOGLE_OAUTH_CLIENT_ID || null, + googleClientSecret: rawEnv.GOOGLE_OAUTH_CLIENT_SECRET || null, + }, + } +} + +/** + * @param {string|undefined} raw + * @param {number} fallback + */ +function parsePort(raw, fallback) { + if (raw === undefined || raw === '') return fallback + const port = Number(raw) + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`voko-server: PORT 非法:${raw}`) + return port +} + +/** + * @param {string|undefined} raw + * @returns {'debug'|'info'|'warn'|'error'} + */ +function parseLogLevel(raw) { + const level = raw || 'info' + switch (level) { + case 'debug': + case 'info': + case 'warn': + case 'error': + return level + default: + throw new Error(`voko-server: LOG_LEVEL 非法:${level}`) + } +} diff --git a/apps/voko-server/src/main.js b/apps/voko-server/src/main.js new file mode 100644 index 0000000..3f8e363 --- /dev/null +++ b/apps/voko-server/src/main.js @@ -0,0 +1,38 @@ +/** + * `@voko/server` 进程入口:runtime → app → listen → close。 + */ + +import { buildApp } from './app.js' +import { createRuntime } from './assembly/runtime.js' + +async function start() { + const runtime = await createRuntime({ service: '@voko/server' }) + const app = buildApp({ services: runtime.services, logger: runtime.logger }) + await app.listen({ port: runtime.env.port, host: '0.0.0.0' }) + runtime.logger.info('服务已启动', { port: runtime.env.port, environment: runtime.env.nodeEnv }) + return { + async close() { + await app.close() + await runtime.close() + }, + } +} + +start() + .then(({ close }) => { + const stop = async (signal) => { + try { + await close() + process.exit(0) + } catch (err) { + console.error(`voko-server failed to stop after ${signal}:`, err) + process.exit(1) + } + } + process.once('SIGINT', () => stop('SIGINT')) + process.once('SIGTERM', () => stop('SIGTERM')) + }) + .catch((err) => { + console.error('voko-server failed to start:', err) + process.exit(1) + }) diff --git a/apps/voko-server/src/routes/access.js b/apps/voko-server/src/routes/access.js new file mode 100644 index 0000000..89eda0c --- /dev/null +++ b/apps/voko-server/src/routes/access.js @@ -0,0 +1,56 @@ +import * as v from 'valibot' + +import { codedError, requireUser } from './auth.js' + +const SubmitBody = v.object({ note: v.optional(v.pipe(v.string(), v.maxLength(1000))) }) +const ListQuery = v.object({ + status: v.optional(v.picklist(['pending', 'approved', 'rejected']), 'pending'), + limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(200)), '50'), +}) +const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) }) +const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) + +/** + * 加入申请(申请制):用户提交申请并查看排队状态;运营在 /admin 人工审核。 + * @param {import('fastify').FastifyInstance} app + * @param {Record} services + */ +export function registerAccessRoutes(app, services) { + const { access } = services + + app.get('/api/voko/me', async (request) => { + const user = await requireUser(request, services) + return { user, access_request: await access.getForUser(request.ctx, { user_id: user.id }) } + }) + + app.post('/api/voko/access-requests', async (request) => { + const user = await requireUser(request, services) + const body = v.parse(SubmitBody, request.body || {}) + return { access_request: await access.submit(request.ctx, { user_id: user.id, email: user.email, note: body.note ?? null }) } + }) + + app.get('/api/voko/admin/access-requests', async (request) => { + await requireAdmin(request, services) + const query = v.parse(ListQuery, request.query) + return { access_requests: await access.list(request.ctx, { status: query.status, limit: query.limit }) } + }) + + app.post('/api/voko/admin/access-requests/:id/review', async (request) => { + const admin = await requireAdmin(request, services) + const params = v.parse(ReviewParams, request.params) + const body = v.parse(ReviewBody, request.body) + const result = await access.review(request.ctx, { request_id: params.id, reviewer_user_id: admin.id, decision: body.decision }) + if (!result.ok) throw codedError(`Access request cannot be reviewed: ${params.id}`, result.code) + return { access_request: result.request } + }) +} + +/** + * @param {import('fastify').FastifyRequest} request + * @param {Record} services + */ +async function requireAdmin(request, services) { + const user = await requireUser(request, services) + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') + return user +} diff --git a/apps/voko-server/src/routes/auth.js b/apps/voko-server/src/routes/auth.js new file mode 100644 index 0000000..75a083e --- /dev/null +++ b/apps/voko-server/src/routes/auth.js @@ -0,0 +1,105 @@ +import * as v from 'valibot' + +const SendCodeBody = v.object({ + email: v.pipe(v.string(), v.email()), + locale: v.optional(v.string()), +}) + +const RegisterBody = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), + verification_code: v.pipe(v.string(), v.minLength(1)), + locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']), +}) + +const LoginBody = v.variant('method', [ + v.object({ method: v.literal('password'), email: v.pipe(v.string(), v.email()), password: v.pipe(v.string(), v.minLength(1)) }), + v.object({ + method: v.literal('google'), + credential: v.pipe(v.string(), v.minLength(1)), + oauth_flow: v.picklist(['access_token', 'authorization_code']), + redirect_uri: v.optional(v.string()), + }), +]) + +const RefreshBody = v.object({ refresh_token: v.pipe(v.string(), v.minLength(1)) }) + +/** + * 账号:邮箱验证码注册 + 密码登录,或 Google 登录(配置了 GOOGLE_OAUTH_CLIENT_ID 时)。 + * @param {import('fastify').FastifyInstance} app + * @param {Record} services + */ +export function registerAuthRoutes(app, services) { + const { auth } = services + + app.post('/api/voko/auth/send-code', async (request) => { + const body = v.parse(SendCodeBody, request.body) + await auth.sendCode({ email: body.email, purpose: 'register', locale: body.locale, ip: request.ip }) + return { ok: true } + }) + + app.post('/api/voko/auth/register', async (request) => { + const body = v.parse(RegisterBody, request.body) + return sessionResponse(await services.register(body)) + }) + + app.post('/api/voko/auth/login', async (request) => { + const body = v.parse(LoginBody, request.body) + switch (body.method) { + case 'password': + return sessionResponse(await auth.login({ email: body.email, password: body.password, ip: request.ip })) + case 'google': + if (!services.oauthEnabled) throw codedError('Google login is not configured', 'BAD_REQUEST') + return sessionResponse(await auth.oauthLogin({ provider: 'google', code: body.credential, oauth_flow: body.oauth_flow, redirect_uri: body.redirect_uri })) + default: + throw new Error(`Unhandled login method: ${/** @type {any} */ (body).method}`) + } + }) + + app.post('/api/voko/auth/refresh', async (request) => { + const body = v.parse(RefreshBody, request.body) + return sessionResponse(await auth.refresh(body)) + }) + + app.post('/api/voko/auth/logout', async (request) => { + const body = v.parse(RefreshBody, request.body) + return auth.logout(body) + }) +} + +/** + * @param {{ access_token: string, refresh_token: string, user: { id: string, email: string } }} result + */ +function sessionResponse(result) { + return { + access_token: result.access_token, + refresh_token: result.refresh_token, + user: { id: result.user.id, email: result.user.email }, + } +} + +/** + * @param {string} message + * @param {string} code + */ +export function codedError(message, code) { + const err = /** @type {Error & { code: string }} */ (new Error(message)) + err.code = code + return err +} + +/** + * 从 Bearer access token 解析当前用户;缺失或无效即 401。 + * @param {import('fastify').FastifyRequest} request + * @param {Record} services + * @returns {Promise<{ id: string, email: string, role: string|null }>} + */ +export async function requireUser(request, services) { + const header = request.headers.authorization + const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim()) : null + if (match === null) throw codedError('Authorization bearer token is required', 'TOKEN_INVALID') + const claims = await services.auth.verifyAccessToken(match[1]) + const user = await services.userRepo.findById(String(claims.sub)) + if (!user) throw codedError(`User not found: ${claims.sub}`, 'TOKEN_INVALID') + return { id: user.id, email: user.email, role: user.role === undefined ? null : user.role } +} diff --git a/apps/voko-server/src/voko-worker-main.js b/apps/voko-server/src/voko-worker-main.js new file mode 100644 index 0000000..6d99002 --- /dev/null +++ b/apps/voko-server/src/voko-worker-main.js @@ -0,0 +1,35 @@ +/** + * `voko-worker` 进程入口:领取并执行 vk_jobs(W2 起实现)。骨架阶段只做心跳,证明进程可被 PM2 托管。 + */ + +import { createRuntime } from './assembly/runtime.js' + +const TICK_MS = 2000 +const HEARTBEAT_EVERY_TICKS = 30 + +const runtime = await createRuntime({ service: '@voko/worker' }) +let ticks = 0 +let stopping = false + +async function tick() { + // W2:在这里用 FOR UPDATE SKIP LOCKED 领取到期 job(见 SDD「定时任务与事件」)。 + await runtime.services.db.raw('select 1') + ticks += 1 + if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks }) +} + +async function loop() { + while (!stopping) { + await tick() + await new Promise((resolve) => setTimeout(resolve, TICK_MS)) + } + await runtime.close() +} + +runtime.logger.info('worker 已启动', { tick_ms: TICK_MS }) +process.once('SIGINT', () => { stopping = true }) +process.once('SIGTERM', () => { stopping = true }) +loop().catch((err) => { + console.error('voko-worker crashed:', err) + process.exit(1) +}) diff --git a/apps/voko-server/tools/grant-admin.mjs b/apps/voko-server/tools/grant-admin.mjs new file mode 100644 index 0000000..3bdeb71 --- /dev/null +++ b/apps/voko-server/tools/grant-admin.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** + * 把一个已注册用户设为运营管理员(role='admin'),用于登录 /admin 审核加入申请。 + * 用法:pnpm --filter @voko/server admin:grant you@example.com + */ +import { createDb } from '@drama/db' + +import { databaseUrlFromArgs } from '../src/db/env-file.js' + +const email = process.argv[2] +if (!email || email.startsWith('--')) throw new Error('usage: admin:grant [--env file]') +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(3)), pool: { min: 1, max: 1 } }) + +try { + const updated = await db('users').where({ email: email.trim().toLowerCase() }).update({ role: 'admin', updated_at: new Date() }) + if (updated !== 1) throw new Error(`user not found: ${email}`) + console.log(JSON.stringify({ ok: true, email, role: 'admin' })) +} finally { + await db.destroy() +} diff --git a/apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs b/apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs new file mode 100644 index 0000000..8d35d50 --- /dev/null +++ b/apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * 初始化清单「验收」的真实链路脚本:注册 → 申请 → 运营审核 → 积分记账与幂等。 + * + * 前提:voko-server 已启动(VOKO_API,默认 http://localhost:9201),.env 已配置真实 SMTP; + * VOKO_TEST_EMAIL:一个能收信的邮箱,脚本用 plus 地址注册新用户(如 you+voko-xxx@gmail.com); + * VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD:一个已用 admin:grant 设为管理员的账号。 + * 缺任一项输出 BLOCKED 并以 2 退出,不用替身补绿。 + * 结束时只清理本脚本创建的数据。 + */ +import { randomUUID } from 'node:crypto' +import { createBillingRepo, createLedger } from '@drama/billing' +import { createDb } from '@drama/db' + +import { databaseUrlFromArgs } from '../../../src/db/env-file.js' + +const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') +const missing = ['VOKO_TEST_EMAIL', 'VOKO_ADMIN_EMAIL', 'VOKO_ADMIN_PASSWORD'].filter((name) => !process.env[name]) +if (missing.length > 0) { + console.log(JSON.stringify({ status: 'BLOCKED', missing })) + process.exit(2) +} + +const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 2 } }) +const [local, domain] = String(process.env.VOKO_TEST_EMAIL).split('@') +const email = `${local}+voko-skeleton-${Date.now()}@${domain}`.toLowerCase() +const results = [] +let userId = null + +/** + * @param {string} name + * @param {boolean} pass + * @param {unknown} [detail] + */ +function check(name, pass, detail) { + results.push({ name, pass, ...(pass ? {} : { detail }) }) + if (!pass) throw new Error(`FAILED: ${name} ${JSON.stringify(detail)}`) +} + +/** + * @param {string} method + * @param {string} path + * @param {{ body?: unknown, token?: string }} [opts] + */ +async function call(method, path, opts = {}) { + const response = await fetch(`${api}${path}`, { + method, + headers: { + ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }), + ...(opts.token === undefined ? {} : { authorization: `Bearer ${opts.token}` }), + }, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }) + return { status: response.status, json: await response.json() } +} + +try { + const health = await call('GET', '/healthz') + check('healthz', health.status === 200 && health.json.ok === true, health) + + const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } }) + check('send-code', sent.status === 200, sent) + const log = await db('email_delivery_logs').where({ to_email: email, template: 'verify-code', status: 'sent' }).orderBy('created_at', 'desc').first() + check('验证码邮件已投递并记录', log !== undefined, { email }) + const code = JSON.parse(log.metadata_json).code + + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en' } }) + check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered) + userId = registered.json.user.id + const token = registered.json.access_token + + const me0 = await call('GET', '/api/voko/me', { token }) + check('新用户没有申请', me0.status === 200 && me0.json.access_request === null, me0) + + const submitted = await call('POST', '/api/voko/access-requests', { token, body: { note: 'skeleton scenario' } }) + check('提交申请 → pending', submitted.json.access_request.status === 'pending', submitted) + const again = await call('POST', '/api/voko/access-requests', { token, body: {} }) + check('重复提交返回同一申请', again.json.access_request.id === submitted.json.access_request.id, again) + + const forbidden = await call('GET', '/api/voko/admin/access-requests', { token }) + check('普通用户访问后台 → 403', forbidden.status === 403, forbidden) + + const admin = await call('POST', '/api/voko/auth/login', { body: { method: 'password', email: process.env.VOKO_ADMIN_EMAIL, password: process.env.VOKO_ADMIN_PASSWORD } }) + check('admin login', admin.status === 200, admin) + const queue = await call('GET', '/api/voko/admin/access-requests?status=pending&limit=200', { token: admin.json.access_token }) + check('排队列表里有这条申请', queue.json.access_requests.some((r) => r.id === submitted.json.access_request.id), queue.json) + + const approved = await call('POST', `/api/voko/admin/access-requests/${submitted.json.access_request.id}/review`, { token: admin.json.access_token, body: { decision: 'approved' } }) + check('审核通过', approved.status === 200 && approved.json.access_request.status === 'approved', approved) + const twice = await call('POST', `/api/voko/admin/access-requests/${submitted.json.access_request.id}/review`, { token: admin.json.access_token, body: { decision: 'rejected' } }) + check('重复审核 → 409', twice.status === 409, twice) + const notice = await db('email_delivery_logs').where({ to_email: email, purpose: 'access_approved' }).first() + check('通过通知邮件已投递', notice !== undefined && notice.status === 'sent', notice) + + const ledger = createLedger(createBillingRepo(db)) + await ledger.grant({ user_id: userId, amount: 100, source: 'admin', reason: 'admin_adjustment', note: 'skeleton scenario', idempotency_key: `skeleton:grant:${userId}` }) + await ledger.deduct({ user_id: userId, amount: 30, source: 'admin', reason: 'skeleton', idempotency_key: `skeleton:deduct:${userId}` }) + const replay = await ledger.deduct({ user_id: userId, amount: 30, source: 'admin', reason: 'skeleton', idempotency_key: `skeleton:deduct:${userId}` }) + check('同一幂等键重放不重复扣', replay.replayed === true && replay.balance_after === 70, replay) + const view = await db('points_balance_view').where({ user_id: userId }).first() + check('余额视图 = 70', Number(view.balance) === 70, view) + + console.log(JSON.stringify({ status: 'PASS', email, results }, null, 2)) +} catch (err) { + console.log(JSON.stringify({ status: 'FAIL', email, results, error: err instanceof Error ? err.message : String(err) }, null, 2)) + process.exitCode = 1 +} finally { + if (userId !== null) { + await db('billing_ledger').where({ user_id: userId }).del() + await db('vk_access_requests').where({ user_id: userId }).del() + await db('auth_sessions').where({ user_id: userId }).del() + await db('users').where({ id: userId }).del() + } + await db('auth_codes').where({ email }).del() + await db('email_delivery_logs').where({ to_email: email }).del() + await db.destroy() +} diff --git a/apps/voko-web/index.html b/apps/voko-web/index.html new file mode 100644 index 0000000..611475b --- /dev/null +++ b/apps/voko-web/index.html @@ -0,0 +1,12 @@ + + + + + + VOKO + + +
+ + + diff --git a/apps/voko-web/package.json b/apps/voko-web/package.json new file mode 100644 index 0000000..de1444f --- /dev/null +++ b/apps/voko-web/package.json @@ -0,0 +1,22 @@ +{ + "name": "@voko/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@drama/ui": "workspace:*", + "vue": "^3.5.0", + "vue-i18n": "^11.0.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@drama/config": "workspace:*", + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^6.0.0" + } +} diff --git a/apps/voko-web/src/App.vue b/apps/voko-web/src/App.vue new file mode 100644 index 0000000..72a71e7 --- /dev/null +++ b/apps/voko-web/src/App.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/apps/voko-web/src/api.js b/apps/voko-web/src/api.js new file mode 100644 index 0000000..ee782e1 --- /dev/null +++ b/apps/voko-web/src/api.js @@ -0,0 +1,35 @@ +/** + * voko-server 网页接口的唯一调用点。access token 存在 sessionStorage(Alpha 内部使用)。 + */ + +const TOKEN_KEY = 'voko.access_token' + +export function getToken() { + return sessionStorage.getItem(TOKEN_KEY) +} + +/** @param {string|null} token */ +export function setToken(token) { + if (token === null) sessionStorage.removeItem(TOKEN_KEY) + else sessionStorage.setItem(TOKEN_KEY, token) +} + +/** + * @param {string} method + * @param {string} path + * @param {unknown} [body] + */ +export async function api(method, path, body) { + const token = getToken() + const response = await fetch(path, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(token === null ? {} : { authorization: `Bearer ${token}` }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const json = await response.json() + if (!response.ok) throw new Error(json.error.message) + return json +} diff --git a/apps/voko-web/src/i18n/messages.js b/apps/voko-web/src/i18n/messages.js new file mode 100644 index 0000000..e3c8521 --- /dev/null +++ b/apps/voko-web/src/i18n/messages.js @@ -0,0 +1,71 @@ +/** + * voko-web 文案。Alpha 为内部使用,先提供 en / zh;对外前补齐 ja / ko / es / pt。 + */ +export const messages = { + en: { + login: { + title: 'Sign in to VOKO', + registerTitle: 'Create a VOKO account', + email: 'Email', + password: 'Password', + code: 'Verification code', + sendCode: 'Send code', + codeSent: 'Code sent. Check your inbox.', + submit: 'Sign in', + register: 'Create account', + toRegister: 'No account? Register', + toLogin: 'Have an account? Sign in', + }, + home: { + title: 'Your VOKO access', + notApplied: 'Apply to join. An operator reviews each request.', + note: 'Anything we should know (optional)', + apply: 'Apply to join', + admin: 'Review access requests', + status: { + pending: 'Your request is in the queue.', + approved: 'Approved. You can claim your agents.', + rejected: 'Your request was not approved.', + }, + }, + admin: { + title: 'Access requests', + empty: 'No pending requests.', + approve: 'Approve', + reject: 'Reject', + }, + }, + zh: { + login: { + title: '登录 VOKO', + registerTitle: '注册 VOKO 账号', + email: '邮箱', + password: '密码', + code: '验证码', + sendCode: '发送验证码', + codeSent: '验证码已发送,请查收邮件。', + submit: '登录', + register: '注册', + toRegister: '没有账号?去注册', + toLogin: '已有账号?去登录', + }, + home: { + title: '我的 VOKO 准入', + notApplied: '提交加入申请,运营会人工审核。', + note: '补充说明(选填)', + apply: '申请加入', + admin: '审核加入申请', + status: { + pending: '你的申请正在排队。', + approved: '已通过,可以认领 Agent 了。', + rejected: '你的申请未通过。', + }, + }, + admin: { + title: '加入申请', + empty: '没有待审核的申请。', + approve: '通过', + reject: '拒绝', + }, + }, +} diff --git a/apps/voko-web/src/main.js b/apps/voko-web/src/main.js new file mode 100644 index 0000000..9f09dda --- /dev/null +++ b/apps/voko-web/src/main.js @@ -0,0 +1,24 @@ +import { createApp } from 'vue' +import { createI18n } from 'vue-i18n' +import { createRouter, createWebHistory } from 'vue-router' +import '@drama/ui/theme.css' + +import App from './App.vue' +import { messages } from './i18n/messages.js' +import AdminPage from './pages/AdminPage.vue' +import HomePage from './pages/HomePage.vue' +import LoginPage from './pages/LoginPage.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: HomePage }, + { path: '/login', component: LoginPage }, + { path: '/admin', component: AdminPage }, + ], +}) + +const locale = navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en' +const i18n = createI18n({ legacy: false, locale, fallbackLocale: 'en', messages }) + +createApp(App).use(router).use(i18n).mount('#app') diff --git a/apps/voko-web/src/pages/AdminPage.vue b/apps/voko-web/src/pages/AdminPage.vue new file mode 100644 index 0000000..18158ff --- /dev/null +++ b/apps/voko-web/src/pages/AdminPage.vue @@ -0,0 +1,47 @@ + + + diff --git a/apps/voko-web/src/pages/HomePage.vue b/apps/voko-web/src/pages/HomePage.vue new file mode 100644 index 0000000..7396d2f --- /dev/null +++ b/apps/voko-web/src/pages/HomePage.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/voko-web/src/pages/LoginPage.vue b/apps/voko-web/src/pages/LoginPage.vue new file mode 100644 index 0000000..a50e06c --- /dev/null +++ b/apps/voko-web/src/pages/LoginPage.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/voko-web/vite.config.js b/apps/voko-web/vite.config.js new file mode 100644 index 0000000..b813336 --- /dev/null +++ b/apps/voko-web/vite.config.js @@ -0,0 +1,16 @@ +import { defineWebPreset } from '@drama/config' +import { defineConfig, loadEnv } from 'vite' +import { fileURLToPath } from 'node:url' + +const appRoot = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig(({ command, mode }) => { + const appEnv = loadEnv(mode, appRoot, '') + return defineWebPreset({ + root: '.', + server: { + port: 5201, + proxy: { '/api': appEnv.VITE_API_PROXY || 'http://localhost:9201' }, + }, + }, { env: appEnv, command }) +}) diff --git a/docs/voko/upstream.md b/docs/voko/upstream.md index 0758d45..2199f89 100644 --- a/docs/voko/upstream.md +++ b/docs/voko/upstream.md @@ -17,6 +17,7 @@ | `tools/scripts/mysql-dev.js` | 默认库 `voko`,默认 env 文件 `apps/voko-server/.env` | | `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) | | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` | +| `apps/voko-server/src/assembly/security.js`、`google-oauth.js`、`email-delivery-log.js` | 从 visual-game-server 原样复制(security.js 只改了一条报错文案) | ## 待裁剪(原样搬入,后续处理) diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..ac936e7 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,7 @@ +// PM2:voko-server 与 voko-worker 两个常驻进程。新增或调整后执行 `pm2 save`。 +module.exports = { + apps: [ + { name: 'voko-server', cwd: 'apps/voko-server', script: 'src/main.js', interpreter: 'node' }, + { name: 'voko-worker', cwd: 'apps/voko-server', script: 'src/voko-worker-main.js', interpreter: 'node' }, + ], +} diff --git a/packages/voko-access/package.json b/packages/voko-access/package.json new file mode 100644 index 0000000..6eeafec --- /dev/null +++ b/packages/voko-access/package.json @@ -0,0 +1,18 @@ +{ + "name": "@voko/access", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "valibot": "^1.4.1" + }, + "devDependencies": { + "vitest": "^2.1.4" + } +} diff --git a/packages/voko-access/src/access-requests.js b/packages/voko-access/src/access-requests.js new file mode 100644 index 0000000..9bf59aa --- /dev/null +++ b/packages/voko-access/src/access-requests.js @@ -0,0 +1,160 @@ +import { randomUUID } from 'node:crypto' + +const TABLE = 'vk_access_requests' + +/** 加入申请的全部状态;消费方 switch 必须穷举。 */ +export const ACCESS_REQUEST_STATUSES = Object.freeze(['pending', 'approved', 'rejected']) + +/** + * @typedef {{ + * id: string, + * user_id: string, + * email: string, + * note: string|null, + * status: 'pending'|'approved'|'rejected', + * created_at: Date, + * reviewed_by_user_id: string|null, + * reviewed_at: Date|null, + * }} AccessRequest + */ + +/** + * DB 行 → AccessRequest 的唯一构造点。按状态保证必有字段,非法即 throw。 + * @param {Record} row + * @returns {AccessRequest} + */ +export function accessRequestFromRow(row) { + const id = requiredString(row.id, 'id', row.id) + const status = row.status + switch (status) { + case 'pending': + if (row.reviewed_at !== null || row.reviewed_by_user_id !== null) { + throw new Error(`vk_access_requests pending row has review fields, id: ${id}`) + } + break + case 'approved': + case 'rejected': + if (!(row.reviewed_at instanceof Date)) throw new Error(`vk_access_requests.reviewed_at missing, id: ${id}`) + requiredString(row.reviewed_by_user_id, 'reviewed_by_user_id', id) + break + default: + throw new Error(`Unhandled status: ${status}, id: ${id}`) + } + if (!(row.created_at instanceof Date)) throw new Error(`vk_access_requests.created_at missing, id: ${id}`) + return { + id, + user_id: requiredString(row.user_id, 'user_id', id), + email: requiredString(row.email, 'email', id), + note: row.note === null ? null : requiredString(row.note, 'note', id), + status, + created_at: row.created_at, + reviewed_by_user_id: row.reviewed_by_user_id, + reviewed_at: row.reviewed_at, + } +} + +/** + * @param {{ + * db: import('knex').Knex, + * notifier: { sendApproved(ctx: object, input: { to_email: string }): Promise }, + * }} deps + */ +export function createAccessRequests({ db, notifier }) { + if (typeof db !== 'function') throw new Error('createAccessRequests: db is required') + if (!notifier || typeof notifier.sendApproved !== 'function') throw new Error('createAccessRequests: notifier is required') + + /** + * 提交申请。同一用户只有一条申请(user_id UNIQUE),重复提交返回已有申请。 + * @param {object} ctx + * @param {{ user_id: string, email: string, note: string|null }} input + * @returns {Promise} + */ + async function submit(ctx, { user_id, email, note }) { + await db(TABLE) + .insert({ id: `ar_${randomUUID()}`, user_id, email, note, status: 'pending', created_at: new Date(), reviewed_by_user_id: null, reviewed_at: null }) + .onConflict('user_id') + .ignore() + return getForUser(ctx, { user_id }) + } + + /** + * @param {object} _ctx + * @param {{ user_id: string }} input + * @returns {Promise} + */ + async function getForUser(_ctx, { user_id }) { + const row = await db(TABLE).where({ user_id }).first() + return row === undefined ? null : accessRequestFromRow(row) + } + + /** + * @param {object} ctx + * @param {{ user_id: string }} input + * @returns {Promise} + */ + async function isApproved(ctx, { user_id }) { + const request = await getForUser(ctx, { user_id }) + if (request === null) return false + switch (request.status) { + case 'approved': + return true + case 'pending': + case 'rejected': + return false + default: + throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`) + } + } + + /** + * 按排队顺序列出申请。 + * @param {object} _ctx + * @param {{ status: 'pending'|'approved'|'rejected', limit: number }} input + * @returns {Promise} + */ + async function list(_ctx, { status, limit }) { + const rows = await db(TABLE).where({ status }).orderBy([{ column: 'created_at', order: 'asc' }, { column: 'id', order: 'asc' }]).limit(limit) + return rows.map(accessRequestFromRow) + } + + /** + * 运营审核。只有 pending 能被审核;并发或重复审核返回 CONFLICT 业务错误。 + * 通过时在事务提交后发邮件通知。 + * @param {object} ctx + * @param {{ request_id: string, reviewer_user_id: string, decision: 'approved'|'rejected' }} input + * @returns {Promise<{ ok: true, request: AccessRequest } | { ok: false, code: 'NOT_FOUND'|'CONFLICT' }>} + */ + async function review(ctx, { request_id, reviewer_user_id, decision }) { + const reviewedAt = new Date() + const updated = await db(TABLE) + .where({ id: request_id, status: 'pending' }) + .update({ status: decision, reviewed_by_user_id: reviewer_user_id, reviewed_at: reviewedAt }) + const row = await db(TABLE).where({ id: request_id }).first() + if (row === undefined) return { ok: false, code: 'NOT_FOUND' } + if (updated === 0) return { ok: false, code: 'CONFLICT' } + const request = accessRequestFromRow(row) + switch (decision) { + case 'approved': + await notifier.sendApproved(ctx, { to_email: request.email }) + break + case 'rejected': + break + default: + throw new Error(`Unhandled decision: ${decision}, id: ${request_id}`) + } + return { ok: true, request } + } + + return { submit, getForUser, isApproved, list, review } +} + +/** + * @param {unknown} value + * @param {string} field + * @param {unknown} id + * @returns {string} + */ +function requiredString(value, field, id) { + if (typeof value === 'string' && value.length > 0) return value + throw new Error(`vk_access_requests.${field} invalid, id: ${String(id)}`) +} diff --git a/packages/voko-access/src/db/tables/index.cjs b/packages/voko-access/src/db/tables/index.cjs new file mode 100644 index 0000000..5a3159b --- /dev/null +++ b/packages/voko-access/src/db/tables/index.cjs @@ -0,0 +1,7 @@ +const tableModules = [ + require('./vk_access_requests.cjs'), +] + +const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) + +module.exports = { targetTables } diff --git a/packages/voko-access/src/db/tables/vk_access_requests.cjs b/packages/voko-access/src/db/tables/vk_access_requests.cjs new file mode 100644 index 0000000..0cbcba3 --- /dev/null +++ b/packages/voko-access/src/db/tables/vk_access_requests.cjs @@ -0,0 +1,18 @@ +const tableName = 'vk_access_requests' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('user_id', 64).notNullable() + t.string('email', 320).notNullable() + t.string('note', 1000).nullable() + t.string('status', 16).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.string('reviewed_by_user_id', 64).nullable() + t.datetime('reviewed_at', { precision: 3 }).nullable() + t.unique(['user_id'], { indexName: 'uq_vk_access_requests_user' }) + t.index(['status', 'created_at', 'id'], 'idx_vk_access_requests_queue') + t.check("status in ('pending','approved','rejected')", [], 'ck_vk_access_requests_status') + 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') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-access/src/index.js b/packages/voko-access/src/index.js new file mode 100644 index 0000000..cdec300 --- /dev/null +++ b/packages/voko-access/src/index.js @@ -0,0 +1,11 @@ +/** + * `@voko/access` —— VOKO 的准入:加入申请(申请制,运营后台人工审核)。 + * + * Alpha 不设邮箱白名单:任何邮箱都可注册并提交申请,只有 approved 的用户才能认领 Agent、 + * 在网页上发任务。后续的 Agent 注册、认领、api_key 也放在本包。 + * + * 边界:不读 `process.env`、不建连接;db 与通知端口由 composition root 注入。 + */ + +export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' +export { default as accessDbSchema } from './db/tables/index.cjs' diff --git a/packages/voko-access/test/access-request-from-row.test.js b/packages/voko-access/test/access-request-from-row.test.js new file mode 100644 index 0000000..95aebed --- /dev/null +++ b/packages/voko-access/test/access-request-from-row.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { accessRequestFromRow, ACCESS_REQUEST_STATUSES } from '../src/access-requests.js' + +const base = { + id: 'ar_1', + user_id: 'u_1', + email: 'a@example.com', + note: null, + created_at: new Date('2026-10-10T00:00:00Z'), +} + +describe('accessRequestFromRow 覆盖全部状态', () => { + it('pending 不能带审核字段', () => { + expect(accessRequestFromRow({ ...base, status: 'pending', reviewed_at: null, reviewed_by_user_id: null }).status).toBe('pending') + expect(() => accessRequestFromRow({ ...base, status: 'pending', reviewed_at: new Date(), reviewed_by_user_id: 'u_admin' })).toThrow(/ar_1/) + }) + + it.each(['approved', 'rejected'])('%s 必须有审核人和审核时间', (status) => { + expect(accessRequestFromRow({ ...base, status, reviewed_at: new Date(), reviewed_by_user_id: 'u_admin' }).status).toBe(status) + expect(() => accessRequestFromRow({ ...base, status, reviewed_at: null, reviewed_by_user_id: 'u_admin' })).toThrow(/reviewed_at/) + }) + + it('未知状态直接 throw', () => { + expect(() => accessRequestFromRow({ ...base, status: 'archived', reviewed_at: null, reviewed_by_user_id: null })).toThrow(/Unhandled status: archived/) + }) + + it('状态全集与 DB CHECK 一致', () => { + expect(ACCESS_REQUEST_STATUSES).toEqual(['pending', 'approved', 'rejected']) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb07587..4db91cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,74 @@ importers: specifier: ^2.1.4 version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) + apps/voko-server: + dependencies: + '@drama/auth': + specifier: workspace:* + version: link:../../packages/auth + '@drama/billing': + specifier: workspace:* + version: link:../../packages/billing + '@drama/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@drama/db': + specifier: workspace:* + version: link:../../packages/db + '@drama/email': + specifier: workspace:* + version: link:../../packages/email + '@drama/observability': + specifier: workspace:* + version: link:../../packages/observability + '@drama/server-kit': + specifier: workspace:* + version: link:../../packages/server-kit + '@voko/access': + specifier: workspace:* + version: link:../../packages/voko-access + dotenv: + specifier: 16.6.1 + version: 16.6.1 + fastify: + specifier: ^5.1.0 + version: 5.8.5 + googleapis: + specifier: ^173.0.0 + version: 173.0.0 + valibot: + specifier: ^1.4.1 + version: 1.4.1(typescript@5.9.3) + devDependencies: + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) + + apps/voko-web: + dependencies: + '@drama/ui': + specifier: workspace:* + version: link:../../packages/ui + vue: + specifier: ^3.5.0 + version: 3.5.38(typescript@5.9.3) + vue-i18n: + specifier: ^11.0.0 + version: 11.4.5(vue@3.5.38(typescript@5.9.3)) + vue-router: + specifier: ^4.4.0 + version: 4.6.4(vue@3.5.38(typescript@5.9.3)) + devDependencies: + '@drama/config': + specifier: workspace:* + version: link:../../packages/config + '@vitejs/plugin-vue': + specifier: ^5.2.0 + 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)) + vite: + specifier: ^6.0.0 + version: 6.4.3(@types/node@24.13.2)(sass@1.101.0)(yaml@2.9.0) + packages/auth: dependencies: argon2: @@ -208,6 +276,16 @@ importers: specifier: ^3.5.38 version: 3.5.38(typescript@5.9.3) + packages/voko-access: + dependencies: + valibot: + specifier: ^1.4.1 + version: 1.4.1(typescript@5.9.3) + devDependencies: + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) + packages: '@asamuzakjp/css-color@3.2.0': @@ -1151,6 +1229,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1226,6 +1308,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1460,6 +1546,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1487,6 +1577,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1504,10 +1598,26 @@ packages: resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} engines: {node: '>=14'} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + gcp-metadata@6.1.1: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.4: + resolution: {integrity: sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==} + engines: {node: '>=18'} + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -1546,6 +1656,14 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} @@ -1554,6 +1672,18 @@ packages: resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} engines: {node: '>=14'} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + googleapis-common@8.0.3: + resolution: {integrity: sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==} + engines: {node: '>=18'} + + googleapis@173.0.0: + resolution: {integrity: sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1562,6 +1692,10 @@ packages: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1838,6 +1972,11 @@ packages: resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} engines: {node: ^18 || ^20 || >= 21} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1847,6 +1986,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -1866,6 +2009,10 @@ packages: nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -1979,6 +2126,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -2033,6 +2184,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2086,6 +2241,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2231,6 +2402,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2363,6 +2537,11 @@ packages: peerDependencies: vue: ^3.0.0 + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + vue@3.5.38: resolution: {integrity: sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==} peerDependencies: @@ -2375,6 +2554,10 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3178,6 +3361,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} chai@5.3.3: @@ -3257,6 +3445,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -3546,6 +3736,11 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3581,6 +3776,10 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fsevents@2.3.2: optional: true @@ -3600,6 +3799,23 @@ snapshots: - encoding - supports-color + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + gcp-metadata@6.1.1: dependencies: gaxios: 6.7.1 @@ -3609,6 +3825,22 @@ snapshots: - encoding - supports-color + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.4: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generate-function@2.3.1: dependencies: is-property: 1.0.2 @@ -3658,6 +3890,29 @@ snapshots: globals@14.0.0: {} + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.4 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -3672,6 +3927,26 @@ snapshots: google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} + + googleapis-common@8.0.3: + dependencies: + extend: 3.0.2 + gaxios: 7.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + qs: 6.16.0 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@173.0.0: + dependencies: + google-auth-library: 10.9.1 + googleapis-common: 8.0.3 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} gtoken@7.1.0: @@ -3682,6 +3957,13 @@ snapshots: - encoding - supports-color + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3959,10 +4241,18 @@ snapshots: node-addon-api@8.8.0: {} + node-domexception@1.0.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-gyp-build@4.8.4: {} nodemailer@6.10.1: {} @@ -3977,6 +4267,8 @@ snapshots: nwsapi@2.2.24: {} + object-inspect@1.13.4: {} + on-exit-leak-free@2.1.2: {} optionator@0.9.4: @@ -4098,6 +4390,11 @@ snapshots: punycode@2.3.1: {} + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quick-format-unescaped@4.0.4: {} readdirp@5.0.0: @@ -4136,6 +4433,10 @@ snapshots: rfdc@1.4.1: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rollup@4.62.0: dependencies: '@types/estree': 1.0.9 @@ -4206,6 +4507,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -4323,6 +4652,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-template@2.0.8: {} + util-deprecate@1.0.2: {} uuid@9.0.1: {} @@ -4432,6 +4763,11 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.38(typescript@5.9.3) + vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.38(typescript@5.9.3) + vue@3.5.38(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.38 @@ -4446,6 +4782,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} -- libgit2 0.21.4