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)
})
})