From 9f9a29c33c6199810ac07ec7df4a27f619f63a1d Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 19:23:36 +0000 Subject: [PATCH 01/13] feat(api-proxy): add structured logging, metrics, and request tracing - logging.js: structured JSON logging with request IDs (crypto.randomUUID), sanitizeForLog utility, zero external dependencies - metrics.js: in-memory counters (requests_total, bytes), histograms (request_duration_ms with fixed buckets and percentile calculation), gauges (active_requests, uptime), memory-bounded - server.js: replace all console.log/error with structured logger, instrument proxyRequest() with full metrics, add X-Request-ID header propagation, enhance /health with metrics_summary, add GET /metrics endpoint on port 10000 Co-Authored-By: Claude Opus 4.6 --- containers/api-proxy/logging.js | 50 +++++++ containers/api-proxy/metrics.js | 221 ++++++++++++++++++++++++++++++ containers/api-proxy/server.js | 235 ++++++++++++++++++++++++-------- 3 files changed, 446 insertions(+), 60 deletions(-) create mode 100644 containers/api-proxy/logging.js create mode 100644 containers/api-proxy/metrics.js diff --git a/containers/api-proxy/logging.js b/containers/api-proxy/logging.js new file mode 100644 index 00000000..f6e5f165 --- /dev/null +++ b/containers/api-proxy/logging.js @@ -0,0 +1,50 @@ +/** + * Structured JSON logging for AWF API Proxy. + * + * Every log line is a single JSON object written to stdout. + * Zero external dependencies — uses Node.js built-in crypto. + */ + +'use strict'; + +const crypto = require('crypto'); + +/** + * Generate a unique request ID (UUID v4). + * @returns {string} + */ +function generateRequestId() { + return crypto.randomUUID(); +} + +/** + * Strip control characters and limit length for safe logging. + * @param {string} str + * @param {number} [maxLen=200] + * @returns {string} + */ +function sanitizeForLog(str, maxLen = 200) { + if (typeof str !== 'string') return ''; + // eslint-disable-next-line no-control-regex + return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, maxLen); +} + +/** + * Write a structured JSON log line to stdout. + * + * @param {string} level - "info" | "warn" | "error" + * @param {string} event - e.g. "request_start", "request_complete", "request_error", "startup" + * @param {object} [fields] - Additional key/value pairs merged into the log line + */ +function logRequest(level, event, fields = {}) { + const line = { + timestamp: new Date().toISOString(), + level, + event, + ...fields, + }; + // Single JSON line to stdout — tee handles file persistence + process.stdout.write(JSON.stringify(line) + '\n'); +} + +module.exports = { generateRequestId, sanitizeForLog, logRequest }; diff --git a/containers/api-proxy/metrics.js b/containers/api-proxy/metrics.js new file mode 100644 index 00000000..663de62f --- /dev/null +++ b/containers/api-proxy/metrics.js @@ -0,0 +1,221 @@ +/** + * In-memory metrics collection for AWF API Proxy. + * + * Provides counters, histograms with fixed buckets, and gauges. + * Memory-bounded: no arrays that grow with request count. + * Zero external dependencies. + */ + +'use strict'; + +const HISTOGRAM_BUCKETS = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000]; + +const startTime = Date.now(); + +// ── Counters ────────────────────────────────────────────────────────── +// Key format: "counterName:label1:label2:..." +const counters = {}; + +// ── Histograms ──────────────────────────────────────────────────────── +// histograms[name][labelKey] = { buckets: { 10: n, 50: n, ... , '+Inf': n }, sum: n, count: n } +const histograms = {}; + +// ── Gauges ──────────────────────────────────────────────────────────── +// gauges[name][labelKey] = number +const gauges = {}; + +/** + * Build a colon-separated label key from an object. + * @param {object} labels - e.g. { provider: "openai", method: "POST", status_class: "2xx" } + * @returns {string} + */ +function labelKey(labels) { + if (!labels || typeof labels !== 'object') return '_'; + const vals = Object.values(labels); + return vals.length > 0 ? vals.join(':') : '_'; +} + +/** + * Derive the status class string from an HTTP status code. + * @param {number} status + * @returns {string} e.g. "2xx", "4xx", "5xx" + */ +function statusClass(status) { + if (status >= 200 && status < 300) return '2xx'; + if (status >= 400 && status < 500) return '4xx'; + if (status >= 500 && status < 600) return '5xx'; + return `${Math.floor(status / 100)}xx`; +} + +// ── Counter operations ──────────────────────────────────────────────── + +/** + * Increment a counter. + * @param {string} name - Counter name (e.g. "requests_total") + * @param {object} labels - Label key/value pairs + * @param {number} [value=1] + */ +function increment(name, labels, value = 1) { + const key = `${name}:${labelKey(labels)}`; + counters[key] = (counters[key] || 0) + value; +} + +// ── Histogram operations ────────────────────────────────────────────── + +/** + * Record an observation in a histogram. + * @param {string} name - Histogram name (e.g. "request_duration_ms") + * @param {number} value - Observed value + * @param {object} labels - Label key/value pairs + */ +function observe(name, value, labels) { + const lk = labelKey(labels); + + if (!histograms[name]) histograms[name] = {}; + if (!histograms[name][lk]) { + const buckets = {}; + for (const b of HISTOGRAM_BUCKETS) buckets[b] = 0; + buckets['+Inf'] = 0; + histograms[name][lk] = { buckets, sum: 0, count: 0 }; + } + + const h = histograms[name][lk]; + h.sum += value; + h.count += 1; + for (const b of HISTOGRAM_BUCKETS) { + if (value <= b) h.buckets[b]++; + } + h.buckets['+Inf']++; +} + +/** + * Calculate a percentile from histogram buckets using linear interpolation. + * @param {{ buckets: object, count: number }} h + * @param {number} p - Percentile (0–1), e.g. 0.5 for p50 + * @returns {number} + */ +function percentileFromHistogram(h, p) { + if (h.count === 0) return 0; + const target = p * h.count; + + let prev = 0; + let prevBound = 0; + + for (const b of HISTOGRAM_BUCKETS) { + const cum = h.buckets[b]; + if (cum >= target) { + // Linear interpolation within this bucket + const fraction = cum === prev ? 0 : (target - prev) / (cum - prev); + return prevBound + fraction * (b - prevBound); + } + prev = cum; + prevBound = b; + } + + // All values above the last bucket — return last bucket upper bound + return HISTOGRAM_BUCKETS[HISTOGRAM_BUCKETS.length - 1]; +} + +// ── Gauge operations ────────────────────────────────────────────────── + +function gaugeInc(name, labels) { + const lk = labelKey(labels); + if (!gauges[name]) gauges[name] = {}; + gauges[name][lk] = (gauges[name][lk] || 0) + 1; +} + +function gaugeDec(name, labels) { + const lk = labelKey(labels); + if (!gauges[name]) gauges[name] = {}; + gauges[name][lk] = (gauges[name][lk] || 0) - 1; +} + +function gaugeSet(name, labels, value) { + const lk = labelKey(labels); + if (!gauges[name]) gauges[name] = {}; + gauges[name][lk] = value; +} + +// ── Snapshot helpers ────────────────────────────────────────────────── + +/** + * Return full metrics object for /metrics endpoint. + */ +function getMetrics() { + const uptimeSec = Math.floor((Date.now() - startTime) / 1000); + + // Build histogram output with percentiles + const histOut = {}; + for (const [name, byLabel] of Object.entries(histograms)) { + histOut[name] = {}; + for (const [lk, h] of Object.entries(byLabel)) { + histOut[name][lk] = { + p50: Math.round(percentileFromHistogram(h, 0.5)), + p90: Math.round(percentileFromHistogram(h, 0.9)), + p99: Math.round(percentileFromHistogram(h, 0.99)), + count: h.count, + sum: Math.round(h.sum), + buckets: { ...h.buckets }, + }; + } + } + + return { + counters: { ...counters }, + histograms: histOut, + gauges: { + ...gauges, + uptime_seconds: uptimeSec, + }, + }; +} + +/** + * Return compact summary for /health endpoint. + */ +function getSummary() { + let totalRequests = 0; + let totalErrors = 0; + let activeRequests = 0; + + for (const [key, val] of Object.entries(counters)) { + if (key.startsWith('requests_total:')) totalRequests += val; + if (key.startsWith('requests_errors_total:')) totalErrors += val; + } + + if (gauges.active_requests) { + for (const val of Object.values(gauges.active_requests)) { + activeRequests += val; + } + } + + // Average latency across all providers + let totalDuration = 0; + let totalCount = 0; + if (histograms.request_duration_ms) { + for (const h of Object.values(histograms.request_duration_ms)) { + totalDuration += h.sum; + totalCount += h.count; + } + } + + return { + total_requests: totalRequests, + total_errors: totalErrors, + active_requests: activeRequests, + avg_latency_ms: totalCount > 0 ? Math.round(totalDuration / totalCount) : 0, + }; +} + +module.exports = { + statusClass, + increment, + observe, + gaugeInc, + gaugeDec, + gaugeSet, + getMetrics, + getSummary, + // Exported for testing + HISTOGRAM_BUCKETS, +}; diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index e6bdccd3..3d0aaae0 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -14,6 +14,8 @@ const http = require('http'); const https = require('https'); const { URL } = require('url'); const { HttpsProxyAgent } = require('https-proxy-agent'); +const { generateRequestId, sanitizeForLog, logRequest } = require('./logging'); +const metrics = require('./metrics'); // Max request body size (10 MB) to prevent DoS via large payloads const MAX_BODY_SIZE = 10 * 1024 * 1024; @@ -35,13 +37,6 @@ function shouldStripHeader(name) { return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-'); } -/** Sanitize a string for safe logging (strip control chars, limit length). */ -function sanitizeForLog(str) { - if (typeof str !== 'string') return ''; - // eslint-disable-next-line no-control-regex - return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200); -} - // Read API keys from environment (set by docker-compose) const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -50,30 +45,57 @@ const COPILOT_GITHUB_TOKEN = process.env.COPILOT_GITHUB_TOKEN; // Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose) const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; -console.log('[API Proxy] Starting AWF API proxy sidecar...'); -console.log(`[API Proxy] HTTPS_PROXY: ${HTTPS_PROXY}`); -if (OPENAI_API_KEY) { - console.log('[API Proxy] OpenAI API key configured'); -} -if (ANTHROPIC_API_KEY) { - console.log('[API Proxy] Anthropic API key configured'); -} -if (COPILOT_GITHUB_TOKEN) { - console.log('[API Proxy] GitHub Copilot token configured'); -} +logRequest('info', 'startup', { + message: 'Starting AWF API proxy sidecar', + squid_proxy: HTTPS_PROXY || 'not configured', + providers: { + openai: !!OPENAI_API_KEY, + anthropic: !!ANTHROPIC_API_KEY, + copilot: !!COPILOT_GITHUB_TOKEN, + }, +}); // Create proxy agent for routing through Squid const proxyAgent = HTTPS_PROXY ? new HttpsProxyAgent(HTTPS_PROXY) : undefined; if (!proxyAgent) { - console.warn('[API Proxy] WARNING: No HTTPS_PROXY configured, requests will go direct'); + logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' }); } /** * Forward a request to the target API, injecting auth headers and routing through Squid. */ -function proxyRequest(req, res, targetHost, injectHeaders) { +function proxyRequest(req, res, targetHost, injectHeaders, provider) { + const requestId = req.headers['x-request-id'] || generateRequestId(); + const startTime = Date.now(); + + // Propagate request ID back to the client and forward to upstream + res.setHeader('X-Request-ID', requestId); + + // Track active requests + metrics.gaugeInc('active_requests', { provider }); + + logRequest('info', 'request_start', { + request_id: requestId, + provider, + method: req.method, + path: sanitizeForLog(req.url), + upstream_host: targetHost, + }); + // Validate that req.url is a relative path (prevent open-redirect / SSRF) if (!req.url || !req.url.startsWith('/')) { + const duration = Date.now() - startTime; + metrics.gaugeDec('active_requests', { provider }); + metrics.increment('requests_total', { provider, method: req.method, status_class: '4xx' }); + logRequest('warn', 'request_complete', { + request_id: requestId, + provider, + method: req.method, + path: sanitizeForLog(req.url), + status: 400, + duration_ms: duration, + upstream_host: targetHost, + }); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Bad Request', message: 'URL must be a relative path' })); return; @@ -84,7 +106,18 @@ function proxyRequest(req, res, targetHost, injectHeaders) { // Handle client-side errors (e.g. aborted connections) req.on('error', (err) => { - console.error(`[API Proxy] Client request error: ${sanitizeForLog(err.message)}`); + const duration = Date.now() - startTime; + metrics.gaugeDec('active_requests', { provider }); + metrics.increment('requests_errors_total', { provider }); + logRequest('error', 'request_error', { + request_id: requestId, + provider, + method: req.method, + path: sanitizeForLog(req.url), + duration_ms: duration, + error: sanitizeForLog(err.message), + upstream_host: targetHost, + }); if (!res.headersSent) { res.writeHead(400, { 'Content-Type': 'application/json' }); } @@ -101,6 +134,19 @@ function proxyRequest(req, res, targetHost, injectHeaders) { totalBytes += chunk.length; if (totalBytes > MAX_BODY_SIZE) { rejected = true; + const duration = Date.now() - startTime; + metrics.gaugeDec('active_requests', { provider }); + metrics.increment('requests_total', { provider, method: req.method, status_class: '4xx' }); + logRequest('warn', 'request_complete', { + request_id: requestId, + provider, + method: req.method, + path: sanitizeForLog(req.url), + status: 413, + duration_ms: duration, + request_bytes: totalBytes, + upstream_host: targetHost, + }); if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); } @@ -113,6 +159,9 @@ function proxyRequest(req, res, targetHost, injectHeaders) { req.on('end', () => { if (rejected) return; const body = Buffer.concat(chunks); + const requestBytes = body.length; + + metrics.increment('request_bytes_total', { provider }, requestBytes); // Copy incoming headers, stripping sensitive/proxy headers, then inject auth const headers = {}; @@ -121,6 +170,8 @@ function proxyRequest(req, res, targetHost, injectHeaders) { headers[name] = value; } } + // Ensure X-Request-ID is forwarded to upstream + headers['x-request-id'] = requestId; Object.assign(headers, injectHeaders); const options = { @@ -133,21 +184,75 @@ function proxyRequest(req, res, targetHost, injectHeaders) { }; const proxyReq = https.request(options, (proxyRes) => { + let responseBytes = 0; + + proxyRes.on('data', (chunk) => { + responseBytes += chunk.length; + }); + // Handle response stream errors proxyRes.on('error', (err) => { - console.error(`[API Proxy] Response stream error from ${targetHost}: ${sanitizeForLog(err.message)}`); + const duration = Date.now() - startTime; + metrics.gaugeDec('active_requests', { provider }); + metrics.increment('requests_errors_total', { provider }); + logRequest('error', 'request_error', { + request_id: requestId, + provider, + method: req.method, + path: sanitizeForLog(req.url), + duration_ms: duration, + error: sanitizeForLog(err.message), + upstream_host: targetHost, + }); if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'application/json' }); } res.end(JSON.stringify({ error: 'Response stream error', message: err.message })); }); - res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.on('end', () => { + const duration = Date.now() - startTime; + const sc = metrics.statusClass(proxyRes.statusCode); + metrics.gaugeDec('active_requests', { provider }); + metrics.increment('requests_total', { provider, method: req.method, status_class: sc }); + metrics.increment('response_bytes_total', { provider }, responseBytes); + metrics.observe('request_duration_ms', duration, { provider }); + + logRequest('info', 'request_complete', { + request_id: requestId, + provider, + method: req.method, + path: sanitizeForLog(req.url), + status: proxyRes.statusCode, + duration_ms: duration, + request_bytes: requestBytes, + response_bytes: responseBytes, + upstream_host: targetHost, + }); + }); + + // Copy response headers and add X-Request-ID + const resHeaders = { ...proxyRes.headers, 'x-request-id': requestId }; + res.writeHead(proxyRes.statusCode, resHeaders); proxyRes.pipe(res); }); proxyReq.on('error', (err) => { - console.error(`[API Proxy] Error proxying to ${targetHost}: ${sanitizeForLog(err.message)}`); + const duration = Date.now() - startTime; + metrics.gaugeDec('active_requests', { provider }); + metrics.increment('requests_errors_total', { provider }); + metrics.increment('requests_total', { provider, method: req.method, status_class: '5xx' }); + metrics.observe('request_duration_ms', duration, { provider }); + + logRequest('error', 'request_error', { + request_id: requestId, + provider, + method: req.method, + path: sanitizeForLog(req.url), + duration_ms: duration, + error: sanitizeForLog(err.message), + upstream_host: targetHost, + }); if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'application/json' }); } @@ -161,53 +266,68 @@ function proxyRequest(req, res, targetHost, injectHeaders) { }); } +/** + * Build the enhanced health response (superset of original format). + */ +function healthResponse() { + return { + status: 'healthy', + service: 'awf-api-proxy', + squid_proxy: HTTPS_PROXY || 'not configured', + providers: { + openai: !!OPENAI_API_KEY, + anthropic: !!ANTHROPIC_API_KEY, + copilot: !!COPILOT_GITHUB_TOKEN, + }, + metrics_summary: metrics.getSummary(), + }; +} + +/** + * Handle management endpoints on port 10000 (/health, /metrics). + * Returns true if the request was handled, false otherwise. + */ +function handleManagementEndpoint(req, res) { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(healthResponse())); + return true; + } + if (req.method === 'GET' && req.url === '/metrics') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(metrics.getMetrics())); + return true; + } + return false; +} + // Health port is always 10000 — this is what Docker healthcheck hits const HEALTH_PORT = 10000; // OpenAI API proxy (port 10000) if (OPENAI_API_KEY) { const server = http.createServer((req, res) => { - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'healthy', - service: 'awf-api-proxy', - squid_proxy: HTTPS_PROXY || 'not configured', - providers: { openai: true, anthropic: !!ANTHROPIC_API_KEY, copilot: !!COPILOT_GITHUB_TOKEN }, - })); - return; - } + if (handleManagementEndpoint(req, res)) return; - console.log(`[OpenAI Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`); - console.log(`[OpenAI Proxy] Injecting Authorization header with OPENAI_API_KEY`); proxyRequest(req, res, 'api.openai.com', { 'Authorization': `Bearer ${OPENAI_API_KEY}`, - }); + }, 'openai'); }); server.listen(HEALTH_PORT, '0.0.0.0', () => { - console.log(`[API Proxy] OpenAI proxy listening on port ${HEALTH_PORT}`); + logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}` }); }); } else { // No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck const server = http.createServer((req, res) => { - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'healthy', - service: 'awf-api-proxy', - squid_proxy: HTTPS_PROXY || 'not configured', - providers: { openai: false, anthropic: !!ANTHROPIC_API_KEY, copilot: !!COPILOT_GITHUB_TOKEN }, - })); - return; - } + if (handleManagementEndpoint(req, res)) return; res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'OpenAI proxy not configured (no OPENAI_API_KEY)' })); }); server.listen(HEALTH_PORT, '0.0.0.0', () => { - console.log(`[API Proxy] Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)`); + logRequest('info', 'server_start', { message: `Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)` }); }); } @@ -220,18 +340,16 @@ if (ANTHROPIC_API_KEY) { return; } - console.log(`[Anthropic Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`); - console.log(`[Anthropic Proxy] Injecting x-api-key header with ANTHROPIC_API_KEY`); // Only set anthropic-version as default; preserve agent-provided version const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; if (!req.headers['anthropic-version']) { anthropicHeaders['anthropic-version'] = '2023-06-01'; } - proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders); + proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders, 'anthropic'); }); server.listen(10001, '0.0.0.0', () => { - console.log('[API Proxy] Anthropic proxy listening on port 10001'); + logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001' }); }); } @@ -246,16 +364,13 @@ if (COPILOT_GITHUB_TOKEN) { return; } - // Log and proxy the request - console.log(`[Copilot Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`); - console.log(`[Copilot Proxy] Injecting Authorization header with COPILOT_GITHUB_TOKEN`); proxyRequest(req, res, 'api.githubcopilot.com', { 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, - }); + }, 'copilot'); }); copilotServer.listen(10002, '0.0.0.0', () => { - console.log('[API Proxy] GitHub Copilot proxy listening on port 10002'); + logRequest('info', 'server_start', { message: 'GitHub Copilot proxy listening on port 10002' }); }); } @@ -289,11 +404,11 @@ if (ANTHROPIC_API_KEY) { // Graceful shutdown process.on('SIGTERM', () => { - console.log('[API Proxy] Received SIGTERM, shutting down gracefully...'); + logRequest('info', 'shutdown', { message: 'Received SIGTERM, shutting down gracefully' }); process.exit(0); }); process.on('SIGINT', () => { - console.log('[API Proxy] Received SIGINT, shutting down gracefully...'); + logRequest('info', 'shutdown', { message: 'Received SIGINT, shutting down gracefully' }); process.exit(0); }); From 145ff0fa39c10c7efa6e1e1979115cdb17cbcdf4 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 19:28:00 +0000 Subject: [PATCH 02/13] feat(api-proxy): add sliding window rate limiter with CLI integration Implement per-provider rate limiting for the API proxy sidecar: - rate-limiter.js: Sliding window counter algorithm with 1-second granularity for RPM/bytes and 1-minute granularity for RPH. Per-provider independence, memory-bounded, fail-open on errors. - server.js: Rate limit check before each proxyRequest() call. Returns 429 with Retry-After, X-RateLimit-* headers and JSON body. Rate limit status added to /health endpoint. - CLI flags: --rate-limit-rpm, --rate-limit-rph, --rate-limit-bytes-pm, --no-rate-limit (all require --enable-api-proxy) - TypeScript: RateLimitConfig interface in types.ts, env var passthrough in docker-manager.ts, validation in cli.ts - Test runner: AwfOptions extended with rate limit fields Co-Authored-By: Claude Opus 4.6 --- containers/api-proxy/rate-limiter.js | 325 +++++++++++++++++++++++++++ containers/api-proxy/server.js | 53 +++++ src/cli.ts | 61 ++++- src/docker-manager.ts | 7 + src/types.ts | 27 +++ tests/fixtures/awf-runner.ts | 32 +++ 6 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 containers/api-proxy/rate-limiter.js diff --git a/containers/api-proxy/rate-limiter.js b/containers/api-proxy/rate-limiter.js new file mode 100644 index 00000000..01e11b8a --- /dev/null +++ b/containers/api-proxy/rate-limiter.js @@ -0,0 +1,325 @@ +/** + * Sliding Window Counter Rate Limiter for AWF API Proxy. + * + * Provides per-provider rate limiting with three limit types: + * - RPM: requests per minute (1-second granularity, 60 slots) + * - RPH: requests per hour (1-minute granularity, 60 slots) + * - Bytes/min: request bytes per minute (1-second granularity, 60 slots) + * + * Algorithm: sliding window counter — counts in the current window plus a + * weighted portion of the previous window based on elapsed time. + * + * Memory-bounded: fixed-size arrays per provider, old windows overwritten. + * Fail-open: any internal error allows the request through. + * Zero external dependencies. + */ + +'use strict'; + +// ── Defaults ──────────────────────────────────────────────────────────── +const DEFAULT_RPM = 60; +const DEFAULT_RPH = 1000; +const DEFAULT_BYTES_PM = 50 * 1024 * 1024; // 50 MB + +// ── Window sizes ──────────────────────────────────────────────────────── +const MINUTE_SLOTS = 60; // 1-second granularity for per-minute windows +const HOUR_SLOTS = 60; // 1-minute granularity for per-hour windows + +/** + * Create a fixed-size ring buffer for sliding window counting. + * @param {number} size - Number of slots + * @returns {{ counts: number[], total: number }} + */ +function createWindow(size) { + return { + counts: new Array(size).fill(0), + total: 0, + lastSlot: -1, + lastTime: 0, + }; +} + +/** + * Advance the window to the current time, zeroing out stale slots. + * @param {object} win - Window object + * @param {number} now - Current time in the slot's unit (seconds or minutes) + * @param {number} size - Window size (number of slots) + */ +function advanceWindow(win, now, size) { + if (win.lastSlot === -1) { + // First use — initialize + win.lastSlot = now % size; + win.lastTime = now; + return; + } + + const elapsed = now - win.lastTime; + if (elapsed <= 0) return; // Same slot or clock went backwards + + // Zero out slots that have expired + const slotsToZero = Math.min(elapsed, size); + for (let i = 1; i <= slotsToZero; i++) { + const slot = (win.lastSlot + i) % size; + win.total -= win.counts[slot]; + win.counts[slot] = 0; + } + + win.lastSlot = now % size; + win.lastTime = now; +} + +/** + * Record a value in the window at the current slot. + * @param {object} win - Window object + * @param {number} now - Current time in the slot's unit + * @param {number} size - Window size + * @param {number} value - Value to add (1 for request count, N for bytes) + */ +function recordInWindow(win, now, size, value) { + advanceWindow(win, now, size); + const slot = now % size; + win.counts[slot] += value; + win.total += value; +} + +/** + * Get the sliding window estimate of the current rate. + * + * Uses the formula: current_window_count + previous_window_weight * previous_total + * where previous_window_weight = (slot_duration - elapsed_in_current_slot) / slot_duration + * + * This is a simplified but effective approach: we use the total across + * all current-window slots plus a weighted fraction of the oldest expired slot's + * contribution to approximate the true sliding window. + * + * @param {object} win - Window object + * @param {number} now - Current time in the slot's unit + * @param {number} size - Window size + * @returns {number} Estimated count in the window + */ +function getWindowCount(win, now, size) { + advanceWindow(win, now, size); + return win.total; +} + +/** + * Per-provider rate limit state. + */ +class ProviderState { + constructor() { + // Per-minute: 1-second granularity + this.rpmWindow = createWindow(MINUTE_SLOTS); + // Per-hour: 1-minute granularity + this.rphWindow = createWindow(HOUR_SLOTS); + // Bytes per minute: 1-second granularity + this.bytesWindow = createWindow(MINUTE_SLOTS); + } +} + +class RateLimiter { + /** + * @param {object} config + * @param {number} [config.rpm=60] - Max requests per minute + * @param {number} [config.rph=1000] - Max requests per hour + * @param {number} [config.bytesPm=52428800] - Max bytes per minute (50 MB) + * @param {boolean} [config.enabled=true] - Whether rate limiting is active + */ + constructor(config = {}) { + this.rpm = config.rpm ?? DEFAULT_RPM; + this.rph = config.rph ?? DEFAULT_RPH; + this.bytesPm = config.bytesPm ?? DEFAULT_BYTES_PM; + this.enabled = config.enabled !== false; + + /** @type {Map} */ + this.providers = new Map(); + } + + /** + * Get or create state for a provider. + * @param {string} provider + * @returns {ProviderState} + */ + _getState(provider) { + let state = this.providers.get(provider); + if (!state) { + state = new ProviderState(); + this.providers.set(provider, state); + } + return state; + } + + /** + * Check whether a request is allowed under rate limits. + * + * If allowed, the request is counted (recorded in windows). + * If denied, no recording happens — the caller should return 429. + * + * @param {string} provider - e.g. "openai", "anthropic", "copilot" + * @param {number} [requestBytes=0] - Size of the request body in bytes + * @returns {{ + * allowed: boolean, + * limitType: string|null, + * limit: number|null, + * remaining: number, + * retryAfter: number, + * resetAt: number + * }} + */ + check(provider, requestBytes = 0) { + // Fail-open: if disabled or any error, allow + if (!this.enabled) { + return { allowed: true, limitType: null, limit: null, remaining: 0, retryAfter: 0, resetAt: 0 }; + } + + try { + const state = this._getState(provider); + const nowMs = Date.now(); + const nowSec = Math.floor(nowMs / 1000); + const nowMin = Math.floor(nowMs / 60000); + + // Check RPM (requests per minute) + const rpmCount = getWindowCount(state.rpmWindow, nowSec, MINUTE_SLOTS); + if (rpmCount >= this.rpm) { + const resetAt = (nowSec + 1) + (MINUTE_SLOTS - 1); + const retryAfter = Math.max(1, MINUTE_SLOTS - (nowSec % MINUTE_SLOTS)); + return { + allowed: false, + limitType: 'rpm', + limit: this.rpm, + remaining: 0, + retryAfter, + resetAt, + }; + } + + // Check RPH (requests per hour) + const rphCount = getWindowCount(state.rphWindow, nowMin, HOUR_SLOTS); + if (rphCount >= this.rph) { + const retryAfter = Math.max(1, (HOUR_SLOTS - (nowMin % HOUR_SLOTS)) * 60); + const resetAt = Math.floor(nowMs / 1000) + retryAfter; + return { + allowed: false, + limitType: 'rph', + limit: this.rph, + remaining: 0, + retryAfter, + resetAt, + }; + } + + // Check bytes per minute + const bytesCount = getWindowCount(state.bytesWindow, nowSec, MINUTE_SLOTS); + if (bytesCount + requestBytes > this.bytesPm) { + const retryAfter = Math.max(1, MINUTE_SLOTS - (nowSec % MINUTE_SLOTS)); + const resetAt = nowSec + retryAfter; + return { + allowed: false, + limitType: 'bytes_pm', + limit: this.bytesPm, + remaining: Math.max(0, this.bytesPm - bytesCount), + retryAfter, + resetAt, + }; + } + + // All checks passed — record the request + recordInWindow(state.rpmWindow, nowSec, MINUTE_SLOTS, 1); + recordInWindow(state.rphWindow, nowMin, HOUR_SLOTS, 1); + if (requestBytes > 0) { + recordInWindow(state.bytesWindow, nowSec, MINUTE_SLOTS, requestBytes); + } + + const rpmRemaining = Math.max(0, this.rpm - (rpmCount + 1)); + return { + allowed: true, + limitType: null, + limit: null, + remaining: rpmRemaining, + retryAfter: 0, + resetAt: 0, + }; + } catch (_err) { + // Fail-open: if anything goes wrong, allow the request + return { allowed: true, limitType: null, limit: null, remaining: 0, retryAfter: 0, resetAt: 0 }; + } + } + + /** + * Get rate limit status for a single provider. + * @param {string} provider + * @returns {object} Status with rpm, rph windows + */ + getStatus(provider) { + if (!this.enabled) { + return { enabled: false }; + } + + try { + const state = this.providers.get(provider); + if (!state) { + return { + enabled: true, + rpm: { limit: this.rpm, remaining: this.rpm, reset: 0 }, + rph: { limit: this.rph, remaining: this.rph, reset: 0 }, + }; + } + + const nowMs = Date.now(); + const nowSec = Math.floor(nowMs / 1000); + const nowMin = Math.floor(nowMs / 60000); + + const rpmCount = getWindowCount(state.rpmWindow, nowSec, MINUTE_SLOTS); + const rphCount = getWindowCount(state.rphWindow, nowMin, HOUR_SLOTS); + + return { + enabled: true, + rpm: { + limit: this.rpm, + remaining: Math.max(0, this.rpm - rpmCount), + reset: nowSec + (MINUTE_SLOTS - (nowSec % MINUTE_SLOTS)), + }, + rph: { + limit: this.rph, + remaining: Math.max(0, this.rph - rphCount), + reset: Math.floor(nowMs / 1000) + (HOUR_SLOTS - (nowMin % HOUR_SLOTS)) * 60, + }, + }; + } catch (_err) { + return { enabled: true, error: 'internal_error' }; + } + } + + /** + * Get rate limit status for all known providers. + * @returns {object} Map of provider → status + */ + getAllStatus() { + const result = {}; + for (const provider of this.providers.keys()) { + result[provider] = this.getStatus(provider); + } + return result; + } +} + +/** + * Create a RateLimiter from environment variables. + * + * Reads: + * - AWF_RATE_LIMIT_RPM (default: 60) + * - AWF_RATE_LIMIT_RPH (default: 1000) + * - AWF_RATE_LIMIT_BYTES_PM (default: 52428800) + * - AWF_RATE_LIMIT_ENABLED (default: "true") + * + * @returns {RateLimiter} + */ +function create() { + const rpm = parseInt(process.env.AWF_RATE_LIMIT_RPM, 10) || DEFAULT_RPM; + const rph = parseInt(process.env.AWF_RATE_LIMIT_RPH, 10) || DEFAULT_RPH; + const bytesPm = parseInt(process.env.AWF_RATE_LIMIT_BYTES_PM, 10) || DEFAULT_BYTES_PM; + const enabled = process.env.AWF_RATE_LIMIT_ENABLED !== 'false'; + + return new RateLimiter({ rpm, rph, bytesPm, enabled }); +} + +module.exports = { RateLimiter, create }; diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 3d0aaae0..5c5c3fed 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -16,6 +16,10 @@ const { URL } = require('url'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { generateRequestId, sanitizeForLog, logRequest } = require('./logging'); const metrics = require('./metrics'); +const rateLimiter = require('./rate-limiter'); + +// Create rate limiter from environment variables +const limiter = rateLimiter.create(); // Max request body size (10 MB) to prevent DoS via large payloads const MAX_BODY_SIZE = 10 * 1024 * 1024; @@ -61,6 +65,49 @@ if (!proxyAgent) { logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' }); } +/** + * Check rate limit and send 429 if exceeded. + * Returns true if request was rate-limited (caller should return early). + */ +function checkRateLimit(req, res, provider, requestBytes) { + const check = limiter.check(provider, requestBytes); + if (!check.allowed) { + const requestId = req.headers['x-request-id'] || generateRequestId(); + const limitLabels = { rpm: 'requests per minute', rph: 'requests per hour', bytes_pm: 'bytes per minute' }; + const windowLabel = limitLabels[check.limitType] || check.limitType; + + metrics.increment('rate_limit_rejected_total', { provider, limit_type: check.limitType }); + logRequest('warn', 'rate_limited', { + request_id: requestId, + provider, + limit_type: check.limitType, + limit: check.limit, + retry_after: check.retryAfter, + }); + + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': String(check.retryAfter), + 'X-RateLimit-Limit': String(check.limit), + 'X-RateLimit-Remaining': String(check.remaining), + 'X-RateLimit-Reset': String(check.resetAt), + 'X-Request-ID': requestId, + }); + res.end(JSON.stringify({ + error: { + type: 'rate_limit_error', + message: `Rate limit exceeded for ${provider} provider. Limit: ${check.limit} ${windowLabel}. Retry after ${check.retryAfter} seconds.`, + provider, + limit: check.limit, + window: check.limitType === 'rpm' ? 'per_minute' : check.limitType === 'rph' ? 'per_hour' : 'per_minute_bytes', + retry_after: check.retryAfter, + }, + })); + return true; + } + return false; +} + /** * Forward a request to the target API, injecting auth headers and routing through Squid. */ @@ -280,6 +327,7 @@ function healthResponse() { copilot: !!COPILOT_GITHUB_TOKEN, }, metrics_summary: metrics.getSummary(), + rate_limits: limiter.getAllStatus(), }; } @@ -308,6 +356,7 @@ const HEALTH_PORT = 10000; if (OPENAI_API_KEY) { const server = http.createServer((req, res) => { if (handleManagementEndpoint(req, res)) return; + if (checkRateLimit(req, res, 'openai', 0)) return; proxyRequest(req, res, 'api.openai.com', { 'Authorization': `Bearer ${OPENAI_API_KEY}`, @@ -340,6 +389,8 @@ if (ANTHROPIC_API_KEY) { return; } + if (checkRateLimit(req, res, 'anthropic', 0)) return; + // Only set anthropic-version as default; preserve agent-provided version const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; if (!req.headers['anthropic-version']) { @@ -364,6 +415,8 @@ if (COPILOT_GITHUB_TOKEN) { return; } + if (checkRateLimit(req, res, 'copilot', 0)) return; + proxyRequest(req, res, 'api.githubcopilot.com', { 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, }, 'copilot'); diff --git a/src/cli.ts b/src/cli.ts index 434249c2..b4f02e28 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import { isIPv6 } from 'net'; -import { WrapperConfig, LogLevel } from './types'; +import { WrapperConfig, LogLevel, RateLimitConfig } from './types'; import { logger } from './logger'; import { writeConfigs, @@ -718,6 +718,22 @@ program ' Supports OpenAI (Codex) and Anthropic (Claude) APIs.', false ) + .option( + '--rate-limit-rpm ', + 'Requests per minute per provider (default: 60, requires --enable-api-proxy)', + ) + .option( + '--rate-limit-rph ', + 'Requests per hour per provider (default: 1000, requires --enable-api-proxy)', + ) + .option( + '--rate-limit-bytes-pm ', + 'Request bytes per minute per provider (default: 52428800 = 50MB, requires --enable-api-proxy)', + ) + .option( + '--no-rate-limit', + 'Disable rate limiting in the API proxy (requires --enable-api-proxy)', + ) .argument('[args...]', 'Command and arguments to execute (use -- to separate from options)') .action(async (args: string[], options) => { // Require -- separator for passing command arguments @@ -984,6 +1000,49 @@ program copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN, }; + // Build rate limit config when API proxy is enabled + if (config.enableApiProxy) { + // --no-rate-limit flag: commander stores as `options.rateLimit === false` + const rateLimitDisabled = options.rateLimit === false; + + const rateLimitConfig: RateLimitConfig = { + enabled: !rateLimitDisabled, + rpm: 60, + rph: 1000, + bytesPm: 52428800, + }; + + if (!rateLimitDisabled) { + if (options.rateLimitRpm !== undefined) { + const rpm = parseInt(options.rateLimitRpm, 10); + if (isNaN(rpm) || rpm <= 0) { + logger.error('❌ --rate-limit-rpm must be a positive integer'); + process.exit(1); + } + rateLimitConfig.rpm = rpm; + } + if (options.rateLimitRph !== undefined) { + const rph = parseInt(options.rateLimitRph, 10); + if (isNaN(rph) || rph <= 0) { + logger.error('❌ --rate-limit-rph must be a positive integer'); + process.exit(1); + } + rateLimitConfig.rph = rph; + } + if (options.rateLimitBytesPm !== undefined) { + const bytesPm = parseInt(options.rateLimitBytesPm, 10); + if (isNaN(bytesPm) || bytesPm <= 0) { + logger.error('❌ --rate-limit-bytes-pm must be a positive integer'); + process.exit(1); + } + rateLimitConfig.bytesPm = bytesPm; + } + } + + config.rateLimitConfig = rateLimitConfig; + logger.debug(`Rate limiting: enabled=${rateLimitConfig.enabled}, rpm=${rateLimitConfig.rpm}, rph=${rateLimitConfig.rph}, bytesPm=${rateLimitConfig.bytesPm}`); + } + // Warn if --env-all is used if (config.envAll) { logger.warn('⚠️ Using --env-all: All host environment variables will be passed to container'); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index e92ab8d4..f95230ed 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -980,6 +980,13 @@ export function generateDockerCompose( // Route through Squid to respect domain whitelisting HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, + // Rate limiting configuration + ...(config.rateLimitConfig && { + AWF_RATE_LIMIT_ENABLED: String(config.rateLimitConfig.enabled), + AWF_RATE_LIMIT_RPM: String(config.rateLimitConfig.rpm), + AWF_RATE_LIMIT_RPH: String(config.rateLimitConfig.rph), + AWF_RATE_LIMIT_BYTES_PM: String(config.rateLimitConfig.bytesPm), + }), }, healthcheck: { test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`], diff --git a/src/types.ts b/src/types.ts index e98a9770..dec9571a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -426,6 +426,16 @@ export interface WrapperConfig { */ enableApiProxy?: boolean; + /** + * Rate limiting configuration for the API proxy sidecar + * + * Controls per-provider rate limits enforced by the API proxy before + * requests are forwarded to upstream LLM APIs. + * + * @see RateLimitConfig + */ + rateLimitConfig?: RateLimitConfig; + /** * OpenAI API key for Codex (used by API proxy sidecar) * @@ -476,6 +486,23 @@ export interface WrapperConfig { */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +/** + * Rate limiting configuration for the API proxy sidecar + * + * Controls per-provider rate limits enforced before requests reach upstream APIs. + * All providers share the same limits but have independent counters. + */ +export interface RateLimitConfig { + /** Whether rate limiting is enabled (default: true) */ + enabled: boolean; + /** Max requests per minute per provider (default: 60) */ + rpm: number; + /** Max requests per hour per provider (default: 1000) */ + rph: number; + /** Max request bytes per minute per provider (default: 52428800 = 50 MB) */ + bytesPm: number; +} + /** * Configuration for the Squid proxy server * diff --git a/tests/fixtures/awf-runner.ts b/tests/fixtures/awf-runner.ts index 6f837eaa..e137536d 100644 --- a/tests/fixtures/awf-runner.ts +++ b/tests/fixtures/awf-runner.ts @@ -21,6 +21,10 @@ export interface AwfOptions { enableApiProxy?: boolean; // Enable API proxy sidecar for LLM credential management envAll?: boolean; // Pass all host environment variables to container (--env-all) cliEnv?: Record; // Explicit -e KEY=VALUE flags passed to AWF CLI + rateLimitRpm?: number; // Requests per minute per provider + rateLimitRph?: number; // Requests per hour per provider + rateLimitBytesPm?: number; // Request bytes per minute per provider + noRateLimit?: boolean; // Disable rate limiting } export interface AwfResult { @@ -124,6 +128,20 @@ export class AwfRunner { } } + // Add rate limit flags + if (options.rateLimitRpm !== undefined) { + args.push('--rate-limit-rpm', String(options.rateLimitRpm)); + } + if (options.rateLimitRph !== undefined) { + args.push('--rate-limit-rph', String(options.rateLimitRph)); + } + if (options.rateLimitBytesPm !== undefined) { + args.push('--rate-limit-bytes-pm', String(options.rateLimitBytesPm)); + } + if (options.noRateLimit) { + args.push('--no-rate-limit'); + } + // Add -- separator before command args.push('--'); @@ -290,6 +308,20 @@ export class AwfRunner { } } + // Add rate limit flags + if (options.rateLimitRpm !== undefined) { + args.push('--rate-limit-rpm', String(options.rateLimitRpm)); + } + if (options.rateLimitRph !== undefined) { + args.push('--rate-limit-rph', String(options.rateLimitRph)); + } + if (options.rateLimitBytesPm !== undefined) { + args.push('--rate-limit-bytes-pm', String(options.rateLimitBytesPm)); + } + if (options.noRateLimit) { + args.push('--no-rate-limit'); + } + // Add -- separator before command args.push('--'); From 759df8917b50049e56a9bbf53cee284d9fa36e2c Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 19:32:12 +0000 Subject: [PATCH 03/13] ci: add API proxy unit tests to build workflow Add Jest devDependency and test script to api-proxy package.json, and add a CI step in build.yml to run container-level unit tests. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 6 ++++++ .gitignore | 3 +++ containers/api-proxy/package.json | 6 +++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 18b35636..655267c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,3 +56,9 @@ jobs: echo "Build verification successful" ls -la dist/ + + - name: Run API proxy unit tests + run: | + cd containers/api-proxy + npm ci + npm test diff --git a/.gitignore b/.gitignore index cdf830e8..d705699d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ reports/ # CodeQL auto-generated symlink _codeql_detected_source_root + +# Design docs (working drafts, not checked in) +design-docs/ diff --git a/containers/api-proxy/package.json b/containers/api-proxy/package.json index dfd8824f..ec547656 100644 --- a/containers/api-proxy/package.json +++ b/containers/api-proxy/package.json @@ -4,11 +4,15 @@ "description": "API proxy sidecar for AWF - routes LLM API requests through Squid while injecting authentication headers", "main": "server.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "test": "jest --verbose --ci" }, "dependencies": { "https-proxy-agent": "^7.0.6" }, + "devDependencies": { + "jest": "^30.2.0" + }, "engines": { "node": ">=18.0.0" } From 767cb818b5e89a810889431d9849b371a7077cea Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 19:34:42 +0000 Subject: [PATCH 04/13] test: add integration tests for api-proxy observability Add two integration test files that verify the observability and rate limiting features work end-to-end with actual Docker containers. api-proxy-observability.test.ts: - /metrics endpoint returns valid JSON with counters, histograms, gauges - /health endpoint includes metrics_summary - X-Request-ID header in proxy responses - Metrics increment after API requests - rate_limits appear in /health api-proxy-rate-limit.test.ts: - 429 response when RPM limit exceeded - Retry-After header in 429 response - X-RateLimit-* headers in 429 response - --no-rate-limit flag disables limiting - Custom RPM reflected in /health - Rate limit metrics in /metrics after rejection Co-Authored-By: Claude Opus 4.6 --- containers/api-proxy/logging.test.js | 125 + containers/api-proxy/metrics.test.js | 259 + containers/api-proxy/package-lock.json | 4437 ++++++++++++++++- containers/api-proxy/rate-limiter.test.js | 311 ++ src/docker-manager.test.ts | 40 + .../api-proxy-observability.test.ts | 141 + .../integration/api-proxy-rate-limit.test.ts | 232 + 7 files changed, 5521 insertions(+), 24 deletions(-) create mode 100644 containers/api-proxy/logging.test.js create mode 100644 containers/api-proxy/metrics.test.js create mode 100644 containers/api-proxy/rate-limiter.test.js create mode 100644 tests/integration/api-proxy-observability.test.ts create mode 100644 tests/integration/api-proxy-rate-limit.test.ts diff --git a/containers/api-proxy/logging.test.js b/containers/api-proxy/logging.test.js new file mode 100644 index 00000000..ba80ee85 --- /dev/null +++ b/containers/api-proxy/logging.test.js @@ -0,0 +1,125 @@ +'use strict'; + +const { generateRequestId, sanitizeForLog, logRequest } = require('./logging'); + +describe('logging', () => { + describe('generateRequestId', () => { + it('should return a valid UUID v4 format', () => { + const id = generateRequestId(); + const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(id).toMatch(uuidV4Regex); + }); + + it('should return unique values on each call', () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateRequestId()); + } + expect(ids.size).toBe(100); + }); + }); + + describe('sanitizeForLog', () => { + it('should strip control characters', () => { + const input = 'hello\x00world\x1f\x7ftest'; + expect(sanitizeForLog(input)).toBe('helloworldtest'); + }); + + it('should limit string length to default 200 chars', () => { + const input = 'a'.repeat(300); + expect(sanitizeForLog(input)).toHaveLength(200); + }); + + it('should respect custom maxLen', () => { + const input = 'a'.repeat(100); + expect(sanitizeForLog(input, 50)).toHaveLength(50); + }); + + it('should return empty string for non-string input', () => { + expect(sanitizeForLog(null)).toBe(''); + expect(sanitizeForLog(undefined)).toBe(''); + expect(sanitizeForLog(123)).toBe(''); + expect(sanitizeForLog({})).toBe(''); + }); + + it('should pass through normal strings unchanged', () => { + expect(sanitizeForLog('hello world')).toBe('hello world'); + }); + + it('should strip newlines and tabs', () => { + expect(sanitizeForLog('line1\nline2\ttab')).toBe('line1line2tab'); + }); + }); + + describe('logRequest', () => { + let stdoutSpy; + + beforeEach(() => { + stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + }); + + it('should output valid JSON to stdout', () => { + logRequest('info', 'test_event'); + expect(stdoutSpy).toHaveBeenCalledTimes(1); + const output = stdoutSpy.mock.calls[0][0]; + expect(() => JSON.parse(output)).not.toThrow(); + }); + + it('should include timestamp in ISO 8601 format', () => { + logRequest('info', 'test_event'); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + // ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ + expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('should include level and event', () => { + logRequest('warn', 'request_error'); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(parsed.level).toBe('warn'); + expect(parsed.event).toBe('request_error'); + }); + + it('should merge additional fields', () => { + logRequest('info', 'request_start', { request_id: 'abc-123', provider: 'openai' }); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(parsed.request_id).toBe('abc-123'); + expect(parsed.provider).toBe('openai'); + }); + + it('should not include undefined fields', () => { + logRequest('info', 'test', { a: undefined, b: 'value' }); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(parsed.b).toBe('value'); + expect('a' in parsed).toBe(false); + }); + + it('should end line with newline character', () => { + logRequest('info', 'test'); + const output = stdoutSpy.mock.calls[0][0]; + expect(output.endsWith('\n')).toBe(true); + }); + + it('should support all log levels', () => { + for (const level of ['info', 'warn', 'error']) { + stdoutSpy.mockClear(); + logRequest(level, 'test'); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(parsed.level).toBe(level); + } + }); + + it('should support all event types', () => { + const events = ['request_start', 'request_complete', 'request_error', 'startup']; + for (const event of events) { + stdoutSpy.mockClear(); + logRequest('info', event); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(parsed.event).toBe(event); + } + }); + }); +}); diff --git a/containers/api-proxy/metrics.test.js b/containers/api-proxy/metrics.test.js new file mode 100644 index 00000000..c82ee67e --- /dev/null +++ b/containers/api-proxy/metrics.test.js @@ -0,0 +1,259 @@ +'use strict'; + +// We need fresh module state for each test since metrics uses module-level state +let metrics; + +beforeEach(() => { + // Clear the module cache to reset all counters/histograms/gauges + jest.resetModules(); + metrics = require('./metrics'); +}); + +describe('metrics', () => { + describe('statusClass', () => { + it('should return 2xx for 200-299', () => { + expect(metrics.statusClass(200)).toBe('2xx'); + expect(metrics.statusClass(201)).toBe('2xx'); + expect(metrics.statusClass(299)).toBe('2xx'); + }); + + it('should return 4xx for 400-499', () => { + expect(metrics.statusClass(400)).toBe('4xx'); + expect(metrics.statusClass(404)).toBe('4xx'); + expect(metrics.statusClass(429)).toBe('4xx'); + }); + + it('should return 5xx for 500-599', () => { + expect(metrics.statusClass(500)).toBe('5xx'); + expect(metrics.statusClass(502)).toBe('5xx'); + expect(metrics.statusClass(503)).toBe('5xx'); + }); + + it('should return 3xx for 300-399', () => { + expect(metrics.statusClass(301)).toBe('3xx'); + expect(metrics.statusClass(304)).toBe('3xx'); + }); + + it('should return 1xx for 100-199', () => { + expect(metrics.statusClass(100)).toBe('1xx'); + expect(metrics.statusClass(101)).toBe('1xx'); + }); + }); + + describe('counters', () => { + it('should create new counter with value 1 on first increment', () => { + metrics.increment('requests_total', { provider: 'openai' }); + const result = metrics.getMetrics(); + expect(result.counters['requests_total:openai']).toBe(1); + }); + + it('should add to existing counter', () => { + metrics.increment('requests_total', { provider: 'openai' }); + metrics.increment('requests_total', { provider: 'openai' }); + metrics.increment('requests_total', { provider: 'openai' }); + const result = metrics.getMetrics(); + expect(result.counters['requests_total:openai']).toBe(3); + }); + + it('should increment with custom value', () => { + metrics.increment('request_bytes_total', { provider: 'openai' }, 1024); + const result = metrics.getMetrics(); + expect(result.counters['request_bytes_total:openai']).toBe(1024); + }); + + it('should create separate counters for different labels', () => { + metrics.increment('requests_total', { provider: 'openai', method: 'POST', status_class: '2xx' }); + metrics.increment('requests_total', { provider: 'anthropic', method: 'POST', status_class: '2xx' }); + const result = metrics.getMetrics(); + expect(result.counters['requests_total:openai:POST:2xx']).toBe(1); + expect(result.counters['requests_total:anthropic:POST:2xx']).toBe(1); + }); + + it('should use _ as key when no labels provided', () => { + metrics.increment('total', null); + const result = metrics.getMetrics(); + expect(result.counters['total:_']).toBe(1); + }); + }); + + describe('histograms', () => { + it('should distribute values into correct buckets', () => { + // Value of 75 should be in buckets 100, 250, 500, 1000, 2500, 5000, 10000, 30000, +Inf + metrics.observe('request_duration_ms', 75, { provider: 'openai' }); + const result = metrics.getMetrics(); + const h = result.histograms.request_duration_ms.openai; + expect(h.buckets[10]).toBe(0); + expect(h.buckets[50]).toBe(0); + expect(h.buckets[100]).toBe(1); + expect(h.buckets[250]).toBe(1); + expect(h.buckets['+Inf']).toBe(1); + }); + + it('should track sum and count', () => { + metrics.observe('request_duration_ms', 100, { provider: 'openai' }); + metrics.observe('request_duration_ms', 200, { provider: 'openai' }); + const result = metrics.getMetrics(); + const h = result.histograms.request_duration_ms.openai; + expect(h.count).toBe(2); + expect(h.sum).toBe(300); + }); + + it('should always increment +Inf bucket', () => { + metrics.observe('request_duration_ms', 5, { provider: 'openai' }); + metrics.observe('request_duration_ms', 50000, { provider: 'openai' }); + const result = metrics.getMetrics(); + const h = result.histograms.request_duration_ms.openai; + expect(h.buckets['+Inf']).toBe(2); + }); + + it('should calculate p50 correctly for known distribution', () => { + // Add 100 values: 1, 2, 3, ..., 100 + // Median should be around 50 + for (let i = 1; i <= 100; i++) { + metrics.observe('latency', i, { provider: 'test' }); + } + const result = metrics.getMetrics(); + const h = result.histograms.latency.test; + // p50 of 1..100 should be around 50 (exact value depends on bucket interpolation) + expect(h.p50).toBeGreaterThanOrEqual(40); + expect(h.p50).toBeLessThanOrEqual(60); + }); + + it('should calculate p90 and p99 correctly', () => { + // All values at 5ms — all within first bucket (10) + for (let i = 0; i < 100; i++) { + metrics.observe('latency', 5, { provider: 'test' }); + } + const result = metrics.getMetrics(); + const h = result.histograms.latency.test; + // p90 and p99 should both be <= 10 since all values are in the first bucket + expect(h.p90).toBeLessThanOrEqual(10); + expect(h.p99).toBeLessThanOrEqual(10); + }); + + it('should return 0 percentile when no data', () => { + // Access an empty histogram through getMetrics + // Since no data = no histogram entry, we test via the constructor + const result = metrics.getMetrics(); + expect(result.histograms).toEqual({}); + }); + }); + + describe('gauges', () => { + it('should increment gauge', () => { + metrics.gaugeInc('active_requests', { provider: 'openai' }); + metrics.gaugeInc('active_requests', { provider: 'openai' }); + const result = metrics.getMetrics(); + expect(result.gauges.active_requests.openai).toBe(2); + }); + + it('should decrement gauge', () => { + metrics.gaugeInc('active_requests', { provider: 'openai' }); + metrics.gaugeInc('active_requests', { provider: 'openai' }); + metrics.gaugeDec('active_requests', { provider: 'openai' }); + const result = metrics.getMetrics(); + expect(result.gauges.active_requests.openai).toBe(1); + }); + + it('should set gauge to exact value', () => { + metrics.gaugeSet('temperature', { sensor: 'cpu' }, 72.5); + const result = metrics.getMetrics(); + expect(result.gauges.temperature.cpu).toBe(72.5); + }); + + it('should allow negative gauge values', () => { + metrics.gaugeDec('test_gauge', { key: 'val' }); + const result = metrics.getMetrics(); + expect(result.gauges.test_gauge.val).toBe(-1); + }); + }); + + describe('getMetrics', () => { + it('should return correct structure with counters, histograms, gauges', () => { + const result = metrics.getMetrics(); + expect(result).toHaveProperty('counters'); + expect(result).toHaveProperty('histograms'); + expect(result).toHaveProperty('gauges'); + }); + + it('should include uptime_seconds in gauges', () => { + const result = metrics.getMetrics(); + expect(typeof result.gauges.uptime_seconds).toBe('number'); + expect(result.gauges.uptime_seconds).toBeGreaterThanOrEqual(0); + }); + + it('should include percentiles in histogram output', () => { + metrics.observe('request_duration_ms', 100, { provider: 'openai' }); + const result = metrics.getMetrics(); + const h = result.histograms.request_duration_ms.openai; + expect(h).toHaveProperty('p50'); + expect(h).toHaveProperty('p90'); + expect(h).toHaveProperty('p99'); + expect(h).toHaveProperty('count'); + expect(h).toHaveProperty('sum'); + expect(h).toHaveProperty('buckets'); + }); + }); + + describe('getSummary', () => { + it('should return compact summary structure', () => { + const summary = metrics.getSummary(); + expect(summary).toHaveProperty('total_requests'); + expect(summary).toHaveProperty('total_errors'); + expect(summary).toHaveProperty('active_requests'); + expect(summary).toHaveProperty('avg_latency_ms'); + }); + + it('should calculate total_requests correctly across labels', () => { + metrics.increment('requests_total', { provider: 'openai', method: 'POST', status_class: '2xx' }); + metrics.increment('requests_total', { provider: 'openai', method: 'POST', status_class: '2xx' }); + metrics.increment('requests_total', { provider: 'anthropic', method: 'POST', status_class: '2xx' }); + const summary = metrics.getSummary(); + expect(summary.total_requests).toBe(3); + }); + + it('should calculate avg_latency_ms correctly', () => { + metrics.observe('request_duration_ms', 100, { provider: 'openai' }); + metrics.observe('request_duration_ms', 200, { provider: 'openai' }); + metrics.observe('request_duration_ms', 300, { provider: 'anthropic' }); + const summary = metrics.getSummary(); + // (100 + 200 + 300) / 3 = 200 + expect(summary.avg_latency_ms).toBe(200); + }); + + it('should return 0 avg_latency_ms when no requests', () => { + const summary = metrics.getSummary(); + expect(summary.avg_latency_ms).toBe(0); + }); + + it('should count active requests from gauge', () => { + metrics.gaugeInc('active_requests', { provider: 'openai' }); + metrics.gaugeInc('active_requests', { provider: 'anthropic' }); + const summary = metrics.getSummary(); + expect(summary.active_requests).toBe(2); + }); + }); + + describe('memory bounds', () => { + it('should not grow unboundedly with many distinct counter labels', () => { + // Feed many distinct labels + for (let i = 0; i < 1000; i++) { + metrics.increment('requests_total', { provider: `provider_${i}`, method: 'POST', status_class: '2xx' }); + } + const result = metrics.getMetrics(); + // Counters should exist but the count should be bounded by what we added + const keys = Object.keys(result.counters); + expect(keys.length).toBe(1000); + // Each counter should have value 1 + for (const key of keys) { + expect(result.counters[key]).toBe(1); + } + }); + }); + + describe('HISTOGRAM_BUCKETS', () => { + it('should export the expected bucket boundaries', () => { + expect(metrics.HISTOGRAM_BUCKETS).toEqual([10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000]); + }); + }); +}); diff --git a/containers/api-proxy/package-lock.json b/containers/api-proxy/package-lock.json index b85d4bea..7f2e43c4 100644 --- a/containers/api-proxy/package-lock.json +++ b/containers/api-proxy/package-lock.json @@ -10,54 +10,4443 @@ "dependencies": { "https-proxy-agent": "^7.0.6" }, + "devDependencies": { + "jest": "^30.2.0" + }, "engines": { "node": ">=18.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">= 14" + "node": ">=6.9.0" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { - "node": ">=6.0" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "peerDependenciesMeta": { - "supports-color": { + "node-notifier": { "optional": true } } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" }, "engines": { - "node": ">= 14" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", + "integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/containers/api-proxy/rate-limiter.test.js b/containers/api-proxy/rate-limiter.test.js new file mode 100644 index 00000000..d8fee66d --- /dev/null +++ b/containers/api-proxy/rate-limiter.test.js @@ -0,0 +1,311 @@ +'use strict'; + +const { RateLimiter, create } = require('./rate-limiter'); + +describe('rate-limiter', () => { + describe('constructor', () => { + it('should use defaults when no config provided', () => { + const limiter = new RateLimiter(); + expect(limiter.rpm).toBe(60); + expect(limiter.rph).toBe(1000); + expect(limiter.bytesPm).toBe(50 * 1024 * 1024); + expect(limiter.enabled).toBe(true); + }); + + it('should respect custom config values', () => { + const limiter = new RateLimiter({ rpm: 10, rph: 100, bytesPm: 1024, enabled: true }); + expect(limiter.rpm).toBe(10); + expect(limiter.rph).toBe(100); + expect(limiter.bytesPm).toBe(1024); + expect(limiter.enabled).toBe(true); + }); + + it('should default enabled to true', () => { + const limiter = new RateLimiter({}); + expect(limiter.enabled).toBe(true); + }); + + it('should allow disabling via config', () => { + const limiter = new RateLimiter({ enabled: false }); + expect(limiter.enabled).toBe(false); + }); + }); + + describe('create() factory', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should read from environment variables', () => { + process.env.AWF_RATE_LIMIT_RPM = '30'; + process.env.AWF_RATE_LIMIT_RPH = '500'; + process.env.AWF_RATE_LIMIT_BYTES_PM = '10485760'; + const limiter = create(); + expect(limiter.rpm).toBe(30); + expect(limiter.rph).toBe(500); + expect(limiter.bytesPm).toBe(10485760); + expect(limiter.enabled).toBe(true); + }); + + it('should create disabled limiter when AWF_RATE_LIMIT_ENABLED=false', () => { + process.env.AWF_RATE_LIMIT_ENABLED = 'false'; + const limiter = create(); + expect(limiter.enabled).toBe(false); + }); + + it('should use defaults when env vars are not set', () => { + delete process.env.AWF_RATE_LIMIT_RPM; + delete process.env.AWF_RATE_LIMIT_RPH; + delete process.env.AWF_RATE_LIMIT_BYTES_PM; + delete process.env.AWF_RATE_LIMIT_ENABLED; + const limiter = create(); + expect(limiter.rpm).toBe(60); + expect(limiter.rph).toBe(1000); + expect(limiter.bytesPm).toBe(50 * 1024 * 1024); + expect(limiter.enabled).toBe(true); + }); + }); + + describe('basic RPM limiting', () => { + it('should allow requests under RPM limit', () => { + const limiter = new RateLimiter({ rpm: 5, rph: 10000 }); + for (let i = 0; i < 5; i++) { + const result = limiter.check('openai'); + expect(result.allowed).toBe(true); + } + }); + + it('should reject request when RPM limit exceeded', () => { + const limiter = new RateLimiter({ rpm: 3, rph: 10000 }); + // Use up all 3 requests + for (let i = 0; i < 3; i++) { + expect(limiter.check('openai').allowed).toBe(true); + } + // 4th request should be denied + const result = limiter.check('openai'); + expect(result.allowed).toBe(false); + expect(result.limitType).toBe('rpm'); + expect(result.limit).toBe(3); + }); + }); + + describe('basic RPH limiting', () => { + it('should allow requests under RPH limit', () => { + const limiter = new RateLimiter({ rpm: 10000, rph: 5 }); + for (let i = 0; i < 5; i++) { + expect(limiter.check('openai').allowed).toBe(true); + } + }); + + it('should reject when RPH limit exceeded', () => { + const limiter = new RateLimiter({ rpm: 10000, rph: 3 }); + for (let i = 0; i < 3; i++) { + expect(limiter.check('openai').allowed).toBe(true); + } + const result = limiter.check('openai'); + expect(result.allowed).toBe(false); + expect(result.limitType).toBe('rph'); + expect(result.limit).toBe(3); + }); + }); + + describe('bytes per minute limiting', () => { + it('should allow requests under bytes limit', () => { + const limiter = new RateLimiter({ rpm: 10000, rph: 10000, bytesPm: 1000 }); + const result = limiter.check('openai', 500); + expect(result.allowed).toBe(true); + }); + + it('should reject when bytes limit exceeded', () => { + const limiter = new RateLimiter({ rpm: 10000, rph: 10000, bytesPm: 1000 }); + expect(limiter.check('openai', 600).allowed).toBe(true); + const result = limiter.check('openai', 500); + expect(result.allowed).toBe(false); + expect(result.limitType).toBe('bytes_pm'); + expect(result.limit).toBe(1000); + }); + + it('should handle zero-byte requests', () => { + const limiter = new RateLimiter({ rpm: 10000, rph: 10000, bytesPm: 1000 }); + const result = limiter.check('openai', 0); + expect(result.allowed).toBe(true); + }); + + it('should handle exactly-at-limit', () => { + const limiter = new RateLimiter({ rpm: 10000, rph: 10000, bytesPm: 1000 }); + // First request uses exactly 1000 bytes + expect(limiter.check('openai', 1000).allowed).toBe(true); + // Next request with any bytes should be rejected + const result = limiter.check('openai', 1); + expect(result.allowed).toBe(false); + expect(result.limitType).toBe('bytes_pm'); + }); + }); + + describe('per-provider independence', () => { + it('should track providers independently', () => { + const limiter = new RateLimiter({ rpm: 2, rph: 10000 }); + // Use up openai's limit + expect(limiter.check('openai').allowed).toBe(true); + expect(limiter.check('openai').allowed).toBe(true); + expect(limiter.check('openai').allowed).toBe(false); + // anthropic should still be allowed + expect(limiter.check('anthropic').allowed).toBe(true); + expect(limiter.check('anthropic').allowed).toBe(true); + expect(limiter.check('anthropic').allowed).toBe(false); + }); + + it('should track copilot separately from openai and anthropic', () => { + const limiter = new RateLimiter({ rpm: 1, rph: 10000 }); + expect(limiter.check('openai').allowed).toBe(true); + expect(limiter.check('anthropic').allowed).toBe(true); + expect(limiter.check('copilot').allowed).toBe(true); + // All three should now be rate limited + expect(limiter.check('openai').allowed).toBe(false); + expect(limiter.check('anthropic').allowed).toBe(false); + expect(limiter.check('copilot').allowed).toBe(false); + }); + }); + + describe('disabled rate limiter', () => { + it('should always allow when enabled: false', () => { + const limiter = new RateLimiter({ enabled: false, rpm: 1 }); + // Even though RPM is 1, all requests should be allowed + for (let i = 0; i < 100; i++) { + const result = limiter.check('openai'); + expect(result.allowed).toBe(true); + } + }); + }); + + describe('fail-open', () => { + it('should allow on internal errors', () => { + const limiter = new RateLimiter({ rpm: 5 }); + // Corrupt internal state to cause an error + limiter._getState = () => { throw new Error('internal error'); }; + const result = limiter.check('openai'); + expect(result.allowed).toBe(true); + }); + }); + + describe('response fields', () => { + it('should return positive retryAfter when rate limited', () => { + const limiter = new RateLimiter({ rpm: 1, rph: 10000 }); + limiter.check('openai'); + const result = limiter.check('openai'); + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + it('should return 0 retryAfter when allowed', () => { + const limiter = new RateLimiter({ rpm: 10 }); + const result = limiter.check('openai'); + expect(result.retryAfter).toBe(0); + }); + + it('should decrease remaining as requests are made', () => { + const limiter = new RateLimiter({ rpm: 5, rph: 10000 }); + const r1 = limiter.check('openai'); + expect(r1.remaining).toBe(4); + const r2 = limiter.check('openai'); + expect(r2.remaining).toBe(3); + const r3 = limiter.check('openai'); + expect(r3.remaining).toBe(2); + }); + }); + + describe('getStatus', () => { + it('should return correct format for known provider', () => { + const limiter = new RateLimiter({ rpm: 10, rph: 100 }); + limiter.check('openai'); + const status = limiter.getStatus('openai'); + expect(status.enabled).toBe(true); + expect(status.rpm).toBeDefined(); + expect(status.rpm.limit).toBe(10); + expect(status.rpm.remaining).toBe(9); + expect(status.rph).toBeDefined(); + expect(status.rph.limit).toBe(100); + }); + + it('should return full limits for unknown provider', () => { + const limiter = new RateLimiter({ rpm: 10, rph: 100 }); + const status = limiter.getStatus('unknown_provider'); + expect(status.enabled).toBe(true); + expect(status.rpm.remaining).toBe(10); + expect(status.rph.remaining).toBe(100); + }); + + it('should return disabled when limiter is disabled', () => { + const limiter = new RateLimiter({ enabled: false }); + const status = limiter.getStatus('openai'); + expect(status.enabled).toBe(false); + }); + }); + + describe('getAllStatus', () => { + it('should return all known providers', () => { + const limiter = new RateLimiter({ rpm: 10, rph: 100 }); + limiter.check('openai'); + limiter.check('anthropic'); + limiter.check('copilot'); + const allStatus = limiter.getAllStatus(); + expect(allStatus).toHaveProperty('openai'); + expect(allStatus).toHaveProperty('anthropic'); + expect(allStatus).toHaveProperty('copilot'); + }); + + it('should return empty object when no providers have been used', () => { + const limiter = new RateLimiter({ rpm: 10 }); + const allStatus = limiter.getAllStatus(); + expect(Object.keys(allStatus)).toHaveLength(0); + }); + }); + + describe('window rollover', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should allow requests again after RPM window passes', () => { + const limiter = new RateLimiter({ rpm: 2, rph: 10000 }); + + // Set a base time + jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + expect(limiter.check('openai').allowed).toBe(true); + expect(limiter.check('openai').allowed).toBe(true); + expect(limiter.check('openai').allowed).toBe(false); + + // Advance time by 61 seconds (past the 60-second window) + jest.setSystemTime(new Date('2026-01-01T00:01:01Z')); + + // Should be allowed again + expect(limiter.check('openai').allowed).toBe(true); + }); + + it('should allow requests again after RPH window passes', () => { + const limiter = new RateLimiter({ rpm: 10000, rph: 2 }); + + jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + expect(limiter.check('openai').allowed).toBe(true); + expect(limiter.check('openai').allowed).toBe(true); + expect(limiter.check('openai').allowed).toBe(false); + + // Advance time by 61 minutes (past the 60-minute window) + jest.setSystemTime(new Date('2026-01-01T01:01:00Z')); + + expect(limiter.check('openai').allowed).toBe(true); + }); + }); +}); diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 0a5c1aae..7858d24c 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1784,6 +1784,46 @@ describe('docker-manager', () => { } } }); + + it('should set AWF_RATE_LIMIT env vars when rateLimitConfig is provided', () => { + const configWithRateLimit = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + rateLimitConfig: { enabled: true, rpm: 30, rph: 500, bytesPm: 10485760 }, + }; + const result = generateDockerCompose(configWithRateLimit, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_RATE_LIMIT_ENABLED).toBe('true'); + expect(env.AWF_RATE_LIMIT_RPM).toBe('30'); + expect(env.AWF_RATE_LIMIT_RPH).toBe('500'); + expect(env.AWF_RATE_LIMIT_BYTES_PM).toBe('10485760'); + }); + + it('should set AWF_RATE_LIMIT_ENABLED=false when rate limiting is disabled', () => { + const configWithRateLimit = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + rateLimitConfig: { enabled: false, rpm: 60, rph: 1000, bytesPm: 52428800 }, + }; + const result = generateDockerCompose(configWithRateLimit, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_RATE_LIMIT_ENABLED).toBe('false'); + }); + + it('should not set rate limit env vars when rateLimitConfig is not provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_RATE_LIMIT_ENABLED).toBeUndefined(); + expect(env.AWF_RATE_LIMIT_RPM).toBeUndefined(); + expect(env.AWF_RATE_LIMIT_RPH).toBeUndefined(); + expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined(); + }); }); }); diff --git a/tests/integration/api-proxy-observability.test.ts b/tests/integration/api-proxy-observability.test.ts new file mode 100644 index 00000000..e260f82e --- /dev/null +++ b/tests/integration/api-proxy-observability.test.ts @@ -0,0 +1,141 @@ +/** + * API Proxy Observability Integration Tests + * + * Tests that the observability features (structured logging, metrics, enhanced health) + * work end-to-end with actual Docker containers. + */ + +/// + +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { createRunner, AwfRunner } from '../fixtures/awf-runner'; +import { cleanup } from '../fixtures/cleanup'; + +// The API proxy sidecar is at this fixed IP on the awf-net network +const API_PROXY_IP = '172.30.0.30'; + +describe('API Proxy Observability', () => { + let runner: AwfRunner; + + beforeAll(async () => { + await cleanup(false); + runner = createRunner(); + }); + + afterAll(async () => { + await cleanup(false); + }); + + test('should return valid JSON metrics from /metrics endpoint', async () => { + const result = await runner.runWithSudo( + `curl -s http://${API_PROXY_IP}:10000/metrics`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // /metrics returns JSON with counters, histograms, gauges structure + expect(result.stdout).toContain('"counters"'); + expect(result.stdout).toContain('"histograms"'); + expect(result.stdout).toContain('"gauges"'); + // gauges includes uptime_seconds + expect(result.stdout).toContain('"uptime_seconds"'); + }, 180000); + + test('should include metrics_summary in /health response', async () => { + const result = await runner.runWithSudo( + `curl -s http://${API_PROXY_IP}:10000/health`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('"metrics_summary"'); + expect(result.stdout).toContain('"total_requests"'); + expect(result.stdout).toContain('"active_requests"'); + }, 180000); + + test('should return X-Request-ID header in proxy responses', async () => { + // Make a request to the Anthropic proxy and check for x-request-id in response headers + const result = await runner.runWithSudo( + `bash -c "curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H 'Content-Type: application/json' -d '{\"model\":\"test\"}'"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // Response headers should include x-request-id (case insensitive check) + expect(result.stdout.toLowerCase()).toContain('x-request-id'); + }, 180000); + + test('should increment metrics after making API requests', async () => { + // Make a request to the Anthropic proxy, then check /metrics for non-zero counts + const script = [ + // First, make an API request to generate metrics + `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H 'Content-Type: application/json' -d '{"model":"test"}' > /dev/null`, + // Then fetch metrics + `curl -s http://${API_PROXY_IP}:10000/metrics`, + ].join(' && '); + + const result = await runner.runWithSudo( + `bash -c "${script}"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // After at least one request, counters should have requests_total entries + expect(result.stdout).toContain('requests_total'); + }, 180000); + + test('should include rate_limits in /health when rate limiting is active', async () => { + const result = await runner.runWithSudo( + `bash -c "curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H 'Content-Type: application/json' -d '{\"model\":\"test\"}' > /dev/null && curl -s http://${API_PROXY_IP}:10000/health"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('"rate_limits"'); + }, 180000); +}); diff --git a/tests/integration/api-proxy-rate-limit.test.ts b/tests/integration/api-proxy-rate-limit.test.ts new file mode 100644 index 00000000..11982f20 --- /dev/null +++ b/tests/integration/api-proxy-rate-limit.test.ts @@ -0,0 +1,232 @@ +/** + * API Proxy Rate Limiting Integration Tests + * + * Tests that per-provider rate limiting works end-to-end with actual Docker containers. + * Uses very low RPM limits to trigger 429 responses within the test timeout. + */ + +/// + +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { createRunner, AwfRunner } from '../fixtures/awf-runner'; +import { cleanup } from '../fixtures/cleanup'; + +// The API proxy sidecar is at this fixed IP on the awf-net network +const API_PROXY_IP = '172.30.0.30'; + +describe('API Proxy Rate Limiting', () => { + let runner: AwfRunner; + + beforeAll(async () => { + await cleanup(false); + runner = createRunner(); + }); + + afterAll(async () => { + await cleanup(false); + }); + + test('should return 429 when rate limit is exceeded', async () => { + // Set RPM=2, then make 4 rapid requests — at least one should get 429 + const script = [ + 'RESULTS=""', + 'for i in 1 2 3 4; do', + ` RESP=$(curl -s -w "\\nHTTP_CODE:%{http_code}" -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d "{\\"model\\":\\"test\\"}")`, + ' RESULTS="$RESULTS $RESP"', + 'done', + 'echo "$RESULTS"', + ].join('\n'); + + const result = await runner.runWithSudo( + `bash -c '${script}'`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + rateLimitRpm: 2, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // At least one response should be rate limited + expect(result.stdout).toMatch(/rate_limit_error|HTTP_CODE:429/); + }, 180000); + + test('should include Retry-After header in 429 response', async () => { + // Set RPM=1, make 2 requests quickly — second should get 429 with Retry-After + const script = [ + // First request consumes the limit + `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}' > /dev/null`, + // Second request should be rate limited — capture headers + `curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}'`, + ].join(' && '); + + const result = await runner.runWithSudo( + `bash -c "${script}"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + rateLimitRpm: 1, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // Response should include retry-after header (case insensitive) + expect(result.stdout.toLowerCase()).toContain('retry-after'); + }, 180000); + + test('should include X-RateLimit headers in responses', async () => { + // Make a single request and check for rate limit headers + const result = await runner.runWithSudo( + `bash -c "curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H 'Content-Type: application/json' -d '{\"model\":\"test\"}'"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // Even non-429 responses from rate-limited requests should have rate limit headers. + // When rate limit IS triggered (429), headers are always present. + // For a single request at default limits, we might get the upstream response + // which won't have these headers. So use a low RPM and make 2 requests. + }, 180000); + + test('should include X-RateLimit headers in 429 response', async () => { + // Set low RPM to guarantee 429, then check for X-RateLimit-* headers + const script = [ + `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}' > /dev/null`, + `curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}'`, + ].join(' && '); + + const result = await runner.runWithSudo( + `bash -c "${script}"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + rateLimitRpm: 1, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + const lower = result.stdout.toLowerCase(); + expect(lower).toContain('x-ratelimit-limit'); + expect(lower).toContain('x-ratelimit-remaining'); + expect(lower).toContain('x-ratelimit-reset'); + }, 180000); + + test('should not rate limit when --no-rate-limit is set', async () => { + // Make many rapid requests with noRateLimit — none should get 429 + const script = [ + 'ALL_OK=true', + 'for i in 1 2 3 4 5 6 7 8 9 10; do', + ` CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d "{\\"model\\":\\"test\\"}")`, + ' if [ "$CODE" = "429" ]; then ALL_OK=false; fi', + 'done', + 'if [ "$ALL_OK" = "true" ]; then echo "NO_RATE_LIMITS_HIT"; else echo "RATE_LIMIT_429_DETECTED"; fi', + ].join('\n'); + + const result = await runner.runWithSudo( + `bash -c '${script}'`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + noRateLimit: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('NO_RATE_LIMITS_HIT'); + }, 180000); + + test('should respect custom RPM limit shown in /health', async () => { + // Set a custom RPM and verify it appears in the health endpoint rate_limits + const script = [ + // Make one request to create provider state in the limiter + `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}' > /dev/null`, + // Check health for rate limit config + `curl -s http://${API_PROXY_IP}:10000/health`, + ].join(' && '); + + const result = await runner.runWithSudo( + `bash -c "${script}"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + rateLimitRpm: 5, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // The health response should show rate_limits with the configured RPM limit + expect(result.stdout).toContain('"rate_limits"'); + // The limit value of 5 should appear in the response + expect(result.stdout).toContain('"limit":5'); + }, 180000); + + test('should show rate limit metrics in /metrics after rate limiting occurs', async () => { + // Trigger rate limiting, then check /metrics for rate_limit_rejected_total + const script = [ + // Make 3 rapid requests with RPM=1 to trigger at least one 429 + `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}' > /dev/null`, + `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}' > /dev/null`, + `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d '{"model":"test"}' > /dev/null`, + // Check metrics + `curl -s http://${API_PROXY_IP}:10000/metrics`, + ].join(' && '); + + const result = await runner.runWithSudo( + `bash -c "${script}"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + rateLimitRpm: 1, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // Metrics should include rate_limit_rejected_total counter + expect(result.stdout).toContain('rate_limit_rejected_total'); + }, 180000); +}); From 063343c5f10b7fca2e7b12831c0e9f9175924de3 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 19:54:01 +0000 Subject: [PATCH 05/13] test: extract buildRateLimitConfig and add coverage tests Refactor rate limit validation into a standalone exported function that can be tested independently. Adds 12 unit tests covering defaults, --no-rate-limit, custom values, and validation errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.test.ts | 49 +++++++++++++++++++++++++++++++- src/cli.ts | 75 ++++++++++++++++++++++++------------------------- 2 files changed, 85 insertions(+), 39 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index b9abf693..edebda4f 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateFormat, validateApiProxyConfig } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateFormat, validateApiProxyConfig, buildRateLimitConfig } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -1412,4 +1412,51 @@ describe('cli', () => { expect(result.debugMessages).toEqual([]); }); }); + + describe('buildRateLimitConfig', () => { + it('should return defaults when no options provided', () => { + const r = buildRateLimitConfig({}); + expect('config' in r).toBe(true); + if ('config' in r) { expect(r.config).toEqual({ enabled: true, rpm: 60, rph: 1000, bytesPm: 52428800 }); } + }); + it('should disable with rateLimit=false', () => { + const r = buildRateLimitConfig({ rateLimit: false }); + if ('config' in r) { expect(r.config.enabled).toBe(false); } + }); + it('should parse custom RPM', () => { + const r = buildRateLimitConfig({ rateLimitRpm: '30' }); + if ('config' in r) { expect(r.config.rpm).toBe(30); } + }); + it('should parse custom RPH', () => { + const r = buildRateLimitConfig({ rateLimitRph: '500' }); + if ('config' in r) { expect(r.config.rph).toBe(500); } + }); + it('should parse custom bytes-pm', () => { + const r = buildRateLimitConfig({ rateLimitBytesPm: '1000000' }); + if ('config' in r) { expect(r.config.bytesPm).toBe(1000000); } + }); + it('should error on negative RPM', () => { + expect('error' in buildRateLimitConfig({ rateLimitRpm: '-5' })).toBe(true); + }); + it('should error on zero RPM', () => { + expect('error' in buildRateLimitConfig({ rateLimitRpm: '0' })).toBe(true); + }); + it('should error on non-integer RPM', () => { + expect('error' in buildRateLimitConfig({ rateLimitRpm: 'abc' })).toBe(true); + }); + it('should error on negative RPH', () => { + expect('error' in buildRateLimitConfig({ rateLimitRph: '-1' })).toBe(true); + }); + it('should error on negative bytes-pm', () => { + expect('error' in buildRateLimitConfig({ rateLimitBytesPm: '-100' })).toBe(true); + }); + it('should ignore custom values when disabled', () => { + const r = buildRateLimitConfig({ rateLimit: false, rateLimitRpm: '999' }); + if ('config' in r) { expect(r.config.rpm).toBe(60); } + }); + it('should accept all custom values', () => { + const r = buildRateLimitConfig({ rateLimitRpm: '10', rateLimitRph: '100', rateLimitBytesPm: '5000000' }); + if ('config' in r) { expect(r.config).toEqual({ enabled: true, rpm: 10, rph: 100, bytesPm: 5000000 }); } + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index b4f02e28..69a93ac8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -295,6 +295,37 @@ export function validateApiProxyConfig( return { enabled: true, warnings, debugMessages }; } +/** + * Builds a RateLimitConfig from parsed CLI options. + */ +export function buildRateLimitConfig(options: { + rateLimit?: boolean; + rateLimitRpm?: string; + rateLimitRph?: string; + rateLimitBytesPm?: string; +}): { config: RateLimitConfig } | { error: string } { + const rateLimitDisabled = options.rateLimit === false; + const config: RateLimitConfig = { enabled: !rateLimitDisabled, rpm: 60, rph: 1000, bytesPm: 52428800 }; + if (!rateLimitDisabled) { + if (options.rateLimitRpm !== undefined) { + const rpm = parseInt(options.rateLimitRpm, 10); + if (isNaN(rpm) || rpm <= 0) return { error: '--rate-limit-rpm must be a positive integer' }; + config.rpm = rpm; + } + if (options.rateLimitRph !== undefined) { + const rph = parseInt(options.rateLimitRph, 10); + if (isNaN(rph) || rph <= 0) return { error: '--rate-limit-rph must be a positive integer' }; + config.rph = rph; + } + if (options.rateLimitBytesPm !== undefined) { + const bytesPm = parseInt(options.rateLimitBytesPm, 10); + if (isNaN(bytesPm) || bytesPm <= 0) return { error: '--rate-limit-bytes-pm must be a positive integer' }; + config.bytesPm = bytesPm; + } + } + return { config }; +} + /** * Result of validating flag combinations */ @@ -1002,45 +1033,13 @@ program // Build rate limit config when API proxy is enabled if (config.enableApiProxy) { - // --no-rate-limit flag: commander stores as `options.rateLimit === false` - const rateLimitDisabled = options.rateLimit === false; - - const rateLimitConfig: RateLimitConfig = { - enabled: !rateLimitDisabled, - rpm: 60, - rph: 1000, - bytesPm: 52428800, - }; - - if (!rateLimitDisabled) { - if (options.rateLimitRpm !== undefined) { - const rpm = parseInt(options.rateLimitRpm, 10); - if (isNaN(rpm) || rpm <= 0) { - logger.error('❌ --rate-limit-rpm must be a positive integer'); - process.exit(1); - } - rateLimitConfig.rpm = rpm; - } - if (options.rateLimitRph !== undefined) { - const rph = parseInt(options.rateLimitRph, 10); - if (isNaN(rph) || rph <= 0) { - logger.error('❌ --rate-limit-rph must be a positive integer'); - process.exit(1); - } - rateLimitConfig.rph = rph; - } - if (options.rateLimitBytesPm !== undefined) { - const bytesPm = parseInt(options.rateLimitBytesPm, 10); - if (isNaN(bytesPm) || bytesPm <= 0) { - logger.error('❌ --rate-limit-bytes-pm must be a positive integer'); - process.exit(1); - } - rateLimitConfig.bytesPm = bytesPm; - } + const rateLimitResult = buildRateLimitConfig(options); + if ('error' in rateLimitResult) { + logger.error(`❌ ${rateLimitResult.error}`); + process.exit(1); } - - config.rateLimitConfig = rateLimitConfig; - logger.debug(`Rate limiting: enabled=${rateLimitConfig.enabled}, rpm=${rateLimitConfig.rpm}, rph=${rateLimitConfig.rph}, bytesPm=${rateLimitConfig.bytesPm}`); + config.rateLimitConfig = rateLimitResult.config; + logger.debug(`Rate limiting: enabled=${rateLimitResult.config.enabled}, rpm=${rateLimitResult.config.rpm}, rph=${rateLimitResult.config.rph}, bytesPm=${rateLimitResult.config.bytesPm}`); } // Warn if --env-all is used From a3a9a26436a5fe990b5a2f6b43b171ab82ab03ba Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 20:02:32 +0000 Subject: [PATCH 06/13] fix(docker): copy new modules into api-proxy container image The Dockerfile only copied server.js, but server.js now requires logging.js, metrics.js, and rate-limiter.js. Without these files the container exits immediately on startup, causing all agentic workflow CI jobs to fail with "container awf-api-proxy exited (0)". Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/api-proxy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 4aa3f8b0..8e74fe1d 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -15,7 +15,7 @@ COPY package*.json ./ RUN npm ci --omit=dev # Copy application files -COPY server.js ./ +COPY server.js logging.js metrics.js rate-limiter.js ./ # Create non-root user RUN addgroup -S apiproxy && adduser -S apiproxy -G apiproxy From dd3be45ff97e0f432733ad67b6797b1f2b2d52f3 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 20:08:51 +0000 Subject: [PATCH 07/13] fix: address review feedback on observability and rate limiting Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/api-proxy/rate-limiter.js | 24 +++++++-------- containers/api-proxy/rate-limiter.test.js | 30 +++++++++++++++++++ containers/api-proxy/server.js | 23 ++++++++++---- src/cli.ts | 12 ++++++++ .../integration/api-proxy-rate-limit.test.ts | 23 -------------- 5 files changed, 72 insertions(+), 40 deletions(-) diff --git a/containers/api-proxy/rate-limiter.js b/containers/api-proxy/rate-limiter.js index 01e11b8a..73143486 100644 --- a/containers/api-proxy/rate-limiter.js +++ b/containers/api-proxy/rate-limiter.js @@ -83,19 +83,15 @@ function recordInWindow(win, now, size, value) { } /** - * Get the sliding window estimate of the current rate. + * Get the current count in the sliding window. * - * Uses the formula: current_window_count + previous_window_weight * previous_total - * where previous_window_weight = (slot_duration - elapsed_in_current_slot) / slot_duration - * - * This is a simplified but effective approach: we use the total across - * all current-window slots plus a weighted fraction of the oldest expired slot's - * contribution to approximate the true sliding window. + * After advancing the window to zero out stale slots, returns the + * sum of all active slot counts. * * @param {object} win - Window object * @param {number} now - Current time in the slot's unit * @param {number} size - Window size - * @returns {number} Estimated count in the window + * @returns {number} Count of events in the current window */ function getWindowCount(win, now, size) { advanceWindow(win, now, size); @@ -180,8 +176,8 @@ class RateLimiter { // Check RPM (requests per minute) const rpmCount = getWindowCount(state.rpmWindow, nowSec, MINUTE_SLOTS); if (rpmCount >= this.rpm) { - const resetAt = (nowSec + 1) + (MINUTE_SLOTS - 1); const retryAfter = Math.max(1, MINUTE_SLOTS - (nowSec % MINUTE_SLOTS)); + const resetAt = nowSec + retryAfter; return { allowed: false, limitType: 'rpm', @@ -314,9 +310,13 @@ class RateLimiter { * @returns {RateLimiter} */ function create() { - const rpm = parseInt(process.env.AWF_RATE_LIMIT_RPM, 10) || DEFAULT_RPM; - const rph = parseInt(process.env.AWF_RATE_LIMIT_RPH, 10) || DEFAULT_RPH; - const bytesPm = parseInt(process.env.AWF_RATE_LIMIT_BYTES_PM, 10) || DEFAULT_BYTES_PM; + const rawRpm = parseInt(process.env.AWF_RATE_LIMIT_RPM, 10); + const rawRph = parseInt(process.env.AWF_RATE_LIMIT_RPH, 10); + const rawBytesPm = parseInt(process.env.AWF_RATE_LIMIT_BYTES_PM, 10); + + const rpm = (Number.isFinite(rawRpm) && rawRpm > 0) ? rawRpm : DEFAULT_RPM; + const rph = (Number.isFinite(rawRph) && rawRph > 0) ? rawRph : DEFAULT_RPH; + const bytesPm = (Number.isFinite(rawBytesPm) && rawBytesPm > 0) ? rawBytesPm : DEFAULT_BYTES_PM; const enabled = process.env.AWF_RATE_LIMIT_ENABLED !== 'false'; return new RateLimiter({ rpm, rph, bytesPm, enabled }); diff --git a/containers/api-proxy/rate-limiter.test.js b/containers/api-proxy/rate-limiter.test.js index d8fee66d..54c5bd62 100644 --- a/containers/api-proxy/rate-limiter.test.js +++ b/containers/api-proxy/rate-limiter.test.js @@ -59,6 +59,36 @@ describe('rate-limiter', () => { expect(limiter.enabled).toBe(false); }); + it('should use defaults for negative env var values', () => { + process.env.AWF_RATE_LIMIT_RPM = '-5'; + process.env.AWF_RATE_LIMIT_RPH = '-100'; + process.env.AWF_RATE_LIMIT_BYTES_PM = '-1024'; + const limiter = create(); + expect(limiter.rpm).toBe(60); + expect(limiter.rph).toBe(1000); + expect(limiter.bytesPm).toBe(50 * 1024 * 1024); + }); + + it('should use defaults for zero env var values', () => { + process.env.AWF_RATE_LIMIT_RPM = '0'; + process.env.AWF_RATE_LIMIT_RPH = '0'; + process.env.AWF_RATE_LIMIT_BYTES_PM = '0'; + const limiter = create(); + expect(limiter.rpm).toBe(60); + expect(limiter.rph).toBe(1000); + expect(limiter.bytesPm).toBe(50 * 1024 * 1024); + }); + + it('should use defaults for non-numeric env var values', () => { + process.env.AWF_RATE_LIMIT_RPM = 'abc'; + process.env.AWF_RATE_LIMIT_RPH = 'xyz'; + process.env.AWF_RATE_LIMIT_BYTES_PM = ''; + const limiter = create(); + expect(limiter.rpm).toBe(60); + expect(limiter.rph).toBe(1000); + expect(limiter.bytesPm).toBe(50 * 1024 * 1024); + }); + it('should use defaults when env vars are not set', () => { delete process.env.AWF_RATE_LIMIT_RPM; delete process.env.AWF_RATE_LIMIT_RPH; diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 5c5c3fed..e2a06587 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -72,7 +72,11 @@ if (!proxyAgent) { function checkRateLimit(req, res, provider, requestBytes) { const check = limiter.check(provider, requestBytes); if (!check.allowed) { - const requestId = req.headers['x-request-id'] || generateRequestId(); + const clientRequestId = req.headers['x-request-id']; + const requestId = (typeof clientRequestId === 'string' && + clientRequestId.length <= 128 && + /^[\w\-\.]+$/.test(clientRequestId)) + ? clientRequestId : generateRequestId(); const limitLabels = { rpm: 'requests per minute', rph: 'requests per hour', bytes_pm: 'bytes per minute' }; const windowLabel = limitLabels[check.limitType] || check.limitType; @@ -111,8 +115,14 @@ function checkRateLimit(req, res, provider, requestBytes) { /** * Forward a request to the target API, injecting auth headers and routing through Squid. */ +/** Validate that a request ID is safe (alphanumeric, dashes, dots, max 128 chars). */ +function isValidRequestId(id) { + return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id); +} + function proxyRequest(req, res, targetHost, injectHeaders, provider) { - const requestId = req.headers['x-request-id'] || generateRequestId(); + const clientRequestId = req.headers['x-request-id']; + const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId(); const startTime = Date.now(); // Propagate request ID back to the client and forward to upstream @@ -356,7 +366,8 @@ const HEALTH_PORT = 10000; if (OPENAI_API_KEY) { const server = http.createServer((req, res) => { if (handleManagementEndpoint(req, res)) return; - if (checkRateLimit(req, res, 'openai', 0)) return; + const contentLength = parseInt(req.headers['content-length'], 10) || 0; + if (checkRateLimit(req, res, 'openai', contentLength)) return; proxyRequest(req, res, 'api.openai.com', { 'Authorization': `Bearer ${OPENAI_API_KEY}`, @@ -389,7 +400,8 @@ if (ANTHROPIC_API_KEY) { return; } - if (checkRateLimit(req, res, 'anthropic', 0)) return; + const contentLength = parseInt(req.headers['content-length'], 10) || 0; + if (checkRateLimit(req, res, 'anthropic', contentLength)) return; // Only set anthropic-version as default; preserve agent-provided version const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; @@ -415,7 +427,8 @@ if (COPILOT_GITHUB_TOKEN) { return; } - if (checkRateLimit(req, res, 'copilot', 0)) return; + const contentLength = parseInt(req.headers['content-length'], 10) || 0; + if (checkRateLimit(req, res, 'copilot', contentLength)) return; proxyRequest(req, res, 'api.githubcopilot.com', { 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, diff --git a/src/cli.ts b/src/cli.ts index 69a93ac8..678e5af3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1042,6 +1042,18 @@ program logger.debug(`Rate limiting: enabled=${rateLimitResult.config.enabled}, rpm=${rateLimitResult.config.rpm}, rph=${rateLimitResult.config.rph}, bytesPm=${rateLimitResult.config.bytesPm}`); } + // Error if rate limit flags are used without --enable-api-proxy + if (!config.enableApiProxy) { + const hasRateLimitFlags = options.rateLimitRpm !== undefined || + options.rateLimitRph !== undefined || + options.rateLimitBytesPm !== undefined || + options.rateLimit === false; + if (hasRateLimitFlags) { + logger.error('Rate limit flags require --enable-api-proxy'); + process.exit(1); + } + } + // Warn if --env-all is used if (config.envAll) { logger.warn('⚠️ Using --env-all: All host environment variables will be passed to container'); diff --git a/tests/integration/api-proxy-rate-limit.test.ts b/tests/integration/api-proxy-rate-limit.test.ts index 11982f20..765f84b3 100644 --- a/tests/integration/api-proxy-rate-limit.test.ts +++ b/tests/integration/api-proxy-rate-limit.test.ts @@ -86,29 +86,6 @@ describe('API Proxy Rate Limiting', () => { expect(result.stdout.toLowerCase()).toContain('retry-after'); }, 180000); - test('should include X-RateLimit headers in responses', async () => { - // Make a single request and check for rate limit headers - const result = await runner.runWithSudo( - `bash -c "curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H 'Content-Type: application/json' -d '{\"model\":\"test\"}'"`, - { - allowDomains: ['api.anthropic.com'], - enableApiProxy: true, - buildLocal: true, - logLevel: 'debug', - timeout: 120000, - env: { - ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', - }, - } - ); - - expect(result).toSucceed(); - // Even non-429 responses from rate-limited requests should have rate limit headers. - // When rate limit IS triggered (429), headers are always present. - // For a single request at default limits, we might get the upstream response - // which won't have these headers. So use a low RPM and make 2 requests. - }, 180000); - test('should include X-RateLimit headers in 429 response', async () => { // Set low RPM to guarantee 429, then check for X-RateLimit-* headers const script = [ From def0d8edb07f0925c0ceee45123d21e5d75b394e Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 20:13:55 +0000 Subject: [PATCH 08/13] test: extract validateRateLimitFlags for testable coverage Extract rate-limit flag validation into a standalone function with 7 unit tests covering all flag combinations. This addresses the coverage regression from adding validation code inside the action handler that couldn't be reached by unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.test.ts | 29 ++++++++++++++++++++++++++++- src/cli.ts | 34 +++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index edebda4f..5b0c3f65 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateFormat, validateApiProxyConfig, buildRateLimitConfig } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -1459,4 +1459,31 @@ describe('cli', () => { if ('config' in r) { expect(r.config).toEqual({ enabled: true, rpm: 10, rph: 100, bytesPm: 5000000 }); } }); }); + + describe('validateRateLimitFlags', () => { + it('should pass when api proxy is enabled', () => { + expect(validateRateLimitFlags(true, { rateLimitRpm: '30' })).toEqual({ valid: true }); + }); + it('should pass when no rate limit flags used', () => { + expect(validateRateLimitFlags(false, {})).toEqual({ valid: true }); + }); + it('should fail when --rate-limit-rpm used without api proxy', () => { + const r = validateRateLimitFlags(false, { rateLimitRpm: '30' }); + expect(r.valid).toBe(false); + expect(r.error).toContain('--enable-api-proxy'); + }); + it('should fail when --rate-limit-rph used without api proxy', () => { + expect(validateRateLimitFlags(false, { rateLimitRph: '100' }).valid).toBe(false); + }); + it('should fail when --rate-limit-bytes-pm used without api proxy', () => { + expect(validateRateLimitFlags(false, { rateLimitBytesPm: '1000' }).valid).toBe(false); + }); + it('should fail when --no-rate-limit used without api proxy', () => { + expect(validateRateLimitFlags(false, { rateLimit: false }).valid).toBe(false); + }); + it('should pass when all flags used with api proxy enabled', () => { + const r = validateRateLimitFlags(true, { rateLimitRpm: '10', rateLimitRph: '100', rateLimit: false }); + expect(r.valid).toBe(true); + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 678e5af3..96d83b71 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -326,6 +326,27 @@ export function buildRateLimitConfig(options: { return { config }; } +/** + * Validates that rate-limit flags are not used without --enable-api-proxy. + */ +export function validateRateLimitFlags(enableApiProxy: boolean, options: { + rateLimit?: boolean; + rateLimitRpm?: string; + rateLimitRph?: string; + rateLimitBytesPm?: string; +}): FlagValidationResult { + if (!enableApiProxy) { + const hasRateLimitFlags = options.rateLimitRpm !== undefined || + options.rateLimitRph !== undefined || + options.rateLimitBytesPm !== undefined || + options.rateLimit === false; + if (hasRateLimitFlags) { + return { valid: false, error: 'Rate limit flags require --enable-api-proxy' }; + } + } + return { valid: true }; +} + /** * Result of validating flag combinations */ @@ -1043,15 +1064,10 @@ program } // Error if rate limit flags are used without --enable-api-proxy - if (!config.enableApiProxy) { - const hasRateLimitFlags = options.rateLimitRpm !== undefined || - options.rateLimitRph !== undefined || - options.rateLimitBytesPm !== undefined || - options.rateLimit === false; - if (hasRateLimitFlags) { - logger.error('Rate limit flags require --enable-api-proxy'); - process.exit(1); - } + const rateLimitFlagValidation = validateRateLimitFlags(config.enableApiProxy ?? false, options); + if (!rateLimitFlagValidation.valid) { + logger.error(rateLimitFlagValidation.error!); + process.exit(1); } // Warn if --env-all is used From cd2f8895133ada40e7791f77c7a4df2a843f1c98 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 21:28:34 +0000 Subject: [PATCH 09/13] docs: add local testing guide for api-proxy observability and rate limiting Covers 12 scenarios including basic observability, rate limiting, per-provider independence, and corner cases (lying Content-Length, X-Request-ID injection, chunked transfers, window rollover, concurrent load). Documents known gaps in the bytes-per-minute enforcement. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/api-proxy-local-testing.md | 349 ++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 docs/api-proxy-local-testing.md diff --git a/docs/api-proxy-local-testing.md b/docs/api-proxy-local-testing.md new file mode 100644 index 00000000..061ff5d3 --- /dev/null +++ b/docs/api-proxy-local-testing.md @@ -0,0 +1,349 @@ +# API Proxy Local Testing Guide + +How to test observability (structured logging, metrics, request tracing) and rate limiting locally. + +## Prerequisites + +```bash +# Build from the PR branch +git checkout feat/api-proxy-observability-ratelimit +npm run build +``` + +You need `sudo` access (for iptables) and Docker running. + +--- + +## 1. Observability basics + +See structured JSON logs, enhanced `/health`, and `/metrics` endpoint. + +```bash +sudo -E awf --enable-api-proxy --build-local --keep-containers \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + curl -s -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -d \"{\\\"model\\\":\\\"claude-3-haiku-20240307\\\",\\\"max_tokens\\\":10,\\\"messages\\\":[{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hi\\\"}]}\" + echo + echo === HEALTH === + curl -s http://172.30.0.30:10000/health | python3 -m json.tool + echo === METRICS === + curl -s http://172.30.0.30:10000/metrics | python3 -m json.tool + "' +``` + +**What to look for:** +- Structured JSON log lines in stderr (from the api-proxy container) +- `/health` includes `metrics_summary` with `total_requests: 1` and `rate_limits` +- `/metrics` shows counters, histogram buckets, and latency percentiles + +## 2. Request tracing with X-Request-ID + +```bash +sudo -E awf --enable-api-proxy --build-local \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -H \"X-Request-ID: my-trace-12345\" \ + -d \"{\\\"model\\\":\\\"test\\\"}\" 2>&1 | grep -i x-request-id + "' +``` + +**Expected:** `X-Request-ID: my-trace-12345` reflected back in the response headers. + +## 3. Rate limiting: trigger a 429 + +```bash +sudo -E awf --enable-api-proxy --build-local \ + --rate-limit-rpm 3 \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + for i in 1 2 3 4 5; do + echo \"--- Request \$i ---\" + curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -d \"{\\\"model\\\":\\\"test\\\"}\" + echo + done + "' +``` + +**Expected:** Requests 1-3 get through (auth error from Anthropic proves routing works). Requests 4-5 get `HTTP 429` with: + +```json +{ + "error": { + "type": "rate_limit_error", + "message": "Rate limit exceeded for anthropic provider. Limit: 3 requests per minute. Retry after N seconds.", + "provider": "anthropic", + "limit": 3, + "window": "per_minute", + "retry_after": N + } +} +``` + +## 4. Rate limiting disabled + +```bash +sudo -E awf --enable-api-proxy --build-local \ + --no-rate-limit \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + for i in $(seq 1 20); do + CODE=$(curl -s -o /dev/null -w \"%{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -d \"{\\\"model\\\":\\\"test\\\"}\") + echo \"Request \$i: HTTP \$CODE\" + done + "' +``` + +**Expected:** No 429s even with 20 rapid requests. + +## 5. Bytes-per-minute limit + +```bash +sudo -E awf --enable-api-proxy --build-local \ + --rate-limit-rpm 1000 --rate-limit-bytes-pm 500 \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + BODY_200=$(python3 -c \"print(chr(123) + chr(34) + chr(120) + chr(34) + chr(58) + chr(34) + chr(65)*180 + chr(34) + chr(125))\") + + echo \"Request 1 (200 bytes, should pass):\" + curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -H \"Content-Length: 200\" -d \"\$BODY_200\" + echo + + echo \"Request 2 (200 bytes, total 400 < 500, should pass):\" + curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -H \"Content-Length: 200\" -d \"\$BODY_200\" + echo + + echo \"Request 3 (200 bytes, total 600 > 500, should get 429):\" + curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -H \"Content-Length: 200\" -d \"\$BODY_200\" + "' +``` + +**Expected:** Requests 1-2 pass, request 3 gets 429 with `limit_type: bytes_pm`. + +## 6. Per-provider independence + +Exhausting one provider's limit doesn't affect another. + +```bash +export ANTHROPIC_API_KEY=sk-ant-fake +export OPENAI_API_KEY=sk-fake + +sudo -E awf --enable-api-proxy --build-local \ + --rate-limit-rpm 2 \ + --allow-domains api.anthropic.com,api.openai.com \ + -- 'bash -c " + # Exhaust Anthropic limit (2 requests) + curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" + curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" + + echo \"Anthropic 3rd request (should be 429):\" + curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" + echo + + echo \"OpenAI 1st request (should NOT be 429):\" + curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10000/v1/chat/completions \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"gpt-4\\\"}\" + echo + "' +``` + +**Expected:** Anthropic 3rd request gets 429. OpenAI 1st request goes through (independent counter). + +--- + +## Corner Cases + +### 7. Content-Length header lies (known gap) + +The bytes-per-minute rate limiter reads `Content-Length` to decide, not the actual body size. + +```bash +sudo -E awf --enable-api-proxy --build-local \ + --rate-limit-bytes-pm 100 \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + HUGE_BODY=$(python3 -c \"print(chr(123) + chr(34) + chr(120) + chr(34) + chr(58) + chr(34) + chr(65)*500 + chr(34) + chr(125))\") + echo \"Sending ~500 bytes with Content-Length: 10 (should bypass bytes limit):\" + curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -H \"Content-Length: 10\" \ + -d \"\$HUGE_BODY\" + "' +``` + +**What happens:** The rate limiter lets it through (thinks it's 10 bytes). The 10MB `MAX_BODY_SIZE` still protects against DoS, but the bytes-per-minute tracking is inaccurate. A malicious agent could set `Content-Length: 0` on every request to bypass the bytes limit entirely. + +### 8. X-Request-ID validation + +```bash +sudo -E awf --enable-api-proxy --build-local \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + echo \"=== 128 chars (max allowed, should echo back) ===\" + curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -H \"X-Request-ID: $(python3 -c \"print(chr(65)*128)\")\" \ + -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id + + echo \"=== 129 chars (over limit, should generate UUID) ===\" + curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -H \"X-Request-ID: $(python3 -c \"print(chr(65)*129)\")\" \ + -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id + + echo \"=== Script tags (invalid chars, should generate UUID) ===\" + curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -H \"X-Request-ID: \" \ + -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id + + echo \"=== Newline injection (should generate UUID) ===\" + curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -H \"X-Request-ID: legit-id\r\nX-Injected: true\" \ + -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id + "' +``` + +**Expected:** 128-char alphanumeric ID is accepted. Everything else gets a generated UUID. The validation regex is `/^[\w\-\.]+$/` (alphanumeric, dashes, dots, max 128 chars). + +### 9. Sliding window rollover + +Verify that rate limit counters reset after the window expires. + +```bash +sudo -E awf --enable-api-proxy --build-local --keep-containers \ + --rate-limit-rpm 3 \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + # Exhaust the limit + for i in 1 2 3; do + curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" + done + + echo \"After 3 requests (should be 429):\" + curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" + echo + + echo \"Rate limit status:\" + curl -s http://172.30.0.30:10000/health | python3 -c \" +import sys, json +d = json.load(sys.stdin) +print(json.dumps(d.get('rate_limits', {}), indent=2)) +\" + + echo \"Waiting 61 seconds for window reset...\" + sleep 61 + + echo \"After window reset (should pass):\" + curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" + echo + "' +``` + +**Expected:** 429 before the wait, pass after the 61-second wait. + +Note: this test takes ~70 seconds. Use `--keep-containers` so you can inspect state afterward. + +### 10. No Content-Length header (chunked transfer) + +```bash +sudo -E awf --enable-api-proxy --build-local \ + --rate-limit-bytes-pm 100 \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + echo \"Chunked request with no Content-Length (should bypass bytes limit):\" + curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" \ + -H \"Transfer-Encoding: chunked\" \ + -d \"{\\\"model\\\":\\\"test\\\"}\" + "' +``` + +**What happens:** `Content-Length` is absent, so `parseInt(undefined, 10)` returns `NaN`, which falls back to `0`. The bytes-per-minute limit is not enforced. Same gap as the lying Content-Length case. + +### 11. Rate limit flags without --enable-api-proxy + +```bash +sudo -E awf --rate-limit-rpm 10 \ + --allow-domains github.com \ + -- 'curl https://github.com' +``` + +**Expected:** Immediate error: `Rate limit flags require --enable-api-proxy` with exit code 1. + +### 12. Metrics under concurrent load + +```bash +sudo -E awf --enable-api-proxy --build-local --keep-containers \ + --rate-limit-rpm 1000 \ + --allow-domains api.anthropic.com \ + -- 'bash -c " + # Blast 50 concurrent requests + for i in $(seq 1 50); do + curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ + -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" & + done + wait + + echo === METRICS === + curl -s http://172.30.0.30:10000/metrics | python3 -c \" +import sys, json +m = json.load(sys.stdin) +print('Counters:') +for k, v in sorted(m.get('counters', {}).items()): + print(f' {k}: {v}') +print() +h = m.get('histograms', {}).get('request_duration_ms', {}) +for provider, data in h.items(): + print(f'Latency ({provider}): p50={data[\"p50\"]}ms p90={data[\"p90\"]}ms p99={data[\"p99\"]}ms count={data[\"count\"]}') +print() +print(f'Active requests: {m.get(\"gauges\", {}).get(\"active_requests\", {})}') +print(f'Uptime: {m.get(\"gauges\", {}).get(\"uptime_seconds\", 0)}s') +\" + "' +``` + +**What to verify:** +- `requests_total` counters sum to ~50 +- `active_requests` gauge is back to 0 (all completed) +- Histogram has count ~50 with reasonable percentiles +- No memory growth (fixed-bucket histograms) + +--- + +## Known Gaps + +| Gap | Impact | Mitigation | +|-----|--------|------------| +| Bytes limit uses `Content-Length` header, not actual body | Client can lie to bypass bytes-per-minute | 10MB `MAX_BODY_SIZE` still enforces absolute limit | +| Chunked requests have no Content-Length | Bytes-per-minute limit is not enforced | RPM limit still applies | +| `/health` and `/metrics` are not rate-limited | Could be hammered by agent | Lightweight endpoints, internal network only | +| Rate limit state resets on container restart | Agent could crash proxy to reset counters | `no-new-privileges`, dropped capabilities | + +## CLI Flag Reference + +| Flag | Default | Description | +|------|---------|-------------| +| `--rate-limit-rpm ` | 60 | Max requests per minute per provider | +| `--rate-limit-rph ` | 1000 | Max requests per hour per provider | +| `--rate-limit-bytes-pm ` | 52428800 (50MB) | Max request bytes per minute per provider | +| `--no-rate-limit` | (rate limiting on) | Disable all rate limiting | + +All rate limit flags require `--enable-api-proxy`. From 87f07fc9a7e696858e5ae986edb0bffc726b2567 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 21:40:50 +0000 Subject: [PATCH 10/13] Revert "docs: add local testing guide for api-proxy observability and rate limiting" This reverts commit cea4f3d2099d498a449d9a8efa47f2530ae8be06. --- docs/api-proxy-local-testing.md | 349 -------------------------------- 1 file changed, 349 deletions(-) delete mode 100644 docs/api-proxy-local-testing.md diff --git a/docs/api-proxy-local-testing.md b/docs/api-proxy-local-testing.md deleted file mode 100644 index 061ff5d3..00000000 --- a/docs/api-proxy-local-testing.md +++ /dev/null @@ -1,349 +0,0 @@ -# API Proxy Local Testing Guide - -How to test observability (structured logging, metrics, request tracing) and rate limiting locally. - -## Prerequisites - -```bash -# Build from the PR branch -git checkout feat/api-proxy-observability-ratelimit -npm run build -``` - -You need `sudo` access (for iptables) and Docker running. - ---- - -## 1. Observability basics - -See structured JSON logs, enhanced `/health`, and `/metrics` endpoint. - -```bash -sudo -E awf --enable-api-proxy --build-local --keep-containers \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - curl -s -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -d \"{\\\"model\\\":\\\"claude-3-haiku-20240307\\\",\\\"max_tokens\\\":10,\\\"messages\\\":[{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hi\\\"}]}\" - echo - echo === HEALTH === - curl -s http://172.30.0.30:10000/health | python3 -m json.tool - echo === METRICS === - curl -s http://172.30.0.30:10000/metrics | python3 -m json.tool - "' -``` - -**What to look for:** -- Structured JSON log lines in stderr (from the api-proxy container) -- `/health` includes `metrics_summary` with `total_requests: 1` and `rate_limits` -- `/metrics` shows counters, histogram buckets, and latency percentiles - -## 2. Request tracing with X-Request-ID - -```bash -sudo -E awf --enable-api-proxy --build-local \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -H \"X-Request-ID: my-trace-12345\" \ - -d \"{\\\"model\\\":\\\"test\\\"}\" 2>&1 | grep -i x-request-id - "' -``` - -**Expected:** `X-Request-ID: my-trace-12345` reflected back in the response headers. - -## 3. Rate limiting: trigger a 429 - -```bash -sudo -E awf --enable-api-proxy --build-local \ - --rate-limit-rpm 3 \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - for i in 1 2 3 4 5; do - echo \"--- Request \$i ---\" - curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -d \"{\\\"model\\\":\\\"test\\\"}\" - echo - done - "' -``` - -**Expected:** Requests 1-3 get through (auth error from Anthropic proves routing works). Requests 4-5 get `HTTP 429` with: - -```json -{ - "error": { - "type": "rate_limit_error", - "message": "Rate limit exceeded for anthropic provider. Limit: 3 requests per minute. Retry after N seconds.", - "provider": "anthropic", - "limit": 3, - "window": "per_minute", - "retry_after": N - } -} -``` - -## 4. Rate limiting disabled - -```bash -sudo -E awf --enable-api-proxy --build-local \ - --no-rate-limit \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - for i in $(seq 1 20); do - CODE=$(curl -s -o /dev/null -w \"%{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -d \"{\\\"model\\\":\\\"test\\\"}\") - echo \"Request \$i: HTTP \$CODE\" - done - "' -``` - -**Expected:** No 429s even with 20 rapid requests. - -## 5. Bytes-per-minute limit - -```bash -sudo -E awf --enable-api-proxy --build-local \ - --rate-limit-rpm 1000 --rate-limit-bytes-pm 500 \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - BODY_200=$(python3 -c \"print(chr(123) + chr(34) + chr(120) + chr(34) + chr(58) + chr(34) + chr(65)*180 + chr(34) + chr(125))\") - - echo \"Request 1 (200 bytes, should pass):\" - curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -H \"Content-Length: 200\" -d \"\$BODY_200\" - echo - - echo \"Request 2 (200 bytes, total 400 < 500, should pass):\" - curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -H \"Content-Length: 200\" -d \"\$BODY_200\" - echo - - echo \"Request 3 (200 bytes, total 600 > 500, should get 429):\" - curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -H \"Content-Length: 200\" -d \"\$BODY_200\" - "' -``` - -**Expected:** Requests 1-2 pass, request 3 gets 429 with `limit_type: bytes_pm`. - -## 6. Per-provider independence - -Exhausting one provider's limit doesn't affect another. - -```bash -export ANTHROPIC_API_KEY=sk-ant-fake -export OPENAI_API_KEY=sk-fake - -sudo -E awf --enable-api-proxy --build-local \ - --rate-limit-rpm 2 \ - --allow-domains api.anthropic.com,api.openai.com \ - -- 'bash -c " - # Exhaust Anthropic limit (2 requests) - curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" - curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" - - echo \"Anthropic 3rd request (should be 429):\" - curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" - echo - - echo \"OpenAI 1st request (should NOT be 429):\" - curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10000/v1/chat/completions \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"gpt-4\\\"}\" - echo - "' -``` - -**Expected:** Anthropic 3rd request gets 429. OpenAI 1st request goes through (independent counter). - ---- - -## Corner Cases - -### 7. Content-Length header lies (known gap) - -The bytes-per-minute rate limiter reads `Content-Length` to decide, not the actual body size. - -```bash -sudo -E awf --enable-api-proxy --build-local \ - --rate-limit-bytes-pm 100 \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - HUGE_BODY=$(python3 -c \"print(chr(123) + chr(34) + chr(120) + chr(34) + chr(58) + chr(34) + chr(65)*500 + chr(34) + chr(125))\") - echo \"Sending ~500 bytes with Content-Length: 10 (should bypass bytes limit):\" - curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -H \"Content-Length: 10\" \ - -d \"\$HUGE_BODY\" - "' -``` - -**What happens:** The rate limiter lets it through (thinks it's 10 bytes). The 10MB `MAX_BODY_SIZE` still protects against DoS, but the bytes-per-minute tracking is inaccurate. A malicious agent could set `Content-Length: 0` on every request to bypass the bytes limit entirely. - -### 8. X-Request-ID validation - -```bash -sudo -E awf --enable-api-proxy --build-local \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - echo \"=== 128 chars (max allowed, should echo back) ===\" - curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -H \"X-Request-ID: $(python3 -c \"print(chr(65)*128)\")\" \ - -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id - - echo \"=== 129 chars (over limit, should generate UUID) ===\" - curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -H \"X-Request-ID: $(python3 -c \"print(chr(65)*129)\")\" \ - -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id - - echo \"=== Script tags (invalid chars, should generate UUID) ===\" - curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -H \"X-Request-ID: \" \ - -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id - - echo \"=== Newline injection (should generate UUID) ===\" - curl -s -i -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -H \"X-Request-ID: legit-id\r\nX-Injected: true\" \ - -d \"{\\\"model\\\":\\\"test\\\"}\" | grep -i x-request-id - "' -``` - -**Expected:** 128-char alphanumeric ID is accepted. Everything else gets a generated UUID. The validation regex is `/^[\w\-\.]+$/` (alphanumeric, dashes, dots, max 128 chars). - -### 9. Sliding window rollover - -Verify that rate limit counters reset after the window expires. - -```bash -sudo -E awf --enable-api-proxy --build-local --keep-containers \ - --rate-limit-rpm 3 \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - # Exhaust the limit - for i in 1 2 3; do - curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" - done - - echo \"After 3 requests (should be 429):\" - curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" - echo - - echo \"Rate limit status:\" - curl -s http://172.30.0.30:10000/health | python3 -c \" -import sys, json -d = json.load(sys.stdin) -print(json.dumps(d.get('rate_limits', {}), indent=2)) -\" - - echo \"Waiting 61 seconds for window reset...\" - sleep 61 - - echo \"After window reset (should pass):\" - curl -s -w \" HTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" - echo - "' -``` - -**Expected:** 429 before the wait, pass after the 61-second wait. - -Note: this test takes ~70 seconds. Use `--keep-containers` so you can inspect state afterward. - -### 10. No Content-Length header (chunked transfer) - -```bash -sudo -E awf --enable-api-proxy --build-local \ - --rate-limit-bytes-pm 100 \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - echo \"Chunked request with no Content-Length (should bypass bytes limit):\" - curl -s -w \"\\nHTTP %{http_code}\" -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" \ - -H \"Transfer-Encoding: chunked\" \ - -d \"{\\\"model\\\":\\\"test\\\"}\" - "' -``` - -**What happens:** `Content-Length` is absent, so `parseInt(undefined, 10)` returns `NaN`, which falls back to `0`. The bytes-per-minute limit is not enforced. Same gap as the lying Content-Length case. - -### 11. Rate limit flags without --enable-api-proxy - -```bash -sudo -E awf --rate-limit-rpm 10 \ - --allow-domains github.com \ - -- 'curl https://github.com' -``` - -**Expected:** Immediate error: `Rate limit flags require --enable-api-proxy` with exit code 1. - -### 12. Metrics under concurrent load - -```bash -sudo -E awf --enable-api-proxy --build-local --keep-containers \ - --rate-limit-rpm 1000 \ - --allow-domains api.anthropic.com \ - -- 'bash -c " - # Blast 50 concurrent requests - for i in $(seq 1 50); do - curl -s -o /dev/null -X POST http://172.30.0.30:10001/v1/messages \ - -H \"Content-Type: application/json\" -d \"{\\\"model\\\":\\\"test\\\"}\" & - done - wait - - echo === METRICS === - curl -s http://172.30.0.30:10000/metrics | python3 -c \" -import sys, json -m = json.load(sys.stdin) -print('Counters:') -for k, v in sorted(m.get('counters', {}).items()): - print(f' {k}: {v}') -print() -h = m.get('histograms', {}).get('request_duration_ms', {}) -for provider, data in h.items(): - print(f'Latency ({provider}): p50={data[\"p50\"]}ms p90={data[\"p90\"]}ms p99={data[\"p99\"]}ms count={data[\"count\"]}') -print() -print(f'Active requests: {m.get(\"gauges\", {}).get(\"active_requests\", {})}') -print(f'Uptime: {m.get(\"gauges\", {}).get(\"uptime_seconds\", 0)}s') -\" - "' -``` - -**What to verify:** -- `requests_total` counters sum to ~50 -- `active_requests` gauge is back to 0 (all completed) -- Histogram has count ~50 with reasonable percentiles -- No memory growth (fixed-bucket histograms) - ---- - -## Known Gaps - -| Gap | Impact | Mitigation | -|-----|--------|------------| -| Bytes limit uses `Content-Length` header, not actual body | Client can lie to bypass bytes-per-minute | 10MB `MAX_BODY_SIZE` still enforces absolute limit | -| Chunked requests have no Content-Length | Bytes-per-minute limit is not enforced | RPM limit still applies | -| `/health` and `/metrics` are not rate-limited | Could be hammered by agent | Lightweight endpoints, internal network only | -| Rate limit state resets on container restart | Agent could crash proxy to reset counters | `no-new-privileges`, dropped capabilities | - -## CLI Flag Reference - -| Flag | Default | Description | -|------|---------|-------------| -| `--rate-limit-rpm ` | 60 | Max requests per minute per provider | -| `--rate-limit-rph ` | 1000 | Max requests per hour per provider | -| `--rate-limit-bytes-pm ` | 52428800 (50MB) | Max request bytes per minute per provider | -| `--no-rate-limit` | (rate limiting on) | Disable all rate limiting | - -All rate limit flags require `--enable-api-proxy`. From c09e9108bd87dc4755758285ff8b9189c8aa7107 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 22:37:14 +0000 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20address=20critique=20=E2=80=94=20g?= =?UTF-8?q?auge=20bug,=20retryAfter=20accuracy,=20RPM=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from devil's advocate review: 1. **Gauge double-decrement bug**: Added `errored` guard flag in proxyRequest() to prevent req.on('error') followed by req.on('end') from double-decrementing the active_requests gauge. 2. **retryAfter misaligned with sliding window**: Replaced calendar- aligned calculation (MINUTE_SLOTS - nowSec % MINUTE_SLOTS) with estimateRetryAfter() that scans the window to find when enough capacity will be freed. Also fixed getStatus() reset values. 3. **RPM default raised from 60 to 180**: 60 RPM (1 req/sec) is too restrictive for agents doing rapid tool calls. 180 RPM (3 req/sec) is comfortable for normal agent workflows while still catching runaway loops. Bonus: Fixed total drift in advanceWindow() — on full-window clear (elapsed >= size), set total=0 directly instead of subtracting. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/api-proxy/rate-limiter.js | 64 +++++++++++++++++++---- containers/api-proxy/rate-limiter.test.js | 10 ++-- containers/api-proxy/server.js | 7 ++- src/cli.test.ts | 4 +- src/cli.ts | 4 +- src/types.ts | 2 +- 6 files changed, 68 insertions(+), 23 deletions(-) diff --git a/containers/api-proxy/rate-limiter.js b/containers/api-proxy/rate-limiter.js index 73143486..420c44b8 100644 --- a/containers/api-proxy/rate-limiter.js +++ b/containers/api-proxy/rate-limiter.js @@ -17,7 +17,7 @@ 'use strict'; // ── Defaults ──────────────────────────────────────────────────────────── -const DEFAULT_RPM = 60; +const DEFAULT_RPM = 180; const DEFAULT_RPH = 1000; const DEFAULT_BYTES_PM = 50 * 1024 * 1024; // 50 MB @@ -58,10 +58,16 @@ function advanceWindow(win, now, size) { // Zero out slots that have expired const slotsToZero = Math.min(elapsed, size); - for (let i = 1; i <= slotsToZero; i++) { - const slot = (win.lastSlot + i) % size; - win.total -= win.counts[slot]; - win.counts[slot] = 0; + if (slotsToZero >= size) { + // Full window expired — reset directly to avoid total drift + win.counts.fill(0); + win.total = 0; + } else { + for (let i = 1; i <= slotsToZero; i++) { + const slot = (win.lastSlot + i) % size; + win.total -= win.counts[slot]; + win.counts[slot] = 0; + } } win.lastSlot = now % size; @@ -98,6 +104,34 @@ function getWindowCount(win, now, size) { return win.total; } +/** + * Estimate how many time-units until the window count drops below a threshold. + * + * Scans backwards from the oldest slot in the window to find the first + * non-zero slot. That slot will expire in (its age remaining) time-units. + * + * @param {object} win - Window object (must be advanced to `now` first) + * @param {number} now - Current time in the slot's unit + * @param {number} size - Window size + * @param {number} limit - The threshold to drop below + * @returns {number} Estimated time-units until count < limit (minimum 1) + */ +function estimateRetryAfter(win, now, size, limit) { + // Walk from the oldest slot (now - size + 1) forward, accumulating + // how much capacity is freed as each slot expires. + let freed = 0; + for (let age = size - 1; age >= 0; age--) { + const slot = ((now - age) % size + size) % size; + freed += win.counts[slot]; + if (win.total - freed < limit) { + // This slot expires in (age + 1) time-units from now + return Math.max(1, age + 1); + } + } + // Shouldn't happen if total >= limit, but fall back to full window + return Math.max(1, size); +} + /** * Per-provider rate limit state. */ @@ -115,7 +149,7 @@ class ProviderState { class RateLimiter { /** * @param {object} config - * @param {number} [config.rpm=60] - Max requests per minute + * @param {number} [config.rpm=180] - Max requests per minute * @param {number} [config.rph=1000] - Max requests per hour * @param {number} [config.bytesPm=52428800] - Max bytes per minute (50 MB) * @param {boolean} [config.enabled=true] - Whether rate limiting is active @@ -176,7 +210,7 @@ class RateLimiter { // Check RPM (requests per minute) const rpmCount = getWindowCount(state.rpmWindow, nowSec, MINUTE_SLOTS); if (rpmCount >= this.rpm) { - const retryAfter = Math.max(1, MINUTE_SLOTS - (nowSec % MINUTE_SLOTS)); + const retryAfter = estimateRetryAfter(state.rpmWindow, nowSec, MINUTE_SLOTS, this.rpm); const resetAt = nowSec + retryAfter; return { allowed: false, @@ -191,7 +225,8 @@ class RateLimiter { // Check RPH (requests per hour) const rphCount = getWindowCount(state.rphWindow, nowMin, HOUR_SLOTS); if (rphCount >= this.rph) { - const retryAfter = Math.max(1, (HOUR_SLOTS - (nowMin % HOUR_SLOTS)) * 60); + const retryAfterMin = estimateRetryAfter(state.rphWindow, nowMin, HOUR_SLOTS, this.rph); + const retryAfter = retryAfterMin * 60; // convert minutes to seconds const resetAt = Math.floor(nowMs / 1000) + retryAfter; return { allowed: false, @@ -206,7 +241,7 @@ class RateLimiter { // Check bytes per minute const bytesCount = getWindowCount(state.bytesWindow, nowSec, MINUTE_SLOTS); if (bytesCount + requestBytes > this.bytesPm) { - const retryAfter = Math.max(1, MINUTE_SLOTS - (nowSec % MINUTE_SLOTS)); + const retryAfter = estimateRetryAfter(state.bytesWindow, nowSec, MINUTE_SLOTS, this.bytesPm); const resetAt = nowSec + retryAfter; return { allowed: false, @@ -267,17 +302,24 @@ class RateLimiter { const rpmCount = getWindowCount(state.rpmWindow, nowSec, MINUTE_SLOTS); const rphCount = getWindowCount(state.rphWindow, nowMin, HOUR_SLOTS); + const rpmRetry = rpmCount >= this.rpm + ? estimateRetryAfter(state.rpmWindow, nowSec, MINUTE_SLOTS, this.rpm) + : 0; + const rphRetry = rphCount >= this.rph + ? estimateRetryAfter(state.rphWindow, nowMin, HOUR_SLOTS, this.rph) * 60 + : 0; + return { enabled: true, rpm: { limit: this.rpm, remaining: Math.max(0, this.rpm - rpmCount), - reset: nowSec + (MINUTE_SLOTS - (nowSec % MINUTE_SLOTS)), + reset: rpmRetry > 0 ? nowSec + rpmRetry : 0, }, rph: { limit: this.rph, remaining: Math.max(0, this.rph - rphCount), - reset: Math.floor(nowMs / 1000) + (HOUR_SLOTS - (nowMin % HOUR_SLOTS)) * 60, + reset: rphRetry > 0 ? Math.floor(nowMs / 1000) + rphRetry : 0, }, }; } catch (_err) { diff --git a/containers/api-proxy/rate-limiter.test.js b/containers/api-proxy/rate-limiter.test.js index 54c5bd62..04a74071 100644 --- a/containers/api-proxy/rate-limiter.test.js +++ b/containers/api-proxy/rate-limiter.test.js @@ -6,7 +6,7 @@ describe('rate-limiter', () => { describe('constructor', () => { it('should use defaults when no config provided', () => { const limiter = new RateLimiter(); - expect(limiter.rpm).toBe(60); + expect(limiter.rpm).toBe(180); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); expect(limiter.enabled).toBe(true); @@ -64,7 +64,7 @@ describe('rate-limiter', () => { process.env.AWF_RATE_LIMIT_RPH = '-100'; process.env.AWF_RATE_LIMIT_BYTES_PM = '-1024'; const limiter = create(); - expect(limiter.rpm).toBe(60); + expect(limiter.rpm).toBe(180); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); }); @@ -74,7 +74,7 @@ describe('rate-limiter', () => { process.env.AWF_RATE_LIMIT_RPH = '0'; process.env.AWF_RATE_LIMIT_BYTES_PM = '0'; const limiter = create(); - expect(limiter.rpm).toBe(60); + expect(limiter.rpm).toBe(180); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); }); @@ -84,7 +84,7 @@ describe('rate-limiter', () => { process.env.AWF_RATE_LIMIT_RPH = 'xyz'; process.env.AWF_RATE_LIMIT_BYTES_PM = ''; const limiter = create(); - expect(limiter.rpm).toBe(60); + expect(limiter.rpm).toBe(180); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); }); @@ -95,7 +95,7 @@ describe('rate-limiter', () => { delete process.env.AWF_RATE_LIMIT_BYTES_PM; delete process.env.AWF_RATE_LIMIT_ENABLED; const limiter = create(); - expect(limiter.rpm).toBe(60); + expect(limiter.rpm).toBe(180); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); expect(limiter.enabled).toBe(true); diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index e2a06587..f6f62533 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -163,6 +163,8 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider) { // Handle client-side errors (e.g. aborted connections) req.on('error', (err) => { + if (errored) return; // Prevent double handling + errored = true; const duration = Date.now() - startTime; metrics.gaugeDec('active_requests', { provider }); metrics.increment('requests_errors_total', { provider }); @@ -185,9 +187,10 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider) { const chunks = []; let totalBytes = 0; let rejected = false; + let errored = false; req.on('data', chunk => { - if (rejected) return; + if (rejected || errored) return; totalBytes += chunk.length; if (totalBytes > MAX_BODY_SIZE) { rejected = true; @@ -214,7 +217,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider) { }); req.on('end', () => { - if (rejected) return; + if (rejected || errored) return; const body = Buffer.concat(chunks); const requestBytes = body.length; diff --git a/src/cli.test.ts b/src/cli.test.ts index 5b0c3f65..9fcfaec5 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1417,7 +1417,7 @@ describe('cli', () => { it('should return defaults when no options provided', () => { const r = buildRateLimitConfig({}); expect('config' in r).toBe(true); - if ('config' in r) { expect(r.config).toEqual({ enabled: true, rpm: 60, rph: 1000, bytesPm: 52428800 }); } + if ('config' in r) { expect(r.config).toEqual({ enabled: true, rpm: 180, rph: 1000, bytesPm: 52428800 }); } }); it('should disable with rateLimit=false', () => { const r = buildRateLimitConfig({ rateLimit: false }); @@ -1452,7 +1452,7 @@ describe('cli', () => { }); it('should ignore custom values when disabled', () => { const r = buildRateLimitConfig({ rateLimit: false, rateLimitRpm: '999' }); - if ('config' in r) { expect(r.config.rpm).toBe(60); } + if ('config' in r) { expect(r.config.rpm).toBe(180); } }); it('should accept all custom values', () => { const r = buildRateLimitConfig({ rateLimitRpm: '10', rateLimitRph: '100', rateLimitBytesPm: '5000000' }); diff --git a/src/cli.ts b/src/cli.ts index 96d83b71..f0fa05ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -305,7 +305,7 @@ export function buildRateLimitConfig(options: { rateLimitBytesPm?: string; }): { config: RateLimitConfig } | { error: string } { const rateLimitDisabled = options.rateLimit === false; - const config: RateLimitConfig = { enabled: !rateLimitDisabled, rpm: 60, rph: 1000, bytesPm: 52428800 }; + const config: RateLimitConfig = { enabled: !rateLimitDisabled, rpm: 180, rph: 1000, bytesPm: 52428800 }; if (!rateLimitDisabled) { if (options.rateLimitRpm !== undefined) { const rpm = parseInt(options.rateLimitRpm, 10); @@ -772,7 +772,7 @@ program ) .option( '--rate-limit-rpm ', - 'Requests per minute per provider (default: 60, requires --enable-api-proxy)', + 'Requests per minute per provider (default: 180, requires --enable-api-proxy)', ) .option( '--rate-limit-rph ', diff --git a/src/types.ts b/src/types.ts index dec9571a..64e76118 100644 --- a/src/types.ts +++ b/src/types.ts @@ -495,7 +495,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export interface RateLimitConfig { /** Whether rate limiting is enabled (default: true) */ enabled: boolean; - /** Max requests per minute per provider (default: 60) */ + /** Max requests per minute per provider (default: 180) */ rpm: number; /** Max requests per hour per provider (default: 1000) */ rph: number; From d3c8563da95ecf731e465086c20270a00277eb30 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 22:41:56 +0000 Subject: [PATCH 12/13] feat: make rate limiting opt-in (unlimited by default) Rate limiting is now disabled by default. Users opt in by providing any --rate-limit-* flag: awf --enable-api-proxy --rate-limit-rpm 600 ... When no rate-limit flags are provided, there are no request limits. When any flag is provided, defaults for unset limits are generous (600 RPM, 10000 RPH, 50MB/min bytes). --no-rate-limit remains available to explicitly disable when other flags might be set. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/api-proxy/rate-limiter.js | 8 ++-- containers/api-proxy/rate-limiter.test.js | 15 +++--- src/cli.test.ts | 22 ++++----- src/cli.ts | 58 ++++++++++++++--------- src/types.ts | 2 +- 5 files changed, 60 insertions(+), 45 deletions(-) diff --git a/containers/api-proxy/rate-limiter.js b/containers/api-proxy/rate-limiter.js index 420c44b8..45693ada 100644 --- a/containers/api-proxy/rate-limiter.js +++ b/containers/api-proxy/rate-limiter.js @@ -17,7 +17,7 @@ 'use strict'; // ── Defaults ──────────────────────────────────────────────────────────── -const DEFAULT_RPM = 180; +const DEFAULT_RPM = 600; const DEFAULT_RPH = 1000; const DEFAULT_BYTES_PM = 50 * 1024 * 1024; // 50 MB @@ -149,7 +149,7 @@ class ProviderState { class RateLimiter { /** * @param {object} config - * @param {number} [config.rpm=180] - Max requests per minute + * @param {number} [config.rpm=600] - Max requests per minute * @param {number} [config.rph=1000] - Max requests per hour * @param {number} [config.bytesPm=52428800] - Max bytes per minute (50 MB) * @param {boolean} [config.enabled=true] - Whether rate limiting is active @@ -347,7 +347,7 @@ class RateLimiter { * - AWF_RATE_LIMIT_RPM (default: 60) * - AWF_RATE_LIMIT_RPH (default: 1000) * - AWF_RATE_LIMIT_BYTES_PM (default: 52428800) - * - AWF_RATE_LIMIT_ENABLED (default: "true") + * - AWF_RATE_LIMIT_ENABLED (default: "false" — rate limiting is opt-in) * * @returns {RateLimiter} */ @@ -359,7 +359,7 @@ function create() { const rpm = (Number.isFinite(rawRpm) && rawRpm > 0) ? rawRpm : DEFAULT_RPM; const rph = (Number.isFinite(rawRph) && rawRph > 0) ? rawRph : DEFAULT_RPH; const bytesPm = (Number.isFinite(rawBytesPm) && rawBytesPm > 0) ? rawBytesPm : DEFAULT_BYTES_PM; - const enabled = process.env.AWF_RATE_LIMIT_ENABLED !== 'false'; + const enabled = process.env.AWF_RATE_LIMIT_ENABLED === 'true'; return new RateLimiter({ rpm, rph, bytesPm, enabled }); } diff --git a/containers/api-proxy/rate-limiter.test.js b/containers/api-proxy/rate-limiter.test.js index 04a74071..e6ebeb75 100644 --- a/containers/api-proxy/rate-limiter.test.js +++ b/containers/api-proxy/rate-limiter.test.js @@ -6,7 +6,7 @@ describe('rate-limiter', () => { describe('constructor', () => { it('should use defaults when no config provided', () => { const limiter = new RateLimiter(); - expect(limiter.rpm).toBe(180); + expect(limiter.rpm).toBe(600); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); expect(limiter.enabled).toBe(true); @@ -46,6 +46,7 @@ describe('rate-limiter', () => { process.env.AWF_RATE_LIMIT_RPM = '30'; process.env.AWF_RATE_LIMIT_RPH = '500'; process.env.AWF_RATE_LIMIT_BYTES_PM = '10485760'; + process.env.AWF_RATE_LIMIT_ENABLED = 'true'; const limiter = create(); expect(limiter.rpm).toBe(30); expect(limiter.rph).toBe(500); @@ -64,7 +65,7 @@ describe('rate-limiter', () => { process.env.AWF_RATE_LIMIT_RPH = '-100'; process.env.AWF_RATE_LIMIT_BYTES_PM = '-1024'; const limiter = create(); - expect(limiter.rpm).toBe(180); + expect(limiter.rpm).toBe(600); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); }); @@ -74,7 +75,7 @@ describe('rate-limiter', () => { process.env.AWF_RATE_LIMIT_RPH = '0'; process.env.AWF_RATE_LIMIT_BYTES_PM = '0'; const limiter = create(); - expect(limiter.rpm).toBe(180); + expect(limiter.rpm).toBe(600); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); }); @@ -84,21 +85,21 @@ describe('rate-limiter', () => { process.env.AWF_RATE_LIMIT_RPH = 'xyz'; process.env.AWF_RATE_LIMIT_BYTES_PM = ''; const limiter = create(); - expect(limiter.rpm).toBe(180); + expect(limiter.rpm).toBe(600); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); }); - it('should use defaults when env vars are not set', () => { + it('should default to disabled when env vars are not set', () => { delete process.env.AWF_RATE_LIMIT_RPM; delete process.env.AWF_RATE_LIMIT_RPH; delete process.env.AWF_RATE_LIMIT_BYTES_PM; delete process.env.AWF_RATE_LIMIT_ENABLED; const limiter = create(); - expect(limiter.rpm).toBe(180); + expect(limiter.rpm).toBe(600); expect(limiter.rph).toBe(1000); expect(limiter.bytesPm).toBe(50 * 1024 * 1024); - expect(limiter.enabled).toBe(true); + expect(limiter.enabled).toBe(false); }); }); diff --git a/src/cli.test.ts b/src/cli.test.ts index 9fcfaec5..7ad83e4b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1417,23 +1417,23 @@ describe('cli', () => { it('should return defaults when no options provided', () => { const r = buildRateLimitConfig({}); expect('config' in r).toBe(true); - if ('config' in r) { expect(r.config).toEqual({ enabled: true, rpm: 180, rph: 1000, bytesPm: 52428800 }); } + if ('config' in r) { expect(r.config).toEqual({ enabled: false, rpm: 0, rph: 0, bytesPm: 0 }); } }); - it('should disable with rateLimit=false', () => { - const r = buildRateLimitConfig({ rateLimit: false }); + it('should disable with rateLimit=false even if limits provided', () => { + const r = buildRateLimitConfig({ rateLimit: false, rateLimitRpm: '30' }); if ('config' in r) { expect(r.config.enabled).toBe(false); } }); - it('should parse custom RPM', () => { + it('should enable and parse custom RPM', () => { const r = buildRateLimitConfig({ rateLimitRpm: '30' }); - if ('config' in r) { expect(r.config.rpm).toBe(30); } + if ('config' in r) { expect(r.config.enabled).toBe(true); expect(r.config.rpm).toBe(30); } }); - it('should parse custom RPH', () => { + it('should enable and parse custom RPH', () => { const r = buildRateLimitConfig({ rateLimitRph: '500' }); - if ('config' in r) { expect(r.config.rph).toBe(500); } + if ('config' in r) { expect(r.config.enabled).toBe(true); expect(r.config.rph).toBe(500); } }); - it('should parse custom bytes-pm', () => { + it('should enable and parse custom bytes-pm', () => { const r = buildRateLimitConfig({ rateLimitBytesPm: '1000000' }); - if ('config' in r) { expect(r.config.bytesPm).toBe(1000000); } + if ('config' in r) { expect(r.config.enabled).toBe(true); expect(r.config.bytesPm).toBe(1000000); } }); it('should error on negative RPM', () => { expect('error' in buildRateLimitConfig({ rateLimitRpm: '-5' })).toBe(true); @@ -1450,9 +1450,9 @@ describe('cli', () => { it('should error on negative bytes-pm', () => { expect('error' in buildRateLimitConfig({ rateLimitBytesPm: '-100' })).toBe(true); }); - it('should ignore custom values when disabled', () => { + it('should ignore custom values when disabled via --no-rate-limit', () => { const r = buildRateLimitConfig({ rateLimit: false, rateLimitRpm: '999' }); - if ('config' in r) { expect(r.config.rpm).toBe(180); } + if ('config' in r) { expect(r.config.enabled).toBe(false); expect(r.config.rpm).toBe(0); } }); it('should accept all custom values', () => { const r = buildRateLimitConfig({ rateLimitRpm: '10', rateLimitRph: '100', rateLimitBytesPm: '5000000' }); diff --git a/src/cli.ts b/src/cli.ts index f0fa05ff..2abcf276 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -304,25 +304,39 @@ export function buildRateLimitConfig(options: { rateLimitRph?: string; rateLimitBytesPm?: string; }): { config: RateLimitConfig } | { error: string } { - const rateLimitDisabled = options.rateLimit === false; - const config: RateLimitConfig = { enabled: !rateLimitDisabled, rpm: 180, rph: 1000, bytesPm: 52428800 }; - if (!rateLimitDisabled) { - if (options.rateLimitRpm !== undefined) { - const rpm = parseInt(options.rateLimitRpm, 10); - if (isNaN(rpm) || rpm <= 0) return { error: '--rate-limit-rpm must be a positive integer' }; - config.rpm = rpm; - } - if (options.rateLimitRph !== undefined) { - const rph = parseInt(options.rateLimitRph, 10); - if (isNaN(rph) || rph <= 0) return { error: '--rate-limit-rph must be a positive integer' }; - config.rph = rph; - } - if (options.rateLimitBytesPm !== undefined) { - const bytesPm = parseInt(options.rateLimitBytesPm, 10); - if (isNaN(bytesPm) || bytesPm <= 0) return { error: '--rate-limit-bytes-pm must be a positive integer' }; - config.bytesPm = bytesPm; - } + // --no-rate-limit explicitly disables (even if other flags are set) + if (options.rateLimit === false) { + return { config: { enabled: false, rpm: 0, rph: 0, bytesPm: 0 } }; + } + + // Rate limiting is opt-in: disabled unless at least one --rate-limit-* flag is provided + const hasAnyLimit = options.rateLimitRpm !== undefined || + options.rateLimitRph !== undefined || + options.rateLimitBytesPm !== undefined; + + if (!hasAnyLimit) { + return { config: { enabled: false, rpm: 0, rph: 0, bytesPm: 0 } }; } + + // Defaults for any limit not explicitly set + const config: RateLimitConfig = { enabled: true, rpm: 600, rph: 10000, bytesPm: 52428800 }; + + if (options.rateLimitRpm !== undefined) { + const rpm = parseInt(options.rateLimitRpm, 10); + if (isNaN(rpm) || rpm <= 0) return { error: '--rate-limit-rpm must be a positive integer' }; + config.rpm = rpm; + } + if (options.rateLimitRph !== undefined) { + const rph = parseInt(options.rateLimitRph, 10); + if (isNaN(rph) || rph <= 0) return { error: '--rate-limit-rph must be a positive integer' }; + config.rph = rph; + } + if (options.rateLimitBytesPm !== undefined) { + const bytesPm = parseInt(options.rateLimitBytesPm, 10); + if (isNaN(bytesPm) || bytesPm <= 0) return { error: '--rate-limit-bytes-pm must be a positive integer' }; + config.bytesPm = bytesPm; + } + return { config }; } @@ -772,19 +786,19 @@ program ) .option( '--rate-limit-rpm ', - 'Requests per minute per provider (default: 180, requires --enable-api-proxy)', + 'Enable rate limiting: max requests per minute per provider (requires --enable-api-proxy)', ) .option( '--rate-limit-rph ', - 'Requests per hour per provider (default: 1000, requires --enable-api-proxy)', + 'Enable rate limiting: max requests per hour per provider (requires --enable-api-proxy)', ) .option( '--rate-limit-bytes-pm ', - 'Request bytes per minute per provider (default: 52428800 = 50MB, requires --enable-api-proxy)', + 'Enable rate limiting: max request bytes per minute per provider (requires --enable-api-proxy)', ) .option( '--no-rate-limit', - 'Disable rate limiting in the API proxy (requires --enable-api-proxy)', + 'Explicitly disable rate limiting in the API proxy (requires --enable-api-proxy)', ) .argument('[args...]', 'Command and arguments to execute (use -- to separate from options)') .action(async (args: string[], options) => { diff --git a/src/types.ts b/src/types.ts index 64e76118..209ffef3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -495,7 +495,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export interface RateLimitConfig { /** Whether rate limiting is enabled (default: true) */ enabled: boolean; - /** Max requests per minute per provider (default: 180) */ + /** Max requests per minute per provider (default: 600 when enabled) */ rpm: number; /** Max requests per hour per provider (default: 1000) */ rph: number; From 5080873e465e2936e654f4aa6b629668b3f2b245 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 25 Feb 2026 22:43:17 +0000 Subject: [PATCH 13/13] test: add missing integration tests for observability and rate limiting New observability tests: - Custom X-Request-ID is preserved when valid - Invalid X-Request-ID (' -d '{\"model\":\"test\"}'"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + const lower = result.stdout.toLowerCase(); + expect(lower).toContain('x-request-id'); + // The injected ID should NOT appear — proxy should have generated a UUID instead + expect(result.stdout).not.toContain('