perf(db): pragma, транзакции, индексы, версия схемы, SQL-фильтры и очистка

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-23 15:56:25 +07:00
1 parent 140ffdb7b1
commit 107eddba9e
18 files changed
+871 -139

No files matched your search

+3
View File
@@ -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()
})
}
+3
View File
@@ -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,
+10 -6
View File
@@ -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<void> {
}
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',
+2 -15
View File
@@ -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<void> {
)
.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)
},
)
+21 -25
View File
@@ -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<void> {
})
}
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 }
+28
View File
@@ -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)
}
}
+95
View File
@@ -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<ReturnType<typeof buildTestApp>> | 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<Record<string, unknown>>
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)
})
})
+3 -1
View File
@@ -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:"
}
}
+35 -14
View File
@@ -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(
+69
View File
@@ -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<typeof sql>,
): 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()),
}
}
+40
View File
@@ -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'
+32 -34
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
import type { AppDb } from './index.js'
/**
* Выполняет fn в транзакции better-sqlite3. Вложенные вызовы превращаются
* в SAVEPOINT, поэтому безопасно вызывать из функций, уже работающих в транзакции.
*/
export function withTransaction<T>(db: AppDb, fn: () => T): T {
return db.$client.transaction(fn)()
}
+59 -34
View File
@@ -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 {
+8 -3
View File
@@ -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<number>`count(*)` })
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId))
.get()
return Number(row?.total ?? 0)
}
/** Число passkey-ов по всем пользователям одним GROUP BY — для списков без N+1. */
+390
View File
@@ -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)
})
})
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { passWithNoTests: true, include: ['test/**/*.test.ts'] },
})
+59 -7
View File
@@ -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