From 107eddba9e6febadf3783f36df12eeace469c291 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 15:56:25 +0700 Subject: [PATCH] =?UTF-8?q?perf(db):=20pragma,=20=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8,=20=D0=B8=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=8B,=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B,=20SQL-=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=B8=20=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/app.ts | 3 + apps/api/src/config.ts | 3 + apps/api/src/routes/admin.ts | 16 +- apps/api/src/routes/auth.ts | 17 +- apps/api/src/routes/ingest-audit.ts | 46 ++- apps/api/src/services/housekeeping.ts | 28 ++ apps/api/test/data-layer.test.ts | 95 +++++++ packages/db/package.json | 4 +- packages/db/src/audit-log.ts | 49 +++- packages/db/src/housekeeping.ts | 69 +++++ packages/db/src/index.ts | 40 +++ packages/db/src/oidc.ts | 66 +++-- packages/db/src/tx.ts | 9 + packages/db/src/users.ts | 93 +++--- packages/db/src/webauthn.ts | 11 +- packages/db/test/db.test.ts | 390 ++++++++++++++++++++++++++ packages/db/vitest.config.ts | 5 + pnpm-lock.yaml | 66 ++++- 18 files changed, 871 insertions(+), 139 deletions(-) create mode 100644 apps/api/src/services/housekeeping.ts create mode 100644 apps/api/test/data-layer.test.ts create mode 100644 packages/db/src/housekeeping.ts create mode 100644 packages/db/src/tx.ts create mode 100644 packages/db/test/db.test.ts create mode 100644 packages/db/vitest.config.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 655801a..70815e4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -34,6 +34,7 @@ import { auditIngestRoutes } from './routes/ingest-audit.js' import { oidcRoutes } from './routes/oidc.js' import { webauthnRoutes } from './routes/webauthn.js' import { startAuditRetentionJob } from './services/audit-retention.js' +import { startHousekeepingJob } from './services/housekeeping.js' import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js' /** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */ @@ -169,8 +170,10 @@ export async function buildApp(opts: { if (process.env.NODE_ENV !== 'test') { const stopRetention = startAuditRetentionJob(app) + const stopHousekeeping = startHousekeepingJob(app) app.addHook('onClose', async () => { stopRetention() + stopHousekeeping() }) } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 7808757..ac9e74f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -22,6 +22,8 @@ export const configSchema = z.object({ serverPort: z.coerce.number().int().positive().default(8080), staticDir: z.string().optional(), logLevel: z.string().default('info'), + /** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */ + sessionRetentionDays: z.coerce.number().positive().default(7), /** Максимум на graceful shutdown, затем принудительный выход. */ shutdownTimeoutMs: z.coerce.number().int().positive().default(8000), isProd: z.boolean(), @@ -59,6 +61,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { staticDir: env.STATIC_DIR || undefined, logLevel: env.LOG_LEVEL ?? 'info', shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000, + sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7, isProd: boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd, diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 9021293..a173d75 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -27,6 +27,7 @@ import { setUserAccess, updateOidcClient, updateUser, + withTransaction, } from '@authportal/db' import { APP_IDS, @@ -169,13 +170,16 @@ export async function adminRoutes(app: FastifyInstance): Promise { } const passwordHash = await hash(data.password) - const user = createUser(app.db, { - email: data.email, - name: data.name, - passwordHash, - isAdmin: data.is_admin, + const user = withTransaction(app.db, () => { + const created = createUser(app.db, { + email: data.email, + name: data.name, + passwordHash, + isAdmin: data.is_admin, + }) + setUserAccess(app.db, created.id, data.apps, permissions) + return created }) - setUserAccess(app.db, user.id, data.apps, permissions) const mapped = mapUser(app.db, getUserById(app.db, user.id)!) safeAudit(app, { action: 'user.create', diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index afbb4fb..ca19bba 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -11,7 +11,7 @@ import { getAppSwitcherConfig, getUserByEmail, getUserById, - listUsers, + searchDirectoryUsers, revokeRefreshSession, } from '@authportal/db' import { requireAuth } from '../plugins/auth-guards.js' @@ -237,20 +237,7 @@ export async function authRoutes(app: FastifyInstance): Promise { ) .trim() .toLowerCase() - const all = listUsers(app.db).filter((u) => !u.disabled) - const filtered = q - ? all.filter( - (u) => - u.email.toLowerCase().includes(q) || - u.name.toLowerCase().includes(q) || - u.id.toLowerCase().includes(q), - ) - : all - return filtered.slice(0, 50).map((u) => ({ - id: u.id, - email: u.email, - name: u.name, - })) + return searchDirectoryUsers(app.db, q, 50) }, ) diff --git a/apps/api/src/routes/ingest-audit.ts b/apps/api/src/routes/ingest-audit.ts index b8bf2fc..1d6483c 100644 --- a/apps/api/src/routes/ingest-audit.ts +++ b/apps/api/src/routes/ingest-audit.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify' -import { appendAudit } from '@authportal/db' +import { appendAuditBatch } from '@authportal/db' import { ingestAuditRequestSchema, type AuditSourceApp, @@ -45,30 +45,26 @@ export async function auditIngestRoutes(app: FastifyInstance): Promise { }) } - let accepted = 0 - let duplicates = 0 - for (const event of parsed.data.events) { - try { - const inserted = appendAudit(app.db, { - eventId: event.event_id, - sourceApp: event.source_app as AuditSourceApp, - action: event.action, - severity: event.severity, - actorUserId: event.actor_user_id, - actorEmail: event.actor_email, - actorName: event.actor_name, - targetType: event.target_type, - targetId: event.target_id, - summary: event.summary, - details: event.details, - ip: event.ip, - createdAt: event.created_at, - }) - if (inserted) accepted += 1 - else duplicates += 1 - } catch (err) { - app.log.warn({ err, event_id: event.event_id }, 'ingest append failed') - } + const { accepted, duplicates, failed } = appendAuditBatch( + app.db, + parsed.data.events.map((event) => ({ + eventId: event.event_id, + sourceApp: event.source_app as AuditSourceApp, + action: event.action, + severity: event.severity, + actorUserId: event.actor_user_id, + actorEmail: event.actor_email, + actorName: event.actor_name, + targetType: event.target_type, + targetId: event.target_id, + summary: event.summary, + details: event.details, + ip: event.ip, + createdAt: event.created_at, + })), + ) + if (failed > 0) { + app.log.warn({ failed }, 'ingest append failed for some events') } return { accepted, duplicates } diff --git a/apps/api/src/services/housekeeping.ts b/apps/api/src/services/housekeeping.ts new file mode 100644 index 0000000..2387198 --- /dev/null +++ b/apps/api/src/services/housekeeping.ts @@ -0,0 +1,28 @@ +import type { FastifyInstance } from 'fastify' +import { runHousekeeping } from '@authportal/db' + +const HOUR_MS = 60 * 60 * 1000 + +/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */ +export function startHousekeepingJob(app: FastifyInstance): () => void { + const run = () => { + try { + const res = runHousekeeping(app.db, { + retentionDays: app.config.sessionRetentionDays, + }) + if (res.sessions + res.authCodes + res.challenges > 0) { + app.log.info(res, 'housekeeping purge') + } + } catch (err) { + app.log.warn({ err }, 'housekeeping job failed') + } + } + const initial = setTimeout(run, 30_000) + const interval = setInterval(run, HOUR_MS) + initial.unref() + interval.unref() + return () => { + clearTimeout(initial) + clearInterval(interval) + } +} diff --git a/apps/api/test/data-layer.test.ts b/apps/api/test/data-layer.test.ts new file mode 100644 index 0000000..11d1a5f --- /dev/null +++ b/apps/api/test/data-layer.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { adminToken, buildTestApp } from './helpers.js' +import { startHousekeepingJob } from '../src/services/housekeeping.js' + +let app: Awaited> | undefined + +afterEach(async () => { + vi.useRealTimers() + await app?.close() + app = undefined +}) + +describe('directory/users', () => { + it('отдаёт только id/email/name и ограничивает 50', async () => { + app = await buildTestApp() + const token = await adminToken(app) + for (let i = 0; i < 55; i++) { + app.sqlite + .prepare( + `INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at) + VALUES (?,?,?,?,0,0,'t','t')`, + ) + .run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash') + } + const res = await app.inject({ + method: 'GET', + url: '/api/v1/directory/users', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const body = res.json() as Array> + expect(body).toHaveLength(50) + expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name']) + expect(res.body).not.toContain('hash') + + const q = await app.inject({ + method: 'GET', + url: '/api/v1/directory/users?q=U54', + headers: { authorization: `Bearer ${token}` }, + }) + expect((q.json() as unknown[]).length).toBe(1) + }) +}) + +describe('ingest audit', () => { + const ev = (i: number) => ({ + event_id: `ev-${i}`, + source_app: 'dns', + action: 'dns.test', + severity: 'info', + summary: 'x', + }) + + it('принимает пачку одной транзакцией и считает дубликаты', async () => { + app = await buildTestApp() + const post = (events: unknown[]) => + app!.inject({ + method: 'POST', + url: '/api/v1/ingest/audit', + headers: { authorization: 'Bearer dev-audit-ingest-secret' }, + payload: { events }, + }) + const first = await post([ev(1), ev(2), ev(3)]) + expect(first.json()).toEqual({ accepted: 3, duplicates: 0 }) + const second = await post([ev(3), ev(4)]) + expect(second.json()).toEqual({ accepted: 1, duplicates: 1 }) + }) +}) + +describe('housekeeping job', () => { + it('удаляет устаревшие сессии по таймеру и останавливается', async () => { + vi.useFakeTimers() + app = await buildTestApp() + app.sqlite + .prepare( + `INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at) + VALUES ('uh','h@t.io','H','x',0,0,'t','t')`, + ) + .run() + app.sqlite + .prepare( + `INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at) + VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`, + ) + .run() + const stop = startHousekeepingJob(app) + await vi.advanceTimersByTimeAsync(31_000) + const left = app.sqlite + .prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`) + .get() + expect(left).toEqual({ c: 0 }) + stop() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/packages/db/package.json b/packages/db/package.json index 92ac430..c7de663 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -19,6 +19,7 @@ "build": "tsup src/index.ts --format esm --dts", "lint": "eslint .", "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", "dev": "tsup src/index.ts --format esm --dts --watch", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push" @@ -36,6 +37,7 @@ "globals": "^17.7.0", "tsup": "^8.5.0", "typescript": "catalog:", - "typescript-eslint": "^8.64.0" + "typescript-eslint": "^8.64.0", + "vitest": "catalog:" } } diff --git a/packages/db/src/audit-log.ts b/packages/db/src/audit-log.ts index 71da18f..26c21de 100644 --- a/packages/db/src/audit-log.ts +++ b/packages/db/src/audit-log.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, like, lt, notLike, or, sql } from 'drizzle-orm' +import { and, desc, eq, gte, lt, or, sql } from 'drizzle-orm' import { randomUUID } from 'node:crypto' import { DEFAULT_AUDIT_RETENTION_DAYS, @@ -10,6 +10,7 @@ import { } from '@authportal/shared' import type { AppDb } from './index.js' import { auditLog, portalSettings } from './schema/index.js' +import { withTransaction } from './tx.js' const SETTINGS_ID = 'main' @@ -64,16 +65,9 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean { const now = input.createdAt ?? new Date().toISOString() const eventId = input.eventId ?? null - if (eventId) { - const existing = db - .select({ id: auditLog.id }) - .from(auditLog) - .where(eq(auditLog.eventId, eventId)) - .get() - if (existing) return false - } - - db.insert(auditLog) + // Дубликат event_id отсекается уникальным индексом: без SELECT и гонок. + const res = db + .insert(auditLog) .values({ id: randomUUID(), eventId, @@ -90,8 +84,32 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean { ip: input.ip ?? null, createdAt: now, }) + .onConflictDoNothing() .run() - return true + return res.changes > 0 +} + +/** Пачка событий в одной транзакции — один коммит вместо N. */ +export function appendAuditBatch( + db: AppDb, + inputs: AppendAuditInput[], +): { accepted: number; duplicates: number; failed: number } { + let accepted = 0 + let duplicates = 0 + let failed = 0 + withTransaction(db, () => { + for (const input of inputs) { + try { + // SAVEPOINT: сбой одного события не откатывает остальные. + const ok = withTransaction(db, () => appendAudit(db, input)) + if (ok) accepted += 1 + else duplicates += 1 + } catch { + failed += 1 + } + } + }) + return { accepted, duplicates, failed } } export function listAudit( @@ -112,9 +130,12 @@ export function listAudit( if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity)) if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp)) if (opts.kind === 'logins') { - conditions.push(like(auditLog.action, 'auth.%')) + // Диапазон по индексу action: 'auth.' <= action < 'auth/' ('/' идёт сразу после '.'). + conditions.push(gte(auditLog.action, 'auth.'), lt(auditLog.action, 'auth/')) } else if (opts.kind === 'changes') { - conditions.push(notLike(auditLog.action, 'auth.%')) + conditions.push( + or(lt(auditLog.action, 'auth.'), gte(auditLog.action, 'auth/'))!, + ) } if (opts.actorEmail && opts.userId) { conditions.push( diff --git a/packages/db/src/housekeeping.ts b/packages/db/src/housekeeping.ts new file mode 100644 index 0000000..6a63dc5 --- /dev/null +++ b/packages/db/src/housekeeping.ts @@ -0,0 +1,69 @@ +import { sql } from 'drizzle-orm' +import type { AppDb } from './index.js' + +const BATCH = 1000 + +/** Удаляет пачками по BATCH строк, чтобы не держать писательскую блокировку надолго. */ +function deleteInBatches( + db: AppDb, + table: string, + where: ReturnType, +): number { + let total = 0 + for (;;) { + const res = db.run( + sql`DELETE FROM ${sql.raw(table)} WHERE rowid IN ( + SELECT rowid FROM ${sql.raw(table)} WHERE ${where} LIMIT ${BATCH} + )`, + ) + total += res.changes + if (res.changes < BATCH) return total + } +} + +/** Сессии, истёкшие или отозванные раньше cutoff (ISO). */ +export function purgeStaleRefreshSessions(db: AppDb, cutoffIso: string): number { + return deleteInBatches( + db, + 'refresh_sessions', + sql`expires_at < ${cutoffIso} OR (revoked_at IS NOT NULL AND revoked_at < ${cutoffIso})`, + ) +} + +/** Коды авторизации: истёкшие раньше cutoff, либо использованные раньше cutoff. */ +export function purgeStaleOidcAuthCodes(db: AppDb, cutoffIso: string): number { + return deleteInBatches( + db, + 'oidc_auth_codes', + sql`expires_at < ${cutoffIso} OR (used_at IS NOT NULL AND used_at < ${cutoffIso})`, + ) +} + +export function purgeExpiredChallenges(db: AppDb, nowIso: string): number { + return deleteInBatches(db, 'webauthn_challenges', sql`expires_at < ${nowIso}`) +} + +export type HousekeepingResult = { + sessions: number + authCodes: number + challenges: number +} + +/** + * Одна проходка очистки. Записи хранятся ещё retentionDays после + * истечения/отзыва — для разбора инцидентов в списке сессий. + */ +export function runHousekeeping( + db: AppDb, + opts: { retentionDays: number; now?: Date } = { retentionDays: 7 }, +): HousekeepingResult { + const now = opts.now ?? new Date() + const cutoff = new Date( + now.getTime() - opts.retentionDays * 24 * 60 * 60 * 1000, + ).toISOString() + return { + sessions: purgeStaleRefreshSessions(db, cutoff), + authCodes: purgeStaleOidcAuthCodes(db, cutoff), + challenges: purgeExpiredChallenges(db, now.toISOString()), + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a5b9d4..0cf68d2 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -17,6 +17,13 @@ export function createDb(databaseUrl: string): { db: AppDb; sqlite: Sqlite } { sqlite.pragma('journal_mode = WAL') sqlite.pragma('foreign_keys = ON') sqlite.pragma('synchronous = NORMAL') + // Без busy_timeout любая конкуренция за блокировку сразу даёт SQLITE_BUSY. + sqlite.pragma('busy_timeout = 5000') + sqlite.pragma('cache_size = -16000') // ~16 МиБ + sqlite.pragma('temp_store = MEMORY') + sqlite.pragma('mmap_size = 67108864') // 64 МиБ + sqlite.pragma('wal_autocheckpoint = 1000') + sqlite.pragma('journal_size_limit = 67108864') const db = drizzle(sqlite, { schema }) return { db, sqlite } } @@ -28,7 +35,38 @@ export function createMemoryDb(): { db: AppDb; sqlite: Sqlite } { return { db, sqlite } } +/** Версия схемы в PRAGMA user_version; при совпадении migrateSchema ничего не делает. */ +export const SCHEMA_VERSION = 1 + export function migrateSchema(sqlite: Sqlite): void { + const current = sqlite.pragma('user_version', { simple: true }) as number + if (current >= SCHEMA_VERSION) return + sqlite.transaction(() => { + baseMigrate(sqlite) + migrateV1(sqlite) + sqlite.pragma(`user_version = ${SCHEMA_VERSION}`) + })() +} + +/** v1: дедупликация доступа, уникальные индексы и индексы под очистку/фильтры. */ +function migrateV1(sqlite: Sqlite): void { + sqlite.exec(` + DELETE FROM user_apps WHERE rowid NOT IN ( + SELECT MIN(rowid) FROM user_apps GROUP BY user_id, app_id + ); + DELETE FROM user_permissions WHERE rowid NOT IN ( + SELECT MIN(rowid) FROM user_permissions GROUP BY user_id, permission + ); + CREATE UNIQUE INDEX IF NOT EXISTS uq_user_apps ON user_apps(user_id, app_id); + CREATE UNIQUE INDEX IF NOT EXISTS uq_user_permissions ON user_permissions(user_id, permission); + CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires ON refresh_sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_expires ON oidc_auth_codes(expires_at); + CREATE INDEX IF NOT EXISTS idx_audit_log_severity ON audit_log(severity, created_at); + CREATE INDEX IF NOT EXISTS idx_audit_log_actor_email ON audit_log(actor_email); + `) +} + +function baseMigrate(sqlite: Sqlite): void { sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY NOT NULL, @@ -231,6 +269,8 @@ export function healthCheck(sqlite: Sqlite): void { } export * from './schema/index.js' +export * from './tx.js' +export * from './housekeeping.js' export * from './users.js' export * from './settings.js' export * from './audit-log.js' diff --git a/packages/db/src/oidc.ts b/packages/db/src/oidc.ts index 3edabcd..dfd795a 100644 --- a/packages/db/src/oidc.ts +++ b/packages/db/src/oidc.ts @@ -1,6 +1,7 @@ -import { and, eq, isNull } from 'drizzle-orm' +import { and, desc, eq, gte, isNull } from 'drizzle-orm' import { randomBytes, randomUUID } from 'node:crypto' import type { AppDb } from './index.js' +import { withTransaction } from './tx.js' import { oidcAuthCodes, oidcClients, oidcSigningKeys } from './schema/index.js' import { hashToken } from './users.js' @@ -153,22 +154,20 @@ export function consumeOidcAuthCode( db: AppDb, rawCode: string, ): OidcAuthCodeRow | undefined { - const codeHash = hashToken(rawCode) - const row = db - .select() - .from(oidcAuthCodes) - .where( - and(eq(oidcAuthCodes.codeHash, codeHash), isNull(oidcAuthCodes.usedAt)), - ) - .get() - if (!row) return undefined const now = new Date().toISOString() - if (row.expiresAt < now) return undefined - db.update(oidcAuthCodes) + // Атомарно: UPDATE … RETURNING исключает двойное использование кода. + return db + .update(oidcAuthCodes) .set({ usedAt: now }) - .where(eq(oidcAuthCodes.id, row.id)) - .run() - return row + .where( + and( + eq(oidcAuthCodes.codeHash, hashToken(rawCode)), + isNull(oidcAuthCodes.usedAt), + gte(oidcAuthCodes.expiresAt, now), + ), + ) + .returning() + .get() } export function getActiveOidcSigningKey( @@ -178,8 +177,9 @@ export function getActiveOidcSigningKey( .select() .from(oidcSigningKeys) .where(eq(oidcSigningKeys.active, true)) - .all() - .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0] + .orderBy(desc(oidcSigningKeys.createdAt)) + .limit(1) + .get() } export function listOidcSigningKeys(db: AppDb): OidcSigningKeyRow[] { @@ -194,23 +194,21 @@ export function insertOidcSigningKey( publicJwkJson: string }, ): OidcSigningKeyRow { - for (const key of listOidcSigningKeys(db)) { - if (key.active) { - db.update(oidcSigningKeys) - .set({ active: false }) - .where(eq(oidcSigningKeys.kid, key.kid)) - .run() - } - } - db.insert(oidcSigningKeys) - .values({ - kid: input.kid, - privatePem: input.privatePem, - publicJwkJson: input.publicJwkJson, - active: true, - createdAt: new Date().toISOString(), - }) - .run() + withTransaction(db, () => { + db.update(oidcSigningKeys) + .set({ active: false }) + .where(eq(oidcSigningKeys.active, true)) + .run() + db.insert(oidcSigningKeys) + .values({ + kid: input.kid, + privatePem: input.privatePem, + publicJwkJson: input.publicJwkJson, + active: true, + createdAt: new Date().toISOString(), + }) + .run() + }) return db .select() .from(oidcSigningKeys) diff --git a/packages/db/src/tx.ts b/packages/db/src/tx.ts new file mode 100644 index 0000000..6e99aa8 --- /dev/null +++ b/packages/db/src/tx.ts @@ -0,0 +1,9 @@ +import type { AppDb } from './index.js' + +/** + * Выполняет fn в транзакции better-sqlite3. Вложенные вызовы превращаются + * в SAVEPOINT, поэтому безопасно вызывать из функций, уже работающих в транзакции. + */ +export function withTransaction(db: AppDb, fn: () => T): T { + return db.$client.transaction(fn)() +} diff --git a/packages/db/src/users.ts b/packages/db/src/users.ts index f7470ef..f2563c6 100644 --- a/packages/db/src/users.ts +++ b/packages/db/src/users.ts @@ -1,6 +1,7 @@ -import { and, eq, gt, isNull } from 'drizzle-orm' +import { and, desc, eq, gt, gte, isNull, or, sql } from 'drizzle-orm' import { createHash, randomUUID } from 'node:crypto' import type { AppDb } from './index.js' +import { withTransaction } from './tx.js' import { refreshSessions, userApps, @@ -140,18 +141,54 @@ export function setUserAccess( apps: string[], permissions: string[], ): void { - db.delete(userApps).where(eq(userApps.userId, userId)).run() - db.delete(userPermissions).where(eq(userPermissions.userId, userId)).run() - for (const appId of apps) { - db.insert(userApps).values({ userId, appId }).run() - } - for (const permission of permissions) { - db.insert(userPermissions).values({ userId, permission }).run() - } - db.update(users) - .set({ updatedAt: new Date().toISOString() }) - .where(eq(users.id, userId)) - .run() + const uniqApps = [...new Set(apps)] + const uniqPerms = [...new Set(permissions)] + withTransaction(db, () => { + db.delete(userApps).where(eq(userApps.userId, userId)).run() + db.delete(userPermissions).where(eq(userPermissions.userId, userId)).run() + if (uniqApps.length > 0) { + db.insert(userApps) + .values(uniqApps.map((appId) => ({ userId, appId }))) + .run() + } + if (uniqPerms.length > 0) { + db.insert(userPermissions) + .values(uniqPerms.map((permission) => ({ userId, permission }))) + .run() + } + db.update(users) + .set({ updatedAt: new Date().toISOString() }) + .where(eq(users.id, userId)) + .run() + }) +} + +/** Поиск для каталога пользователей: только нужные колонки, LIKE и LIMIT в SQL. */ +export function searchDirectoryUsers( + db: AppDb, + query: string, + limit = 50, +): Array<{ id: string; email: string; name: string }> { + const q = query.trim().toLowerCase().replace(/[\\%_]/g, (c) => `\\${c}`) + const pattern = `%${q}%` + return db + .select({ id: users.id, email: users.email, name: users.name }) + .from(users) + .where( + and( + eq(users.disabled, false), + q + ? or( + sql`lower(${users.email}) LIKE ${pattern} ESCAPE '\\'`, + sql`lower(${users.name}) LIKE ${pattern} ESCAPE '\\'`, + sql`lower(${users.id}) LIKE ${pattern} ESCAPE '\\'`, + ) + : undefined, + ), + ) + .orderBy(users.email) + .limit(limit) + .all() } export function touchLastLogin( @@ -235,7 +272,7 @@ export function listActiveSessions( opts: { userId?: string } = {}, ): ActiveSessionRow[] { const now = new Date().toISOString() - const rows = db + return db .select({ id: refreshSessions.id, userId: refreshSessions.userId, @@ -245,30 +282,18 @@ export function listActiveSessions( userAgent: refreshSessions.userAgent, createdAt: refreshSessions.createdAt, expiresAt: refreshSessions.expiresAt, - revokedAt: refreshSessions.revokedAt, }) .from(refreshSessions) .innerJoin(users, eq(refreshSessions.userId, users.id)) + .where( + and( + isNull(refreshSessions.revokedAt), + gte(refreshSessions.expiresAt, now), + opts.userId ? eq(refreshSessions.userId, opts.userId) : undefined, + ), + ) + .orderBy(desc(refreshSessions.createdAt)) .all() - - return rows - .filter((r) => { - if (r.revokedAt) return false - if (r.expiresAt < now) return false - if (opts.userId && r.userId !== opts.userId) return false - return true - }) - .map((r) => ({ - id: r.id, - userId: r.userId, - email: r.email, - name: r.name, - ip: r.ip, - userAgent: r.userAgent, - createdAt: r.createdAt, - expiresAt: r.expiresAt, - })) - .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) } export function revokeSessionById(db: AppDb, sessionId: string): boolean { diff --git a/packages/db/src/webauthn.ts b/packages/db/src/webauthn.ts index 165032d..7443afa 100644 --- a/packages/db/src/webauthn.ts +++ b/packages/db/src/webauthn.ts @@ -1,4 +1,4 @@ -import { and, eq, lt, sql } from 'drizzle-orm' +import { and, desc, eq, lt, sql } from 'drizzle-orm' import { randomUUID } from 'node:crypto' import type { AppDb } from './index.js' import { webauthnChallenges, webauthnCredentials } from './schema/index.js' @@ -77,12 +77,17 @@ export function listWebauthnCredentials( .select() .from(webauthnCredentials) .where(eq(webauthnCredentials.userId, userId)) + .orderBy(desc(webauthnCredentials.createdAt)) .all() - .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) } export function countWebauthnCredentials(db: AppDb, userId: string): number { - return listWebauthnCredentials(db, userId).length + const row = db + .select({ total: sql`count(*)` }) + .from(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)) + .get() + return Number(row?.total ?? 0) } /** Число passkey-ов по всем пользователям одним GROUP BY — для списков без N+1. */ diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts new file mode 100644 index 0000000..fb205e9 --- /dev/null +++ b/packages/db/test/db.test.ts @@ -0,0 +1,390 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { afterEach, describe, expect, it } from 'vitest' +import { + SCHEMA_VERSION, + appendAudit, + appendAuditBatch, + closeDb, + consumeOidcAuthCode, + countWebauthnCredentials, + createDb, + createOidcAuthCode, + createRefreshSession, + createUser, + createWebauthnCredential, + getActiveOidcSigningKey, + getUserApps, + getUserPermissions, + insertOidcSigningKey, + listActiveSessions, + listAudit, + migrateSchema, + revokeSessionById, + runHousekeeping, + searchDirectoryUsers, + setUserAccess, + withTransaction, + type AppDb, + type Sqlite, +} from '../src/index.js' + +const open: Sqlite[] = [] +const dirs: string[] = [] + +function memDb(): { db: AppDb; sqlite: Sqlite } { + const r = createDb('sqlite::memory:') + migrateSchema(r.sqlite) + open.push(r.sqlite) + return r +} + +afterEach(() => { + for (const s of open.splice(0)) closeDb(s) + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +function mkUser(db: AppDb, email: string, extra: { name?: string } = {}) { + return createUser(db, { + email, + name: extra.name ?? email.split('@')[0]!, + passwordHash: 'x', + isAdmin: false, + }) +} + +describe('createDb / closeDb', () => { + it('выставляет pragma на файловой БД', () => { + const dir = mkdtempSync(join(tmpdir(), 'ap-db-')) + dirs.push(dir) + const { sqlite } = createDb(join(dir, 'app.db')) + open.push(sqlite) + expect(sqlite.pragma('journal_mode', { simple: true })).toBe('wal') + expect(sqlite.pragma('busy_timeout', { simple: true })).toBe(5000) + expect(sqlite.pragma('synchronous', { simple: true })).toBe(1) + expect(sqlite.pragma('temp_store', { simple: true })).toBe(2) + expect(sqlite.pragma('cache_size', { simple: true })).toBe(-16000) + expect(sqlite.pragma('foreign_keys', { simple: true })).toBe(1) + }) + + it('closeDb идемпотентен', () => { + const { sqlite } = memDb() + closeDb(sqlite) + expect(sqlite.open).toBe(false) + expect(() => closeDb(sqlite)).not.toThrow() + }) +}) + +describe('migrateSchema', () => { + it('ставит user_version и повторный запуск — no-op', () => { + const { sqlite } = memDb() + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect(() => migrateSchema(sqlite)).not.toThrow() + }) + + it('создаёт новые индексы', () => { + const { sqlite } = memDb() + const names = ( + sqlite + .prepare(`SELECT name FROM sqlite_master WHERE type='index'`) + .all() as Array<{ name: string }> + ).map((r) => r.name) + for (const n of [ + 'uq_user_apps', + 'uq_user_permissions', + 'idx_refresh_sessions_expires', + 'idx_oidc_auth_codes_expires', + 'idx_audit_log_severity', + 'idx_audit_log_actor_email', + ]) { + expect(names).toContain(n) + } + }) + + it('мигрирует «старую» схему: дедупликация и недостающие колонки', () => { + const sqlite = new Database(':memory:') + open.push(sqlite) + sqlite.pragma('foreign_keys = ON') + sqlite.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE user_apps (user_id TEXT NOT NULL, app_id TEXT NOT NULL); + CREATE TABLE user_permissions (user_id TEXT NOT NULL, permission TEXT NOT NULL); + INSERT INTO users VALUES ('u1','a@b.c','A','h',0,0,'t','t'); + INSERT INTO user_apps VALUES ('u1','dns'),('u1','dns'),('u1','vps'); + INSERT INTO user_permissions VALUES ('u1','p'),('u1','p'); + `) + migrateSchema(sqlite) + expect( + sqlite.prepare('SELECT COUNT(*) c FROM user_apps').get(), + ).toEqual({ c: 2 }) + expect( + sqlite.prepare('SELECT COUNT(*) c FROM user_permissions').get(), + ).toEqual({ c: 1 }) + const cols = ( + sqlite.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }> + ).map((c) => c.name) + expect(cols).toContain('last_login_at') + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect(() => + sqlite.prepare(`INSERT INTO user_apps VALUES ('u1','dns')`).run(), + ).toThrow(/UNIQUE/) + }) + + it('откатывается целиком при ошибке', () => { + const sqlite = new Database(':memory:') + open.push(sqlite) + // users — это view-подобная помеха: таблица с несовместимой схемой user_apps без user_id + sqlite.exec(`CREATE TABLE user_apps (foo TEXT)`) + expect(() => migrateSchema(sqlite)).toThrow() + expect(sqlite.pragma('user_version', { simple: true })).toBe(0) + }) +}) + +describe('withTransaction / setUserAccess', () => { + it('откатывает все изменения при исключении', () => { + const { db } = memDb() + const u = mkUser(db, 'a@x.io') + setUserAccess(db, u.id, ['dns'], ['p1']) + expect(() => + withTransaction(db, () => { + setUserAccess(db, u.id, ['vps', 'bgp'], ['p2']) + throw new Error('boom') + }), + ).toThrow('boom') + expect(getUserApps(db, u.id)).toEqual(['dns']) + expect(getUserPermissions(db, u.id)).toEqual(['p1']) + }) + + it('схлопывает дубликаты во входе', () => { + const { db } = memDb() + const u = mkUser(db, 'a@x.io') + setUserAccess(db, u.id, ['dns', 'dns'], ['p', 'p']) + expect(getUserApps(db, u.id)).toEqual(['dns']) + expect(getUserPermissions(db, u.id)).toEqual(['p']) + }) + + it('вложенная транзакция откатывается как savepoint', () => { + const { db } = memDb() + const u = mkUser(db, 'a@x.io') + withTransaction(db, () => { + setUserAccess(db, u.id, ['dns'], []) + try { + withTransaction(db, () => { + setUserAccess(db, u.id, ['vps'], []) + throw new Error('inner') + }) + } catch { + /* ожидаемо */ + } + }) + expect(getUserApps(db, u.id)).toEqual(['dns']) + }) +}) + +describe('сессии и каталог', () => { + it('listActiveSessions: только активные, фильтр по пользователю, сортировка', () => { + const { db } = memDb() + const a = mkUser(db, 'a@x.io') + const b = mkUser(db, 'b@x.io') + const future = new Date(Date.now() + 3600_000) + const past = new Date(Date.now() - 3600_000) + const s1 = createRefreshSession(db, a.id, 't1', future) + createRefreshSession(db, a.id, 't2', past) + const s3 = createRefreshSession(db, b.id, 't3', future) + const s4 = createRefreshSession(db, b.id, 't4', future) + revokeSessionById(db, s4) + + const all = listActiveSessions(db).map((s) => s.id) + expect(new Set(all)).toEqual(new Set([s1, s3])) + expect(listActiveSessions(db, { userId: a.id }).map((s) => s.id)).toEqual([ + s1, + ]) + }) + + it('searchDirectoryUsers: без passwordHash, без disabled, LIMIT, экранирование', () => { + const { db } = memDb() + for (let i = 0; i < 60; i++) mkUser(db, `user${i}@x.io`) + const weird = mkUser(db, 'pct@x.io', { name: '100%' }) + const dis = mkUser(db, 'off@x.io') + db.$client.prepare('UPDATE users SET disabled=1 WHERE id=?').run(dis.id) + + const page = searchDirectoryUsers(db, '', 50) + expect(page).toHaveLength(50) + expect(Object.keys(page[0]!).sort()).toEqual(['email', 'id', 'name']) + expect(searchDirectoryUsers(db, 'off@x.io')).toEqual([]) + expect(searchDirectoryUsers(db, 'USER7@').map((u) => u.email)).toEqual([ + 'user7@x.io', + ]) + expect(searchDirectoryUsers(db, '%').map((u) => u.id)).toEqual([weird.id]) + expect(searchDirectoryUsers(db, weird.id)).toHaveLength(1) + }) + + it('countWebauthnCredentials считает COUNT(*)', () => { + const { db } = memDb() + const u = mkUser(db, 'a@x.io') + expect(countWebauthnCredentials(db, u.id)).toBe(0) + for (const id of ['c1', 'c2']) { + createWebauthnCredential(db, { + userId: u.id, + credentialId: id, + publicKey: 'k', + counter: 0, + name: id, + }) + } + expect(countWebauthnCredentials(db, u.id)).toBe(2) + }) +}) + +describe('OIDC', () => { + function code(db: AppDb, userId: string, raw: string, expiresAt: Date) { + createOidcAuthCode(db, { + rawCode: raw, + clientId: 'c', + userId, + redirectUri: 'https://x/cb', + scope: 'openid', + expiresAt, + }) + } + + it('consumeOidcAuthCode: одноразовый, просроченный отклоняется', () => { + const { db } = memDb() + const u = mkUser(db, 'a@x.io') + code(db, u.id, 'ok', new Date(Date.now() + 60_000)) + code(db, u.id, 'old', new Date(Date.now() - 60_000)) + expect(consumeOidcAuthCode(db, 'ok')?.userId).toBe(u.id) + expect(consumeOidcAuthCode(db, 'ok')).toBeUndefined() + expect(consumeOidcAuthCode(db, 'old')).toBeUndefined() + expect(consumeOidcAuthCode(db, 'missing')).toBeUndefined() + }) + + it('insertOidcSigningKey оставляет один активный ключ — новый', () => { + const { db } = memDb() + insertOidcSigningKey(db, { kid: 'k1', privatePem: 'p', publicJwkJson: '{}' }) + insertOidcSigningKey(db, { kid: 'k2', privatePem: 'p', publicJwkJson: '{}' }) + expect(getActiveOidcSigningKey(db)?.kid).toBe('k2') + const active = db.$client + .prepare('SELECT COUNT(*) c FROM oidc_signing_keys WHERE active=1') + .get() + expect(active).toEqual({ c: 1 }) + }) +}) + +describe('audit', () => { + it('appendAudit: дубликат event_id → false', () => { + const { db } = memDb() + expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe( + true, + ) + expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe( + false, + ) + expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true) + expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true) + }) + + it('appendAuditBatch: принимает, пропускает дубли, не теряет остальные', () => { + const { db } = memDb() + const events = Array.from({ length: 500 }, (_, i) => ({ + eventId: `e${i % 400}`, + action: 'x.y', + summary: 's', + })) + const res = appendAuditBatch(db, events) + expect(res).toEqual({ accepted: 400, duplicates: 100, failed: 0 }) + expect(listAudit(db, { limit: 1000 })).toHaveLength(400) + }) + + it('listAudit kind=logins/changes делит по префиксу auth.', () => { + const { db } = memDb() + appendAudit(db, { action: 'auth.login', summary: 's' }) + appendAudit(db, { action: 'auth.login_failed', summary: 's' }) + appendAudit(db, { action: 'user.create', summary: 's' }) + appendAudit(db, { action: 'audit.purge', summary: 's' }) + appendAudit(db, { action: 'authz.other', summary: 's' }) + const logins = listAudit(db, { kind: 'logins' }).map((e) => e.action) + const changes = listAudit(db, { kind: 'changes' }).map((e) => e.action) + expect(logins.sort()).toEqual(['auth.login', 'auth.login_failed']) + expect(changes.sort()).toEqual(['audit.purge', 'authz.other', 'user.create']) + }) +}) + +describe('runHousekeeping', () => { + it('удаляет старые истёкшие/отозванные, оставляя активные и свежие', () => { + const { db } = memDb() + const u = mkUser(db, 'a@x.io') + const day = 86_400_000 + const active = createRefreshSession(db, u.id, 'a', new Date(Date.now() + day)) + createRefreshSession(db, u.id, 'oldexp', new Date(Date.now() - 10 * day)) + createRefreshSession(db, u.id, 'freshexp', new Date(Date.now() - day)) + const oldRevoked = createRefreshSession( + db, + u.id, + 'oldrev', + new Date(Date.now() + day), + ) + db.$client + .prepare('UPDATE refresh_sessions SET revoked_at=? WHERE id=?') + .run(new Date(Date.now() - 10 * day).toISOString(), oldRevoked) + + createOidcAuthCode(db, { + rawCode: 'stale', + clientId: 'c', + userId: u.id, + redirectUri: 'r', + scope: 's', + expiresAt: new Date(Date.now() - 10 * day), + }) + createOidcAuthCode(db, { + rawCode: 'live', + clientId: 'c', + userId: u.id, + redirectUri: 'r', + scope: 's', + expiresAt: new Date(Date.now() + 60_000), + }) + db.$client + .prepare( + `INSERT INTO webauthn_challenges (id,purpose,challenge,expires_at,created_at) + VALUES ('c1','register','x',?,?)`, + ) + .run(new Date(Date.now() - 1000).toISOString(), new Date().toISOString()) + + const res = runHousekeeping(db, { retentionDays: 7 }) + expect(res).toEqual({ sessions: 2, authCodes: 1, challenges: 1 }) + const left = db.$client + .prepare('SELECT id FROM refresh_sessions') + .all() as Array<{ id: string }> + expect(left).toHaveLength(2) + expect(left.map((r) => r.id)).toContain(active) + + expect(runHousekeeping(db, { retentionDays: 7 })).toEqual({ + sessions: 0, + authCodes: 0, + challenges: 0, + }) + }) + + it('удаляет пачками > 1000 строк', () => { + const { db } = memDb() + const u = mkUser(db, 'a@x.io') + const ins = db.$client.prepare( + `INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at) + VALUES (?,?,?,?,?)`, + ) + db.$client.transaction(() => { + for (let i = 0; i < 2500; i++) { + ins.run(`s${i}`, u.id, `h${i}`, '2000-01-01T00:00:00.000Z', 't') + } + })() + expect(runHousekeeping(db, { retentionDays: 1 }).sessions).toBe(2500) + }) +}) diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts new file mode 100644 index 0000000..aa18381 --- /dev/null +++ b/packages/db/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { passWithNoTests: true, include: ['test/**/*.test.ts'] }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 913253e..491a0a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,7 +129,7 @@ importers: version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) apps/web: dependencies: @@ -300,6 +300,9 @@ importers: typescript-eslint: specifier: ^8.64.0 version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) packages/shared: dependencies: @@ -6798,13 +6801,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: @@ -9253,7 +9264,7 @@ snapshots: vary@1.1.2: {} - vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1): + vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -9262,6 +9273,20 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.1 + + vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.19 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 @@ -9281,10 +9306,10 @@ snapshots: jiti: 2.7.0 tsx: 4.23.1 - vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9301,13 +9326,40 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 transitivePeerDependencies: - msw + vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - msw + vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10