perf(api): кэш-заголовки статики, CORS по allowlist, раздельные health/ready, compress в Traefik

This commit is contained in:
Denozordec committed 2026-10-23 16:55:59 +07:00
1 parent 30d331c633
commit 1d7ea35a23
9 files changed
+269 -83

No files matched your search

-2
View File
@@ -19,12 +19,10 @@
"@fastify/helmet": "^13.0.1", "@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0", "@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.8", "jose": "^6.2.8",
"zod": "catalog:" "zod": "catalog:"
}, },
+43 -12
View File
@@ -3,7 +3,6 @@ import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import helmet from '@fastify/helmet' import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit' import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import formbody from '@fastify/formbody' import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt' import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static' import fastifyStatic from '@fastify/static'
@@ -16,6 +15,7 @@ import {
import { import {
closeDb, closeDb,
createDb, createDb,
getAppSwitcherConfig,
getUserApps, getUserApps,
healthCheck, healthCheck,
migrateSchema, migrateSchema,
@@ -35,10 +35,17 @@ import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js' import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js' import { startAuditRetentionJob } from './services/audit-retention.js'
import { startHousekeepingJob } from './services/housekeeping.js' import { startHousekeepingJob } from './services/housekeeping.js'
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js' import {
ensureOidcSigningKey,
isOidcKeyReady,
resetOidcKeyCache,
} from './lib/oidc/keys.js'
import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */ /** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known'] const API_PREFIXES = ['/api', '/oauth', '/.well-known']
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -140,9 +147,14 @@ export async function buildApp(opts: {
}) })
await app.register(sensible) const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url),
)
await app.register(formbody) await app.register(formbody)
await app.register(cors, { origin: true, credentials: true }) await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true,
})
await app.register(helmet, { contentSecurityPolicy: false }) await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { await app.register(rateLimit, {
max: 200, max: 200,
@@ -152,13 +164,20 @@ export async function buildApp(opts: {
}) })
await app.register(fjwt, { secret: config.jwtSecret }) await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => { // liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
healthCheck(sqlite) // контейнер из-за временной блокировки.
return { ok: true } app.get('/health', async () => ({ ok: true }))
})
app.get('/ready', async () => { // readiness: БД отвечает и ключ подписи OIDC загружен.
healthCheck(sqlite) app.get('/ready', async (_request, reply) => {
try {
healthCheck(sqlite)
} catch {
return reply.status(503).send({ ok: false, reason: 'db' })
}
if (!isOidcKeyReady()) {
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
}
return { ok: true } return { ok: true }
}) })
@@ -187,9 +206,21 @@ export async function buildApp(opts: {
await app.register(fastifyStatic, { await app.register(fastifyStatic, {
root: resolve(config.staticDir), root: resolve(config.staticDir),
wildcard: false, wildcard: false,
// Traefik перед приложением не сжимает; .gz/.br для статики готовит // .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
preCompressed: true, preCompressed: true,
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
cacheControl: false,
setHeaders(res, filePath) {
if (HASHED_ASSET_RE.test(filePath)) {
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else if (INDEX_HTML_RE.test(filePath)) {
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
res.setHeader('Cache-Control', 'no-cache')
} else {
res.setHeader('Cache-Control', 'public, max-age=0')
}
},
}) })
} }
const spa = Boolean(config.staticDir && existsSync(config.staticDir)) const spa = Boolean(config.staticDir && existsSync(config.staticDir))
+3
View File
@@ -33,6 +33,8 @@ export const configSchema = z.object({
serverPort: z.coerce.number().int().positive().default(8080), serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(), staticDir: z.string().optional(),
logLevel: z.string().default('info'), logLevel: z.string().default('info'),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */ /** Одновременных argon2-хешей (каждый ~19 МиБ). */
argon2Concurrency: z.coerce.number().int().positive().default(2), argon2Concurrency: z.coerce.number().int().positive().default(2),
/** 'true' | 'false' | список IP/CIDR доверенных прокси. */ /** 'true' | 'false' | список IP/CIDR доверенных прокси. */
@@ -77,6 +79,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
logLevel: env.LOG_LEVEL ?? 'info', logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000, shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7, sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2, argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY, trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
isProd: isProd:
+57
View File
@@ -0,0 +1,57 @@
import type { AppConfig } from '../config.js'
import { oidcIssuerFromConfig } from '../config.js'
function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
return null
}
}
/**
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
* приложения из App Switcher и хосты из RETURN_TO_ALLOWLIST
* (".suffix" — суффикс хоста, "localhost", точные хосты). '*' в CORS_ORIGINS —
* разрешить всё (только осознанно).
*/
export function createOriginChecker(
config: AppConfig,
switcherUrls: () => string[],
): (origin: string) => boolean {
const explicit = new Set<string>()
let allowAll = false
for (const raw of config.corsOrigins.split(',')) {
const v = raw.trim()
if (!v) continue
if (v === '*') allowAll = true
else {
const o = originOf(v)
if (o) explicit.add(o)
}
}
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
const o = originOf(u)
if (o) explicit.add(o)
}
const tokens = config.returnToAllowlist
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
return (origin: string) => {
if (allowAll) return true
const o = originOf(origin)
if (!o) return false
if (explicit.has(o)) return true
const host = new URL(o).hostname.toLowerCase()
for (const t of tokens) {
if (t === 'private') continue
if (t.startsWith('.') ? host.endsWith(t) : host === t) return true
}
for (const u of switcherUrls()) {
if (originOf(u) === o) return true
}
return false
}
}
+5
View File
@@ -113,6 +113,11 @@ export async function ensureOidcSigningKey(
return cached return cached
} }
/** Ключ подписи загружен — приложение готово выдавать токены. */
export function isOidcKeyReady(): boolean {
return cached !== null
}
/** Reset cache (tests). */ /** Reset cache (tests). */
export function resetOidcKeyCache(): void { export function resetOidcKeyCache(): void {
cached = null cached = null
+12 -8
View File
@@ -23,11 +23,14 @@ import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
export async function authRoutes(app: FastifyInstance): Promise<void> { export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */ /** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async () => ({ app.get('/api/v1/auth/config', async (_request, reply) => {
return_to_allowlist: app.config.returnToAllowlist, reply.header('Cache-Control', 'public, max-age=60')
issuer: app.config.issuer, return {
webauthn: true, return_to_allowlist: app.config.returnToAllowlist,
})) issuer: app.config.issuer,
webauthn: true,
}
})
app.post('/api/v1/auth/login', { app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } }, config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
@@ -213,9 +216,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
) )
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */ /** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
app.get('/api/v1/app-switcher', async () => app.get('/api/v1/app-switcher', async (_request, reply) => {
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)), reply.header('Cache-Control', 'public, max-age=30')
) return publicAppSwitcherConfig(getAppSwitcherConfig(app.db))
})
app.get( app.get(
'/api/v1/auth/me', '/api/v1/auth/me',
+145
View File
@@ -0,0 +1,145 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createOriginChecker } from '../src/lib/cors.js'
import { loadConfig } from '../src/config.js'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
function staticDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
mkdirSync(join(dir, 'assets'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
return dir
}
describe('статика', () => {
it('hashed-ассеты immutable, index.html no-cache', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
expect(asset.statusCode).toBe(200)
expect(asset.headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
)
const index = await app.inject({ method: 'GET', url: '/index.html' })
expect(index.headers['cache-control']).toBe('no-cache')
const spa = await app.inject({ method: 'GET', url: '/users/42' })
expect(spa.statusCode).toBe(200)
expect(spa.headers['cache-control']).toBe('no-cache')
})
it('отсутствующий ассет → не отдаёт index.html как JS (404 JSON или html, но не 200 js)', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
// SPA-fallback допустим только как html; кэшироваться как immutable он не должен
expect(res.headers['cache-control']).not.toContain('immutable')
})
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
const dir = staticDir()
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
app = await buildTestApp({ STATIC_DIR: dir })
const res = await app.inject({
method: 'GET',
url: '/assets/app-abc123.js',
headers: { 'accept-encoding': 'br' },
})
expect(res.headers['content-encoding']).toBe('br')
expect(res.body).toBe('BR-BYTES')
})
})
describe('кэш-заголовки API', () => {
it('app-switcher и auth/config кэшируются коротко', async () => {
app = await buildTestApp()
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(sw.headers['cache-control']).toBe('public, max-age=30')
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
})
})
describe('health / ready', () => {
it('/health не трогает БД и живёт после закрытия БД', async () => {
app = await buildTestApp()
app.sqlite.close()
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
})
it('/ready 200 в норме и 503 при недоступной БД', async () => {
app = await buildTestApp()
const ok = await app.inject({ method: 'GET', url: '/ready' })
expect(ok.statusCode).toBe(200)
app.sqlite.close()
const bad = await app.inject({ method: 'GET', url: '/ready' })
expect(bad.statusCode).toBe(503)
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
})
})
describe('CORS', () => {
const origin = (url: string) => ({ origin: url })
it('разрешает issuer/allowlist, блокирует чужие', async () => {
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
const allowed = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://auth.test.local'),
})
expect(allowed.headers['access-control-allow-origin']).toBe(
'https://auth.test.local',
)
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
const foreign = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://evil.example'),
})
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
})
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://extra.example'),
})
expect(res.headers['access-control-allow-origin']).toBe(
'https://extra.example',
)
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(none.statusCode).toBe(200)
})
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
const base = {
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.test.local',
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
}
const check = createOriginChecker(loadConfig(base), () => [
'https://dns.custom.io/path',
])
expect(check('https://cfdm.shnt.top')).toBe(true)
expect(check('http://localhost:5173')).toBe(true)
expect(check('https://dns.custom.io')).toBe(true)
expect(check('https://evilshnt.top')).toBe(false)
expect(check('not a url')).toBe(false)
const all = createOriginChecker(
loadConfig({ ...base, CORS_ORIGINS: '*' }),
() => [],
)
expect(all('https://anything.example')).toBe(true)
})
})
+4
View File
@@ -88,6 +88,10 @@ services:
- traefik.http.routers.auth-portal.tls=true - traefik.http.routers.auth-portal.tls=true
- traefik.http.routers.auth-portal.tls.certresolver=letsencrypt - traefik.http.routers.auth-portal.tls.certresolver=letsencrypt
- traefik.http.services.auth-portal.loadbalancer.server.port=8080 - traefik.http.services.auth-portal.loadbalancer.server.port=8080
# Сжатие JSON/текста на стороне Traefik (ноль CPU/RAM в Node);
# предсжатые .br/.gz статики Traefik не пересжимает (есть Content-Encoding).
- traefik.http.middlewares.auth-portal-compress.compress=true
- traefik.http.routers.auth-portal.middlewares=auth-portal-compress
healthcheck: healthcheck:
test: test:
[ [
-61
View File
@@ -78,9 +78,6 @@ importers:
'@fastify/rate-limit': '@fastify/rate-limit':
specifier: ^10.3.0 specifier: ^10.3.0
version: 10.3.0 version: 10.3.0
'@fastify/sensible':
specifier: ^6.0.3
version: 6.0.4
'@fastify/static': '@fastify/static':
specifier: ^8.2.0 specifier: ^8.2.0
version: 8.3.0 version: 8.3.0
@@ -93,9 +90,6 @@ importers:
fastify: fastify:
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.10.0 version: 5.10.0
fastify-plugin:
specifier: ^5.0.1
version: 5.1.0
jose: jose:
specifier: ^6.2.8 specifier: ^6.2.8
version: 6.2.8 version: 6.2.8
@@ -1295,9 +1289,6 @@ packages:
'@fastify/[email protected]': '@fastify/[email protected]':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/[email protected]':
resolution: {integrity: sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==}
'@fastify/[email protected]': '@fastify/[email protected]':
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
@@ -3122,10 +3113,6 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -3707,10 +3694,6 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
[email protected]: [email protected]:
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
engines: {node: '>=16.10'} engines: {node: '>=16.10'}
@@ -3734,18 +3717,10 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
[email protected]:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
[email protected]:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
[email protected]: [email protected]:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -4748,10 +4723,6 @@ packages:
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
engines: {node: '>=20'} engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
[email protected]: [email protected]:
resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==} resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4870,10 +4841,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
[email protected]:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
[email protected]: [email protected]:
resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -5779,16 +5746,6 @@ snapshots:
http-errors: 2.0.1 http-errors: 2.0.1
mime: 3.0.0 mime: 3.0.0
'@fastify/[email protected]':
dependencies:
'@lukeed/ms': 2.0.2
dequal: 2.0.3
fastify-plugin: 5.1.0
forwarded: 0.2.0
http-errors: 2.0.1
type-is: 2.1.0
vary: 1.1.2
'@fastify/[email protected]': '@fastify/[email protected]':
dependencies: dependencies:
'@fastify/accept-negotiator': 2.0.1 '@fastify/accept-negotiator': 2.0.1
@@ -7735,8 +7692,6 @@ snapshots:
hasown: 2.0.4 hasown: 2.0.4
mime-types: 2.1.35 mime-types: 2.1.35
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -8242,8 +8197,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -8259,16 +8212,10 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
mime-db: 1.52.0 mime-db: 1.52.0
[email protected]:
dependencies:
mime-db: 1.54.0
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -9180,12 +9127,6 @@ snapshots:
dependencies: dependencies:
tagged-tag: 1.0.0 tagged-tag: 1.0.0
[email protected]:
dependencies:
content-type: 2.0.0
media-typer: 1.1.0
mime-types: 3.0.2
[email protected]([email protected]([email protected]))([email protected]): [email protected]([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]) '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
@@ -9262,8 +9203,6 @@ snapshots:
spdx-correct: 3.2.0 spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1 spdx-expression-parse: 3.0.1
[email protected]: {}
[email protected](@types/[email protected])([email protected])([email protected])([email protected]): [email protected](@types/[email protected])([email protected])([email protected])([email protected]):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0