From f18aea1d87ac36b8a0dbb49f8fd0de394d291b97 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Tue, 29 Sep 2026 13:23:57 +0100 Subject: [PATCH 1/4] feat(config): align .env.example with Zod schema and remove direct process.env reads (#573) --- backend/.env.example | 282 +++++++++++++++----- backend/src/api/middleware/auth.ts | 5 +- backend/src/api/middleware/errorHandler.ts | 8 +- backend/src/api/middleware/rateLimit.ts | 33 +-- backend/src/api/routes/agents.ts | 11 +- backend/src/api/routes/tasks.ts | 9 +- backend/src/api/routes/v1/tasks.ts | 2 - backend/src/api/routes/v2/tasks.ts | 4 +- backend/src/api/routes/versions.ts | 85 +++--- backend/src/cache/registry.ts | 9 +- backend/src/config/index.ts | 126 +++++---- backend/src/db/index.ts | 6 +- backend/src/db/migrations/loader.ts | 3 +- backend/src/schemas/task.ts | 24 +- backend/src/services/adminControl.ts | 38 +-- backend/src/services/auth/tokenService.ts | 11 +- backend/src/services/featureFlags.ts | 10 +- backend/src/services/idempotency.ts | 9 +- backend/src/services/qualityScorer.ts | 19 +- backend/src/services/venice/client.ts | 2 +- backend/tests/config.test.ts | 31 ++- backend/tests/no-direct-process-env.test.ts | 54 ++++ 22 files changed, 505 insertions(+), 276 deletions(-) create mode 100644 backend/tests/no-direct-process-env.test.ts diff --git a/backend/.env.example b/backend/.env.example index ed918c29..9322f275 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,5 @@ -# Server runtime: development | test | production. +# ── Server Runtime ────────────────────────────────────────────────────────────── +# Server runtime environment: development | test | production. NODE_ENV=development # HTTP port for the backend API. @@ -13,18 +14,13 @@ NPM_PACKAGE_VERSION=0.1.0 # Seconds to wait during graceful shutdown before forcing process exit. GRACEFUL_SHUTDOWN_TIMEOUT=30 -# SQLite/PostgreSQL connection string used by backend persistence. -DATABASE_URL=./data/ai-net.db +# Timeout in milliseconds for health probe HTTP checks. +HEALTH_PROBE_TIMEOUT_MS=5000 # Comma-separated browser origins allowed by CORS. ALLOWED_ORIGINS=http://localhost:3000 -# Optional comma-separated bearer API keys for protected API access. -API_KEYS= - -# Optional shared secret for admin endpoints such as /health/dashboard. -ADMIN_API_KEY= - +# ── Stellar & Soroban ────────────────────────────────────────────────────────── # Stellar network selector: testnet | mainnet | local | futurenet. STELLAR_NETWORK=testnet @@ -34,34 +30,60 @@ STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org # Optional legacy alias for STELLAR_HORIZON_URL. STELLAR_HORIZON= -# Optional backend coordinator secret key for payment release signing. -STELLAR_COORDINATOR_SECRET= - -# Optional testnet secret key used by live Stellar E2E tests. -STELLAR_TEST_SECRET= - # Optional public key used when local agents self-register. STELLAR_PUBLIC_KEY= +# Set true only in local/test runs to bypass Horizon account validation. +SKIP_STELLAR_ACCOUNT_VERIFY=false + # Soroban RPC URL used by registry event sync. SOROBAN_RPC_URL=https://soroban-testnet.stellar.org # Optional deployed AgentRegistry contract ID for event sync. REGISTRY_CONTRACT_ID= -# Set true only in local/test runs to bypass Horizon account validation. -SKIP_STELLAR_ACCOUNT_VERIFY=false +# Optional backend coordinator secret key for payment release signing. +STELLAR_COORDINATOR_SECRET= + +# Optional testnet secret key used by live Stellar E2E tests. +STELLAR_TEST_SECRET= + +# ── API & Authentication ─────────────────────────────────────────────────────── +# Optional comma-separated bearer API keys for protected API access. +API_KEYS= + +# Optional shared secret for admin endpoints such as /health/dashboard. +ADMIN_API_KEY= + +# JWT secret key used to sign and verify access tokens. +AUTH_JWT_SECRET=ai-net-default-auth-secret-change-in-production + +# Access token validity in seconds. Default: 900 (15 min). +AUTH_ACCESS_TOKEN_TTL_SECONDS=900 +# Refresh token sliding expiry validity in seconds. Default: 604800 (7 days). +AUTH_REFRESH_TOKEN_TTL_SECONDS=604800 + +# Max absolute session lifetime in seconds. Default: 2592000 (30 days). +AUTH_SESSION_MAX_TTL_SECONDS=2592000 + +# ── API Versioning ──────────────────────────────────────────────────────────── +# Latest API version advertised by versioning middleware. +API_LATEST_VERSION=2.0 + +# Comma-separated API versions accepted by the server. +API_SUPPORTED_VERSIONS=1.0,1.1,2.0 + +# API version used when the client omits API-Version. +API_DEFAULT_VERSION=1.0 + +# Optional HTTP Sunset header value for v1 clients. +API_V1_SUNSET_DATE= + +# ── Venice AI & LLM Budget ──────────────────────────────────────────────────── # Venice AI API key. Required outside NODE_ENV=test. VENICE_API_KEY=your_venice_api_key_here -# ── Database ────────────────────────────────────────────────────────────────── -# Filesystem path to the SQLite database holding the ai-net schema. -# Applied by `npm run db:migrate` (see src/db/migrations/). -DATABASE_URL=./data/ai-net.db - -# Optional: keep the versioned migration files outside src/db/migrations. -# DB_MIGRATIONS_DIR=./db/migrations # Venice API base URL. VENICE_BASE_URL=https://api.venice.ai/api/v1 @@ -74,9 +96,75 @@ VENICE_CACHE_TTL_MS=86400000 # Shorter Venice cache TTL for coding-agent responses in milliseconds. VENICE_CACHE_CODING_TTL_MS=3600000 -# Similarity threshold for semantic Venice cache reuse. +# Similarity threshold for semantic Venice cache reuse (0.0 to 1.0). VENICE_CACHE_SIMILARITY_THRESHOLD=0.8 +# Venice HTTP request timeout in milliseconds. +VENICE_REQUEST_TIMEOUT_MS=10000 + +# Maximum retries per provider before failing over. +VENICE_PROVIDER_MAX_RETRIES=3 + +# Optional comma-separated fallback API keys for Venice failover. +VENICE_FALLBACK_API_KEYS= + +# Optional comma-separated fallback base URLs for Venice failover. +VENICE_FALLBACK_BASE_URLS= + +# Total tokens (input + output) a single task may consume before it halts. +TASK_TOKEN_BUDGET=200000 + +# Ceiling on one LLM call's max_tokens. +LLM_MAX_TOKENS_PER_CALL=8192 + +# Ceiling on one LLM call's input prompt; longer prompts are trimmed. +LLM_MAX_PROMPT_TOKENS=16000 + +# Format: model_name=inputUsd:outputUsd,... overrides for the pricing table. +VENICE_PRICING= + +# How often in-flight task costs are flushed to the database in ms. +COST_FLUSH_INTERVAL_MS=60000 + +# ── Database & SQLite WAL Read Pool ─────────────────────────────────────────── +# Filesystem path to the primary SQLite database file holding the ai-net schema. +# Note: SQLite with WAL mode is used for single-writer thread safety. +DATABASE_URL=./data/ai-net.db + +# Optional: keep the versioned migration files outside src/db/migrations. +DB_MIGRATIONS_DIR= + +# SQLite connection pool size (minimum eager read-only connections). +DB_POOL_MIN=2 + +# SQLite connection pool size (maximum concurrent WAL read-only connections). +DB_POOL_MAX=10 + +# Timeout in ms to acquire a read-only SQLite connection handle. +DB_POOL_ACQUIRE_TIMEOUT_MS=5000 + +# Whether to run SELECT 1 on pooled SQLite reader handles before reuse. +DB_POOL_HEALTH_CHECK=true + +# Database backup target directory. +DB_BACKUP_DIR=./data/backups + +# Number of database backups to retain. +DB_BACKUP_RETENTION_COUNT=5 + +# Interval in ms for database maintenance tasks (e.g. WAL checkpoint, VACUUM). +DB_MAINTENANCE_INTERVAL_MS=3600000 + +# Auto-vacuum page count threshold before maintenance executes VACUUM. +DB_MAINTENANCE_VACUUM_THRESHOLD=100 + +# Path to the admin audit log SQLite database file. +ADMIN_AUDIT_DB_PATH= + +# Target directory for admin-triggered system backups. +ADMIN_BACKUP_DIR= + +# ── Caching ─────────────────────────────────────────────────────────────────── # Cache backend: lru | redis. CACHE_DRIVER=lru @@ -95,40 +183,39 @@ CACHE_TTL_STATS=30 # Health cache TTL in seconds. CACHE_TTL_HEALTH=10 -# Deployment-scoped prefix for registry cache keys (Issue #427). -# Change this per environment (e.g. "staging", "prod") when multiple -# deployments share the same Redis instance. +# Deployment-scoped prefix for registry cache keys. REGISTRY_CACHE_KEY_PREFIX=registry +# ── Rate Limiting & Quotas ──────────────────────────────────────────────────── # Maximum accepted task prompt length in characters. MAX_PROMPT_LENGTH=10000 -# Maximum tasks per wallet per rolling 24h window. Set 0 to disable. -DAILY_TASK_LIMIT_PER_WALLET=100 - -# Per-IP rate-limit window in milliseconds. +# Global fallback per-IP rate-limit window in milliseconds. RATE_LIMIT_WINDOW_MS=60000 -# Maximum task-create requests per IP per window. +# Global fallback maximum task-create requests per IP per window. RATE_LIMIT_MAX_REQUESTS=20 -# Per-route-group limits (token-bucket, per IP, rolling window) -# Public routes: /health, /api/stats, GET /api/agents +# Rate limit for agent registration endpoint (/api/agents/register). +REGISTER_RATE_LIMIT_MAX_REQUESTS=10 + +# Rate limit for public unauthenticated endpoints (/health, /api/stats, GET /api/agents). RATE_LIMIT_PUBLIC_WINDOW_MS=60000 RATE_LIMIT_PUBLIC_MAX_REQUESTS=120 -# Authenticated routes: /api/tasks + +# Rate limit for authenticated endpoints (/api/tasks). RATE_LIMIT_AUTHED_WINDOW_MS=60000 RATE_LIMIT_AUTHED_MAX_REQUESTS=30 -# Admin routes: /api/admin/* + +# Rate limit for admin endpoints (/api/admin/*). RATE_LIMIT_ADMIN_WINDOW_MS=60000 RATE_LIMIT_ADMIN_MAX_REQUESTS=20 -# ── Per-Wallet Daily Quota ──────────────────────────────────────────────────── -# Maximum tasks a single wallet address may create within a rolling 24-hour -# window. Set to 0 to disable the quota entirely. +# Per-wallet rolling 24-hour daily task creation limit (0 to disable). DAILY_TASK_LIMIT_PER_WALLET=100 -# Agent cleanup loop interval in milliseconds. +# ── Heartbeat & Agent Watchdog ──────────────────────────────────────────────── +# Agent heartbeat loop interval in milliseconds. HEARTBEAT_INTERVAL_MS=300000 # Minutes before an unseen agent is considered stale. @@ -137,33 +224,42 @@ HEARTBEAT_STALE_THRESHOLD_MINUTES=5 # Hours before stale offline agents are deleted. AGENT_OFFLINE_DELETE_HOURS=24 -# Optional webhook for reconciliation discrepancy alerts. -RECONCILIATION_WEBHOOK_URL= +# Agent watchdog check interval in milliseconds. +AGENT_WATCHDOG_INTERVAL_MS=60000 -# Automated reconciliation interval in milliseconds. -RECONCILIATION_INTERVAL_MS=86400000 +# Grace period in minutes before watchdog marks unresponsive agents offline. +AGENT_WATCHDOG_GRACE_MINUTES=10 -# Minimum response size in bytes before compression is attempted. -COMPRESSION_THRESHOLD=1024 +# Error registry cleanup interval in milliseconds. +ERROR_REGISTRY_MAINTENANCE_INTERVAL_MS=3600000 -# gzip/Brotli compression level. gzip uses 1-9. -COMPRESSION_LEVEL=6 +# Maximum error records kept per agent in the error registry. +ERROR_REGISTRY_CAP_PER_AGENT=100 -# Enable Brotli when clients advertise br support. -COMPRESSION_ENABLE_BROTLI=true +# ── Event Store Retention & Compaction ──────────────────────────────────────── +# On-disk path for the append-only event store SQLite database. +EVENT_STORE_PATH=./data/events.db -# Latest API version advertised by versioning middleware. -API_LATEST_VERSION=2.0 +# Retention window in days for finished task events. +EVENT_RETENTION_DAYS=30 -# Comma-separated API versions accepted by the server. -API_SUPPORTED_VERSIONS=1.0,1.1,2.0 +# How often the compaction pass runs in milliseconds. +EVENT_COMPACTION_INTERVAL_MS=3600000 -# API version used when the client omits API-Version. -API_DEFAULT_VERSION=1.0 +# Maximum tasks compacted per pass. +EVENT_COMPACTION_BATCH_TASKS=50 -# Optional HTTP Sunset header value for v1 clients. -API_V1_SUNSET_DATE= +# Master switch for the event store retention compaction job. +EVENT_COMPACTION_ENABLED=true +# ── Idempotency Store ───────────────────────────────────────────────────────── +# Retention TTL in milliseconds for idempotency keys. +IDEMPOTENCY_TTL_MS=86400000 + +# Background cleanup interval in milliseconds for expired idempotency keys. +IDEMPOTENCY_CLEANUP_MS=300000 + +# ── WebSockets ──────────────────────────────────────────────────────────────── # Maximum concurrent WebSocket stream connections per client IP. WS_MAX_CONNECTIONS_PER_CLIENT=5 @@ -179,6 +275,7 @@ WS_HEARTBEAT_INTERVAL_MS=30000 # WebSocket pong timeout in milliseconds. WS_PONG_TIMEOUT_MS=10000 +# ── Metrics ─────────────────────────────────────────────────────────────────── # Health dashboard cache TTL in milliseconds. METRICS_CACHE_TTL_MS=5000 @@ -188,22 +285,61 @@ METRICS_WINDOW_MS=60000 # Maximum HTTP request samples retained in memory. METRICS_MAX_SAMPLES=1000 -# ── Event store retention & compaction ─────────────────────────────────────── +# ── Quality Scorer ──────────────────────────────────────────────────────────── +# Completeness dimension score weight (0.0 to 1.0). +QUALITY_WEIGHT_COMPLETENESS=0.4 -# On-disk path for the append-only event store. Must be a file path for the -# retention job to be meaningful; the event log is discarded on restart when -# this is :memory:. -EVENT_STORE_PATH=./data/events.db +# Relevance dimension score weight (0.0 to 1.0). +QUALITY_WEIGHT_RELEVANCE=0.3 -# Retention window in days. Events for finished tasks older than this are -# archived (full-fidelity) and purged from the live task_events table. -EVENT_RETENTION_DAYS=30 +# Format dimension score weight (0.0 to 1.0). +QUALITY_WEIGHT_FORMAT=0.3 -# How often the compaction pass runs in milliseconds. -EVENT_COMPACTION_INTERVAL_MS=3600000 +# Score threshold below which output is flagged for review. +QUALITY_REVIEW_THRESHOLD=60 -# Maximum tasks compacted per pass, bounding writer-lock hold time. -EVENT_COMPACTION_BATCH_TASKS=50 +# Enable percentile rank normalization across historical agent output scores. +QUALITY_PERCENTILE_ENABLED=false -# Master switch for the retention job. -EVENT_COMPACTION_ENABLED=true +# Minimum sample count before percentile calculations activate. +QUALITY_PERCENTILE_MIN_SAMPLES=10 + +# ── System Maintenance & Read-Only ──────────────────────────────────────────── +# Set true to reject mutating requests with 503 Maintenance. +AI_NET_READ_ONLY=false + +# Human-readable message returned when AI_NET_READ_ONLY is active. +AI_NET_READ_ONLY_REASON= + +# ── Feature Flags ───────────────────────────────────────────────────────────── +# Enable real-time token streaming for LLM completion responses. +FEATURE_STREAMING_RESPONSES=true + +# Enable visual DAG preview endpoint and payloads. +FEATURE_DAG_PREVIEW=false + +# Enable experimental multi-agent models and workflows. +FEATURE_EXPERIMENTAL_AGENTS=false + +# Enable output quality scoring service. +FEATURE_QUALITY_SCORER=true + +# Enable automated system reconciliation sweeps. +FEATURE_RECONCILIATION=true + +# ── Automated Reconciliation ────────────────────────────────────────────────── +# Optional webhook URL for reconciliation discrepancy alerts. +RECONCILIATION_WEBHOOK_URL= + +# Automated reconciliation interval in milliseconds. +RECONCILIATION_INTERVAL_MS=86400000 + +# ── HTTP Compression ────────────────────────────────────────────────────────── +# Minimum response size in bytes before compression is attempted. +COMPRESSION_THRESHOLD=1024 + +# Compression level (1-9). +COMPRESSION_LEVEL=6 + +# Enable Brotli compression when client advertises support. +COMPRESSION_ENABLE_BROTLI=true diff --git a/backend/src/api/middleware/auth.ts b/backend/src/api/middleware/auth.ts index 37dffa36..b236dafd 100644 --- a/backend/src/api/middleware/auth.ts +++ b/backend/src/api/middleware/auth.ts @@ -101,10 +101,9 @@ export function resolveAdminApiKey(): string | undefined { fromConfig = (require("../../config") as typeof import("../../config")).getConfig() .ADMIN_API_KEY; } catch { - // Config not loaded — fall through to the environment. + // Config not loaded — ignore. } - const key = fromConfig ?? process.env.ADMIN_API_KEY; - return key && key.length > 0 ? key : undefined; + return fromConfig && fromConfig.length > 0 ? fromConfig : undefined; } /** Constant-time string comparison; length differences short-circuit safely. */ diff --git a/backend/src/api/middleware/errorHandler.ts b/backend/src/api/middleware/errorHandler.ts index ef3c8a6c..def07204 100644 --- a/backend/src/api/middleware/errorHandler.ts +++ b/backend/src/api/middleware/errorHandler.ts @@ -4,7 +4,6 @@ import { AppError } from "../../errors"; import { getConfig } from "../../config"; import { HTTP_STATUS_FOR_CODE } from "../../errors/ErrorCode"; -const isProduction = process.env.NODE_ENV === "production"; /** * Build the canonical error envelope for every API response. @@ -164,7 +163,10 @@ export function errorHandler( "unhandled error", ); - const message = isProduction + const isProd = getConfig().NODE_ENV === "production"; + const isDev = getConfig().NODE_ENV === "development"; + + const message = isProd ? "Internal server error" : err instanceof Error ? err.message || "Internal server error" @@ -176,7 +178,7 @@ export function errorHandler( statusCode, path, correlationId, - details: isDevelopment && err instanceof Error ? { stack: err.stack } : undefined, + details: isDev && err instanceof Error ? { stack: err.stack } : undefined, }); res.status(statusCode).json(body); diff --git a/backend/src/api/middleware/rateLimit.ts b/backend/src/api/middleware/rateLimit.ts index 01df0a8f..5a37267e 100644 --- a/backend/src/api/middleware/rateLimit.ts +++ b/backend/src/api/middleware/rateLimit.ts @@ -256,42 +256,31 @@ export function getRateLimiter(): RedisRateLimiter { // // Limits are intentionally conservative; operators should tune via env. -function readEnvInt(key: string, fallback: number): number { - const raw = process.env[key]; - if (!raw) return fallback; - const n = parseInt(raw, 10); - return Number.isFinite(n) && n > 0 ? n : fallback; -} - -function readEnvWindowMs(key: string, fallback: number): number { - const raw = process.env[key]; - if (!raw) return fallback; - const n = parseInt(raw, 10); - return Number.isFinite(n) && n > 0 ? n : fallback; -} - /** - * Lazily-created group limiters. Using factory functions so tests can reset - * process.env before the limiter is instantiated. + * Lazily-created group limiters. Using factory functions so tests can reset + * config before the limiter is instantiated. */ export function createPublicLimiter(): RateLimiter { + const cfg = getConfig(); return createRateLimiter({ - windowMs: readEnvWindowMs("RATE_LIMIT_PUBLIC_WINDOW_MS", 60_000), - maxRequests: readEnvInt("RATE_LIMIT_PUBLIC_MAX_REQUESTS", 120), + windowMs: cfg.RATE_LIMIT_PUBLIC_WINDOW_MS, + maxRequests: cfg.RATE_LIMIT_PUBLIC_MAX_REQUESTS, }); } export function createAuthedLimiter(): RateLimiter { + const cfg = getConfig(); return createRateLimiter({ - windowMs: readEnvWindowMs("RATE_LIMIT_AUTHED_WINDOW_MS", 60_000), - maxRequests: readEnvInt("RATE_LIMIT_AUTHED_MAX_REQUESTS", 30), + windowMs: cfg.RATE_LIMIT_AUTHED_WINDOW_MS, + maxRequests: cfg.RATE_LIMIT_AUTHED_MAX_REQUESTS, }); } export function createAdminLimiter(): RateLimiter { + const cfg = getConfig(); return createRateLimiter({ - windowMs: readEnvWindowMs("RATE_LIMIT_ADMIN_WINDOW_MS", 60_000), - maxRequests: readEnvInt("RATE_LIMIT_ADMIN_MAX_REQUESTS", 20), + windowMs: cfg.RATE_LIMIT_ADMIN_WINDOW_MS, + maxRequests: cfg.RATE_LIMIT_ADMIN_MAX_REQUESTS, }); } diff --git a/backend/src/api/routes/agents.ts b/backend/src/api/routes/agents.ts index 6fbe559a..f7cef68b 100644 --- a/backend/src/api/routes/agents.ts +++ b/backend/src/api/routes/agents.ts @@ -6,7 +6,7 @@ import { heartbeatRateLimitMiddleware } from "../middleware/rateLimit"; import { NotFoundError, ValidationError, UnauthorizedError, AppError } from "../../errors"; import { cacheMiddleware } from "../middleware/cache"; import { invalidateAgentsCache } from "../../cache/invalidation"; -import { ttlForRoute } from "../../config"; +import { ttlForRoute, getConfig } from "../../config"; const AgentCursorListSchema = z.object({ cursor: z.string().optional(), @@ -34,8 +34,7 @@ const RegisterAgentSchema = z.object({ }); const DEFAULT_HEALTH_TIMEOUT_MS = 3_000; -const HORIZON_URL = process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org"; -const horizon = new Horizon.Server(HORIZON_URL); +const getHorizon = () => new Horizon.Server(getConfig().STELLAR_HORIZON_URL); export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { const router = Router(); @@ -264,9 +263,9 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { const data = parse.data; // Verify Stellar account exists - if (process.env.SKIP_STELLAR_ACCOUNT_VERIFY !== "true") { + if (!getConfig().SKIP_STELLAR_ACCOUNT_VERIFY) { try { - await horizon.loadAccount(data.stellarPublicKey); + await getHorizon().loadAccount(data.stellarPublicKey); } catch (error: any) { if (error?.response?.status === 404) { throw new ValidationError( @@ -275,7 +274,7 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { correlationId, ); } - if (process.env.NODE_ENV !== "test") { + if (getConfig().NODE_ENV !== "test") { throw new AppError( "Failed to verify Stellar account", 503, diff --git a/backend/src/api/routes/tasks.ts b/backend/src/api/routes/tasks.ts index f126e0e8..026c914a 100644 --- a/backend/src/api/routes/tasks.ts +++ b/backend/src/api/routes/tasks.ts @@ -10,15 +10,14 @@ import { createLogger } from "../../utils/logger"; import { validate } from "../middleware/validate"; import { rateLimitMiddleware } from "../middleware/rateLimit"; import { idempotencyMiddleware } from "../middleware/idempotency"; -import { ValidationError, NotFoundError, AppError, RateLimitError } from "../../errors"; +import { ValidationError, NotFoundError, AppError, RateLimitError, ForbiddenError, ConflictError } from "../../errors"; import { getGlobalJobQueue, type JobQueue, type JobPriority } from "../../queue"; +import { getConfig } from "../../config"; + // ── Validation config ──────────────────────────────────────────────────────── -// Read at module load time so the value is stable for the lifetime of the -// process. Tests that need a different value should set process.env before -// importing (or use jest.resetModules() + re-require). -const DAILY_TASK_LIMIT = Number(process.env.DAILY_TASK_LIMIT_PER_WALLET ?? 100); +const getDailyTaskLimit = () => getConfig().DAILY_TASK_LIMIT_PER_WALLET; // ── Schemas ────────────────────────────────────────────────────────────────── diff --git a/backend/src/api/routes/v1/tasks.ts b/backend/src/api/routes/v1/tasks.ts index 2d1f778f..4dec926e 100644 --- a/backend/src/api/routes/v1/tasks.ts +++ b/backend/src/api/routes/v1/tasks.ts @@ -16,8 +16,6 @@ import { NotFoundError, ForbiddenError, ConflictError, RateLimitError } from ".. import { getGlobalJobQueue, type JobQueue, type JobPriority } from "../../../queue"; -// ── Validation config ──────────────────────────────────────────────────────── -const DAILY_TASK_LIMIT = Number(process.env.DAILY_TASK_LIMIT_PER_WALLET ?? 100); // ── Schemas ────────────────────────────────────────────────────────────────── diff --git a/backend/src/api/routes/v2/tasks.ts b/backend/src/api/routes/v2/tasks.ts index c85be586..c14d247e 100644 --- a/backend/src/api/routes/v2/tasks.ts +++ b/backend/src/api/routes/v2/tasks.ts @@ -12,13 +12,11 @@ import { rateLimitMiddleware } from "../../middleware/rateLimit"; import { idempotencyMiddleware } from "../../middleware/idempotency"; import { currentTraceId } from "../../../services/traceContext"; import { getConfig } from "../../../config"; -import { RateLimitError, NotFoundError, ForbiddenError, ConflictError } from "../../../errors"; +import { RateLimitError, NotFoundError, ForbiddenError, ConflictError, ValidationError } from "../../../errors"; import { taskStreamUrl } from "../stream"; import { getGlobalJobQueue, type JobQueue, type JobPriority } from "../../../queue"; -// ── Validation config ──────────────────────────────────────────────────────── -const DAILY_TASK_LIMIT = Number(process.env.DAILY_TASK_LIMIT_PER_WALLET ?? 100); // ── Schemas ────────────────────────────────────────────────────────────────── diff --git a/backend/src/api/routes/versions.ts b/backend/src/api/routes/versions.ts index 47b9fead..5452724a 100644 --- a/backend/src/api/routes/versions.ts +++ b/backend/src/api/routes/versions.ts @@ -10,6 +10,7 @@ * information available as structured JSON. */ import { Router, Request, Response } from "express"; +import { getConfig } from "../../config"; export interface VersionEntry { version: string; @@ -24,66 +25,54 @@ export interface VersionEntry { migratesTo?: string; } -const VERSION_MANIFEST: VersionEntry[] = [ - { - version: "1.0", - status: "deprecated", - deprecatedAt: "2026-01-01", - sunsetAt: process.env.API_V1_SUNSET_DATE ?? "2027-01-01", - breakingChanges: [], - migratesTo: "2.0", - }, - { - version: "1.1", - status: "deprecated", - deprecatedAt: "2026-06-01", - sunsetAt: process.env.API_V1_SUNSET_DATE ?? "2027-01-01", - breakingChanges: [ - "Task response envelope changed: `result` moved to `data.result`.", - ], - migratesTo: "2.0", - }, - { - version: "2.0", - status: "current", - breakingChanges: [ - "Error responses now include a machine-readable `code` field.", - "Paginated list endpoints return `{ data, pagination }` instead of a bare array.", - "Agent registration requires `capabilities` array (was optional in v1).", - ], - }, -]; +function getVersionManifest(): VersionEntry[] { + const sunsetAt = getConfig().API_V1_SUNSET_DATE ?? "2027-01-01"; + return [ + { + version: "1.0", + status: "deprecated", + deprecatedAt: "2026-01-01", + sunsetAt, + breakingChanges: [], + migratesTo: "2.0", + }, + { + version: "1.1", + status: "deprecated", + deprecatedAt: "2026-06-01", + sunsetAt, + breakingChanges: [ + "Task response envelope changed: `result` moved to `data.result`.", + ], + migratesTo: "2.0", + }, + { + version: "2.0", + status: "current", + breakingChanges: [ + "Error responses now include a machine-readable `code` field.", + "Paginated list endpoints return `{ data, pagination }` instead of a bare array.", + "Agent registration requires `capabilities` array (was optional in v1).", + ], + }, + ]; +} export function createVersionsRouter(): Router { const router = Router(); - /** - * @openapi - * /api/versions: - * get: - * summary: API versioning lifecycle manifest - * description: > - * Lists all API versions with their deprecation status, sunset dates, - * and breaking-change summaries. Clients should poll this endpoint to - * detect when an in-use version has been deprecated or is near its - * sunset date. - * tags: [Versioning] - * security: [] - * responses: - * 200: - * description: Version manifest - */ router.get("/", (_req: Request, res: Response) => { - const current = VERSION_MANIFEST.find((v) => v.status === "current"); + const manifest = getVersionManifest(); + const current = manifest.find((v) => v.status === "current"); res.json({ latestVersion: current?.version ?? "2.0", - defaultVersion: process.env.API_DEFAULT_VERSION ?? "1.0", + defaultVersion: getConfig().API_DEFAULT_VERSION, policy: { deprecationNoticeMonths: 6, sunsetGracePeriodMonths: 12, policyUrl: "/docs#api-versioning", }, - versions: VERSION_MANIFEST, + versions: manifest, }); }); diff --git a/backend/src/cache/registry.ts b/backend/src/cache/registry.ts index 9d0fd244..37959b96 100644 --- a/backend/src/cache/registry.ts +++ b/backend/src/cache/registry.ts @@ -30,20 +30,15 @@ import { createHash } from 'crypto'; import { getCacheClient } from './index'; import { recordInvalidation, markStale } from './metrics'; import { createLogger } from '../utils/logger'; +import { getConfig } from '../config'; const logger = createLogger({ module: 'registry-cache' }); -// --------------------------------------------------------------------------- -// Deployment-keyed prefix -// --------------------------------------------------------------------------- - /** * Returns the deployment-specific cache key prefix. - * Reads `REGISTRY_CACHE_KEY_PREFIX` at call-time so tests can override it via - * `process.env` without restarting the module. */ export function getRegistryCachePrefix(): string { - return process.env.REGISTRY_CACHE_KEY_PREFIX ?? 'registry'; + return getConfig().REGISTRY_CACHE_KEY_PREFIX; } // --------------------------------------------------------------------------- diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 044232ce..409b6f0a 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -20,14 +20,19 @@ const envSchema = z.object({ .default("false"), SOROBAN_RPC_URL: z.string().url().default("https://soroban-testnet.stellar.org"), REGISTRY_CONTRACT_ID: z.string().optional(), + VENICE_API_KEY: z.string().min(1, "VENICE_API_KEY is required"), - // Filesystem path to the SQLite database that holds the ai-net schema. - // Applied by `npm run db:migrate`, which resolves it via - // `resolveDatabasePath()` in src/db/index.ts. VENICE_BASE_URL: z.string().url().default("https://api.venice.ai/api/v1"), + VENICE_MODEL_VERSION: z.string().default("v1"), + VENICE_CACHE_TTL_MS: z.coerce.number().int().positive().default(86_400_000), + VENICE_CACHE_CODING_TTL_MS: z.coerce.number().int().positive().default(3_600_000), + VENICE_CACHE_SIMILARITY_THRESHOLD: z.coerce.number().min(0).max(1).default(0.8), + VENICE_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), + VENICE_PROVIDER_MAX_RETRIES: z.coerce.number().int().positive().default(3), + VENICE_FALLBACK_API_KEYS: z.string().optional(), + VENICE_FALLBACK_BASE_URLS: z.string().optional(), + DATABASE_URL: z.string().min(1, "DATABASE_URL is required").default("./data/ai-net.db"), - // Overrides the location of the versioned migration files. Only needed when - // migrations are kept outside the repository's `src/db/migrations` folder. DB_MIGRATIONS_DIR: z.string().optional(), STELLAR_COORDINATOR_SECRET: z.string().optional(), STELLAR_TEST_SECRET: z.string().optional(), @@ -42,32 +47,30 @@ const envSchema = z.object({ CACHE_TTL_AGENTS: z.coerce.number().int().nonnegative().default(60), CACHE_TTL_STATS: z.coerce.number().int().nonnegative().default(30), CACHE_TTL_HEALTH: z.coerce.number().int().nonnegative().default(10), - /** Deployment-scoped key prefix for registry cache entries (Issue #427). */ REGISTRY_CACHE_KEY_PREFIX: z.string().default("registry"), MAX_PROMPT_LENGTH: z.coerce.number().int().positive().default(10_000), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20), REGISTER_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(10), + RATE_LIMIT_PUBLIC_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + RATE_LIMIT_PUBLIC_MAX_REQUESTS: z.coerce.number().int().positive().default(120), + RATE_LIMIT_AUTHED_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + RATE_LIMIT_AUTHED_MAX_REQUESTS: z.coerce.number().int().positive().default(30), + RATE_LIMIT_ADMIN_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + RATE_LIMIT_ADMIN_MAX_REQUESTS: z.coerce.number().int().positive().default(20), DAILY_TASK_LIMIT_PER_WALLET: z.coerce.number().int().min(0).default(100), - /** Token budget management and per-task cost tracking (Issue #390). */ - /** Total tokens (input + output) a single task may consume before it halts. */ TASK_TOKEN_BUDGET: z.coerce.number().int().positive().default(200_000), - /** Ceiling on one LLM call's max_tokens. */ LLM_MAX_TOKENS_PER_CALL: z.coerce.number().int().positive().default(8_192), - /** Ceiling on one LLM call's input prompt; longer prompts are trimmed. */ LLM_MAX_PROMPT_TOKENS: z.coerce.number().int().positive().default(16_000), - /** `MODEL=inputUsd:outputUsd,MODEL=...` overrides for the pricing table. */ VENICE_PRICING: z.string().optional(), - /** How often in-flight task costs are flushed to the database (ms). */ COST_FLUSH_INTERVAL_MS: z.coerce.number().int().positive().default(60_000), HEARTBEAT_INTERVAL_MS: z.coerce.number().int().positive().default(300_000), HEARTBEAT_STALE_THRESHOLD_MINUTES: z.coerce.number().int().positive().default(5), AGENT_OFFLINE_DELETE_HOURS: z.coerce.number().int().positive().default(24), - /** Agent heartbeat watchdog: grace period before eviction (Issue #379). */ AGENT_WATCHDOG_INTERVAL_MS: z.coerce.number().int().positive().default(60_000), AGENT_WATCHDOG_GRACE_MINUTES: z.coerce.number().int().positive().default(10), @@ -76,7 +79,10 @@ const envSchema = z.object({ COMPRESSION_THRESHOLD: z.coerce.number().int().min(0).default(1024), COMPRESSION_LEVEL: z.coerce.number().int().min(1).max(9).default(6), - COMPRESSION_ENABLE_BROTLI: z.enum(["true", "false"]).transform((v) => v === "true").default("true"), + COMPRESSION_ENABLE_BROTLI: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .default("true"), API_LATEST_VERSION: z.string().default("2.0"), API_SUPPORTED_VERSIONS: z.string().default("1.0,1.1,2.0"), @@ -100,38 +106,16 @@ const envSchema = z.object({ ERROR_REGISTRY_MAINTENANCE_INTERVAL_MS: z.coerce.number().int().positive().default(3_600_000), ERROR_REGISTRY_CAP_PER_AGENT: z.coerce.number().int().positive().default(100), - // ── Event store retention & compaction (Issue #383) ───────────────────────── - /** - * On-disk path for the append-only event store. A file path is required for - * the retention job to be meaningful — with `:memory:` the whole event log is - * discarded on restart, so there is nothing to archive or compact. - */ EVENT_STORE_PATH: z.string().default("./data/events.db"), - /** - * Retention window in days. Events belonging to a *finished* task whose most - * recent event is older than this are archived and then purged from the live - * `task_events` table. Days (not row counts) because the boundary is task - * age, not table pressure. - */ EVENT_RETENTION_DAYS: z.coerce.number().int().positive().default(30), - /** How often the compaction pass runs, in milliseconds. */ EVENT_COMPACTION_INTERVAL_MS: z.coerce.number().int().positive().default(3_600_000), - /** - * Maximum number of tasks compacted per pass. Bounds the work (and therefore - * the writer-lock hold time) of a single tick so the live event path is not - * starved by a large backlog. - */ EVENT_COMPACTION_BATCH_TASKS: z.coerce.number().int().positive().default(50), - /** Master switch for the retention job. Also disabled when NODE_ENV=test. */ EVENT_COMPACTION_ENABLED: z .enum(["true", "false"]) .transform((v) => v === "true") .default("true"), - // ── Idempotency store (Issue #657) ─────────────────────────────────────────── - /** How long idempotency keys are retained before they can be replayed. Default: 24 h. */ IDEMPOTENCY_TTL_MS: z.coerce.number().int().positive().default(86_400_000), - /** How often the background cleanup sweep runs to delete expired keys. Default: 5 min. */ IDEMPOTENCY_CLEANUP_MS: z.coerce.number().int().positive().default(300_000), WS_MAX_CONNECTIONS_PER_CLIENT: z.coerce.number().int().positive().default(5), @@ -144,15 +128,49 @@ const envSchema = z.object({ METRICS_WINDOW_MS: z.coerce.number().int().positive().default(60_000), METRICS_MAX_SAMPLES: z.coerce.number().int().positive().default(1_000), - // ── Authentication & Session Security ─────────────────────────────────────── - /** JWT secret key used to sign and verify access tokens. */ AUTH_JWT_SECRET: z.string().default("ai-net-default-auth-secret-change-in-production"), - /** Access token validity in seconds. Default: 900 (15 min). */ AUTH_ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().positive().default(900), - /** Refresh token sliding expiry validity in seconds. Default: 604 800 (7 days). */ AUTH_REFRESH_TOKEN_TTL_SECONDS: z.coerce.number().int().positive().default(604_800), - /** Max absolute session lifetime in seconds. Default: 2 592 000 (30 days). */ AUTH_SESSION_MAX_TTL_SECONDS: z.coerce.number().int().positive().default(2_592_000), + + AI_NET_READ_ONLY: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .default("false"), + AI_NET_READ_ONLY_REASON: z.string().optional(), + ADMIN_AUDIT_DB_PATH: z.string().optional(), + ADMIN_BACKUP_DIR: z.string().optional(), + + QUALITY_WEIGHT_COMPLETENESS: z.coerce.number().min(0).max(1).default(0.4), + QUALITY_WEIGHT_RELEVANCE: z.coerce.number().min(0).max(1).default(0.3), + QUALITY_WEIGHT_FORMAT: z.coerce.number().min(0).max(1).default(0.3), + QUALITY_REVIEW_THRESHOLD: z.coerce.number().min(0).max(100).default(60), + QUALITY_PERCENTILE_ENABLED: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .default("false"), + QUALITY_PERCENTILE_MIN_SAMPLES: z.coerce.number().int().positive().default(10), + + FEATURE_STREAMING_RESPONSES: z + .enum(["true", "false", "1", "0"]) + .transform((v) => v === "true" || v === "1") + .optional(), + FEATURE_DAG_PREVIEW: z + .enum(["true", "false", "1", "0"]) + .transform((v) => v === "true" || v === "1") + .optional(), + FEATURE_EXPERIMENTAL_AGENTS: z + .enum(["true", "false", "1", "0"]) + .transform((v) => v === "true" || v === "1") + .optional(), + FEATURE_QUALITY_SCORER: z + .enum(["true", "false", "1", "0"]) + .transform((v) => v === "true" || v === "1") + .optional(), + FEATURE_RECONCILIATION: z + .enum(["true", "false", "1", "0"]) + .transform((v) => v === "true" || v === "1") + .optional(), }); export type RawConfig = z.infer; @@ -173,29 +191,24 @@ export class ConfigValidationError extends Error { let cachedConfig: Config | null = null; -function emptyToUndefined(value: unknown): unknown { - return typeof value === "string" && value.trim() === "" ? undefined : value; -} - function withRuntimeDefaults(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const nodeEnv = env.NODE_ENV ?? "development"; + const dbUrl = env.DATABASE_URL ?? env.DB_PATH; const testDefaults = nodeEnv === "test" ? { - DATABASE_URL: ":memory:", - // Keep the event store off the filesystem under test, mirroring - // DATABASE_URL. Tests that need real persistence pass an explicit - // path to createEventStore() instead. - EVENT_STORE_PATH: ":memory:", - VENICE_API_KEY: "test-venice-key", - LOG_LEVEL: "silent", + DATABASE_URL: dbUrl ?? ":memory:", + EVENT_STORE_PATH: env.EVENT_STORE_PATH ?? ":memory:", + VENICE_API_KEY: env.VENICE_API_KEY ?? "test-venice-key", + LOG_LEVEL: env.LOG_LEVEL ?? "silent", } : {}; return { ...testDefaults, ...env, - STELLAR_HORIZON_URL: env.STELLAR_HORIZON_URL ?? env.STELLAR_HORIZON, + DATABASE_URL: dbUrl ?? testDefaults.DATABASE_URL ?? "./data/ai-net.db", + STELLAR_HORIZON_URL: env.STELLAR_HORIZON_URL ?? env.STELLAR_HORIZON ?? "https://horizon-testnet.stellar.org", }; } @@ -228,6 +241,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } export function getConfig(): Config { + if (process.env.NODE_ENV === "test") { + return loadConfig(); + } return cachedConfig ?? loadConfig(); } @@ -274,4 +290,6 @@ export function redactedConfigSnapshot(cfg: Config = getConfig()): Record [key, redactConfigValue(key, value)]), ); -} \ No newline at end of file +} + +export { envSchema }; \ No newline at end of file diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 8413a896..bdb726c0 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -29,10 +29,12 @@ export function isInMemoryPath(dbPath: string): boolean { return value === ":memory:" || value.startsWith("file::memory:") || /mode=memory/.test(value); } +import { getConfig } from "../config"; + /** * Resolve the SQLite file path for the consolidated database. * - * Precedence: explicit argument → `DB_PATH` → `DATABASE_URL` → default. A + * Precedence: explicit argument → config.DATABASE_URL → default. A * `file:` prefix is stripped and relative paths are resolved against the * current working directory so `./data/ai-net.db` in `.env` means the same * thing regardless of where the process was started. @@ -41,7 +43,7 @@ export function isInMemoryPath(dbPath: string): boolean { * (e.g. `postgresql://…`), which would otherwise create a bogus file name. */ export function resolveDatabasePath(override?: string): string { - const raw = (override ?? process.env.DB_PATH ?? process.env.DATABASE_URL ?? DEFAULT_DB_PATH).trim(); + const raw = (override ?? getConfig().DATABASE_URL ?? DEFAULT_DB_PATH).trim(); if (raw === "") { throw new Error("Database path is empty — set DB_PATH or DATABASE_URL."); diff --git a/backend/src/db/migrations/loader.ts b/backend/src/db/migrations/loader.ts index d8e15504..842e0e7f 100644 --- a/backend/src/db/migrations/loader.ts +++ b/backend/src/db/migrations/loader.ts @@ -22,6 +22,7 @@ import { createHash } from "crypto"; import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { join } from "path"; +import { getConfig } from "../../config"; /** A single migration, parsed from one `.sql` file. */ export interface Migration { @@ -161,7 +162,7 @@ export function loadMigrations(dir: string): Migration[] { export function resolveMigrationsDir(explicit?: string): string { const candidates = [ explicit, - process.env.DB_MIGRATIONS_DIR, + getConfig().DB_MIGRATIONS_DIR, __dirname, // ts-node / jest / dist (when assets were copied) join(__dirname, "..", "..", "..", "..", "src", "db", "migrations"), // dist → source tree ].filter((candidate): candidate is string => typeof candidate === "string" && candidate !== ""); diff --git a/backend/src/schemas/task.ts b/backend/src/schemas/task.ts index ba3f24b3..f163d762 100644 --- a/backend/src/schemas/task.ts +++ b/backend/src/schemas/task.ts @@ -8,18 +8,13 @@ import { z } from "zod"; import { idParamSchema, sortSchema, withPagination } from "./common"; +import { getConfig } from "../config"; /** * Prompt ceiling, in characters. - * - * Bounded because Venice AI is billed per token: an unbounded prompt is an - * unbounded invoice (issue #181). - * - * Read from the environment at module load, matching the behaviour of the - * route modules this schema replaces. Tests that need a different ceiling must - * set the variable before importing, or use `jest.resetModules()`. */ -export const MAX_PROMPT_LENGTH = Number(process.env.MAX_PROMPT_LENGTH ?? 10_000); +export const getMaxPromptLength = (): number => getConfig().MAX_PROMPT_LENGTH; +export const MAX_PROMPT_LENGTH = 10_000; /** Task lifecycle states a caller may filter on. */ export const taskStatusSchema = z.enum([ @@ -44,7 +39,18 @@ export const taskPrioritySchema = z.enum(["low", "normal", "high", "critical"]); export const promptSchema = z .string() .min(1, "Prompt is required") - .max(MAX_PROMPT_LENGTH, `Prompt too long (max ${MAX_PROMPT_LENGTH} characters)`) + .superRefine((val, ctx) => { + const max = getMaxPromptLength(); + if (val.length > max) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: max, + type: "string", + inclusive: true, + message: `Prompt too long (max ${max} characters)`, + }); + } + }) .transform((s) => s.replace(/[\x00-\x08\x0E-\x1F]/g, "").trim()); /** diff --git a/backend/src/services/adminControl.ts b/backend/src/services/adminControl.ts index f0e82cfa..ea846484 100644 --- a/backend/src/services/adminControl.ts +++ b/backend/src/services/adminControl.ts @@ -8,6 +8,8 @@ import { getTaskDb } from "../db/tasks"; import { getJobDb } from "../queue"; import { createLogger } from "../utils/logger"; +import { getConfig } from "../config"; + const logger = createLogger({ component: "admin-control" }); const DEFAULT_AUDIT_DB = path.join(process.cwd(), "admin_audit.db"); @@ -31,18 +33,30 @@ export interface AdminAuditEntry { details?: unknown; } -let readOnlyState: ReadOnlyState = { - enabled: process.env.AI_NET_READ_ONLY === "true", - reason: process.env.AI_NET_READ_ONLY_REASON, - changedAt: new Date().toISOString(), - changedBy: "boot", -}; +let readOnlyState: ReadOnlyState | null = null; + +export function getReadOnlyState(): ReadOnlyState { + if (!readOnlyState) { + const cfg = getConfig(); + readOnlyState = { + enabled: cfg.AI_NET_READ_ONLY, + reason: cfg.AI_NET_READ_ONLY_REASON, + changedAt: new Date().toISOString(), + changedBy: "boot", + }; + } + return { ...readOnlyState }; +} + +export function isReadOnly(): boolean { + return getReadOnlyState().enabled; +} let auditDb: Database.Database | null = null; function getAuditDb(): Database.Database { if (!auditDb) { - const dbPath = process.env.ADMIN_AUDIT_DB_PATH ?? DEFAULT_AUDIT_DB; + const dbPath = getConfig().ADMIN_AUDIT_DB_PATH ?? DEFAULT_AUDIT_DB; auditDb = new Database(dbPath); auditDb.pragma("busy_timeout = 5000"); auditDb.pragma("journal_mode = WAL"); @@ -66,14 +80,6 @@ function getAuditDb(): Database.Database { return auditDb; } -export function getReadOnlyState(): ReadOnlyState { - return { ...readOnlyState }; -} - -export function isReadOnly(): boolean { - return readOnlyState.enabled; -} - export function setReadOnlyState(enabled: boolean, actor: string, reason?: string): ReadOnlyState { readOnlyState = { enabled, @@ -225,7 +231,7 @@ export function vacuumDatabases(): MaintenanceResult[] { } export async function backupDatabases(directory?: string): Promise { - const targetDir = directory ?? process.env.ADMIN_BACKUP_DIR ?? DEFAULT_BACKUP_DIR; + const targetDir = directory ?? getConfig().ADMIN_BACKUP_DIR ?? DEFAULT_BACKUP_DIR; fs.mkdirSync(targetDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); diff --git a/backend/src/services/auth/tokenService.ts b/backend/src/services/auth/tokenService.ts index 1dd90ea3..927a24db 100644 --- a/backend/src/services/auth/tokenService.ts +++ b/backend/src/services/auth/tokenService.ts @@ -23,6 +23,8 @@ function base64UrlDecode(str: string): string { return Buffer.from(base64, "base64").toString("utf-8"); } +import { getConfig } from "../../config"; + export class TokenService { private jwtSecret: string; private accessTtlSeconds: number; @@ -35,10 +37,11 @@ export class TokenService { refreshTtlSeconds?: number; sessionMaxTtlSeconds?: number; }) { - this.jwtSecret = options?.jwtSecret ?? process.env.AUTH_JWT_SECRET ?? "ai-net-auth-secret"; - this.accessTtlSeconds = options?.accessTtlSeconds ?? 900; // 15 mins - this.refreshTtlSeconds = options?.refreshTtlSeconds ?? 604800; // 7 days - this.sessionMaxTtlSeconds = options?.sessionMaxTtlSeconds ?? 2592000; // 30 days + const cfg = getConfig(); + this.jwtSecret = options?.jwtSecret ?? cfg.AUTH_JWT_SECRET; + this.accessTtlSeconds = options?.accessTtlSeconds ?? cfg.AUTH_ACCESS_TOKEN_TTL_SECONDS; + this.refreshTtlSeconds = options?.refreshTtlSeconds ?? cfg.AUTH_REFRESH_TOKEN_TTL_SECONDS; + this.sessionMaxTtlSeconds = options?.sessionMaxTtlSeconds ?? cfg.AUTH_SESSION_MAX_TTL_SECONDS; } /** diff --git a/backend/src/services/featureFlags.ts b/backend/src/services/featureFlags.ts index aa06d92c..21ac4cb8 100644 --- a/backend/src/services/featureFlags.ts +++ b/backend/src/services/featureFlags.ts @@ -39,11 +39,15 @@ const runtimeOverrides = new Map(); // ─── Resolution ─────────────────────────────────────────────────────────────── +import { getConfig } from "../config"; + function readEnvFlag(flag: FeatureFlag): boolean | undefined { + const cfg = getConfig() as Record; const key = `FEATURE_${flag.toUpperCase()}`; - const val = process.env[key]; - if (val === undefined) return undefined; - return val === "1" || val.toLowerCase() === "true"; + const val = cfg[key]; + if (val === undefined || val === null) return undefined; + if (typeof val === "boolean") return val; + return val === "1" || String(val).toLowerCase() === "true"; } /** diff --git a/backend/src/services/idempotency.ts b/backend/src/services/idempotency.ts index 6cc202ce..c42c58f9 100644 --- a/backend/src/services/idempotency.ts +++ b/backend/src/services/idempotency.ts @@ -241,9 +241,12 @@ let _defaultStore: IdempotencyStore | null = null; * * The store is lazily initialised on first call. */ +import { getConfig } from '../config'; + export function getDefaultIdempotencyStore(config?: Pick): IdempotencyStore { if (!_defaultStore) { - const isTest = (config?.NODE_ENV ?? process.env.NODE_ENV) === 'test'; + const sysConfig = getConfig(); + const isTest = (config?.NODE_ENV ?? sysConfig.NODE_ENV) === 'test'; let db: Database.Database; if (isTest) { @@ -257,8 +260,8 @@ export function getDefaultIdempotencyStore(config?: Pick = { export const DEFAULT_REVIEW_THRESHOLD = 60; +import { getConfig } from '../config'; + /** - * Load quality scorer configuration from process.env. - * Called lazily so config changes (env vars) take effect without redeploy. - * Falls back to defaults when env vars are not set. + * Load quality scorer configuration from config. */ export function loadScorerConfig(): QualityScorerConfig { - const weightComp = Number(process.env.QUALITY_WEIGHT_COMPLETENESS ?? 0.4); - const weightRel = Number(process.env.QUALITY_WEIGHT_RELEVANCE ?? 0.3); - const weightFmt = Number(process.env.QUALITY_WEIGHT_FORMAT ?? 0.3); - const reviewThreshold = Number(process.env.QUALITY_REVIEW_THRESHOLD ?? 60); - const percentileEnabled = process.env.QUALITY_PERCENTILE_ENABLED === 'true'; - const percentileMinSamples = Number(process.env.QUALITY_PERCENTILE_MIN_SAMPLES ?? 10); + const cfg = getConfig(); + const weightComp = cfg.QUALITY_WEIGHT_COMPLETENESS; + const weightRel = cfg.QUALITY_WEIGHT_RELEVANCE; + const weightFmt = cfg.QUALITY_WEIGHT_FORMAT; + const reviewThreshold = cfg.QUALITY_REVIEW_THRESHOLD; + const percentileEnabled = cfg.QUALITY_PERCENTILE_ENABLED; + const percentileMinSamples = cfg.QUALITY_PERCENTILE_MIN_SAMPLES; return { weightCompleteness: clamp(weightComp, 0, 1), diff --git a/backend/src/services/venice/client.ts b/backend/src/services/venice/client.ts index a4ca179d..3ab633ef 100644 --- a/backend/src/services/venice/client.ts +++ b/backend/src/services/venice/client.ts @@ -131,7 +131,7 @@ export class VeniceClient implements VeniceClientLike { } constructor(config: VeniceClientConfig) { - this.breaker = config.circuitBreaker ?? new CircuitBreaker(); + this.breaker = config.circuitBreaker ?? new CircuitBreaker({ name: 'venice' }); const env = this.resolveConfig() as any; this.modelVersion = config.modelVersion ?? env.VENICE_MODEL_VERSION ?? CONFIG_FALLBACK.VENICE_MODEL_VERSION; diff --git a/backend/tests/config.test.ts b/backend/tests/config.test.ts index a36550ee..b2ddeeaa 100644 --- a/backend/tests/config.test.ts +++ b/backend/tests/config.test.ts @@ -1,6 +1,8 @@ -import { loadConfig, resetConfigForTests } from "../src/config"; +import fs from "fs"; +import path from "path"; +import { loadConfig, resetConfigForTests, envSchema } from "../src/config"; -describe("config validation", () => { +describe("config validation & schema parity", () => { afterEach(() => { resetConfigForTests(); }); @@ -23,4 +25,29 @@ describe("config validation", () => { expect(message).toContain("DATABASE_URL"); expect(message).toContain("VENICE_API_KEY"); }); + + it(".env.example and envSchema shape agree in both directions", () => { + const envExamplePath = path.resolve(__dirname, "../.env.example"); + const content = fs.readFileSync(envExamplePath, "utf8"); + + // Extract all KEY= or # KEY= declarations from .env.example + const envKeys = new Set(); + const lines = content.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(?:#\s*)?([A-Z0-9_]+)=/); + if (match) { + envKeys.add(match[1]); + } + } + + const schemaKeys = new Set(Object.keys(envSchema.shape)); + + const missingInEnvExample = [...schemaKeys].filter((k) => !envKeys.has(k)); + const missingInSchema = [...envKeys].filter((k) => !schemaKeys.has(k)); + + expect(missingInEnvExample).toEqual([]); + expect(missingInSchema).toEqual([]); + }); }); diff --git a/backend/tests/no-direct-process-env.test.ts b/backend/tests/no-direct-process-env.test.ts new file mode 100644 index 00000000..5b67c193 --- /dev/null +++ b/backend/tests/no-direct-process-env.test.ts @@ -0,0 +1,54 @@ +import fs from "fs"; +import path from "path"; + +describe("process.env direct access enforcement", () => { + const allowedRelativePaths = new Set([ + "config/index.ts", + "utils/logger.ts", + "index.ts", + "checkSpec.ts", + "db/cli.ts", + "db/seed.ts", + "db/migrations/cli.ts", + "db/migrations/runner.ts", + "queue/worker.ts", + ]); + + function getFiles(dir: string): string[] { + const subdirs = fs.readdirSync(dir); + const files: string[] = []; + for (const subdir of subdirs) { + const res = path.resolve(dir, subdir); + if (fs.statSync(res).isDirectory()) { + files.push(...getFiles(res)); + } else if (res.endsWith(".ts") && !res.endsWith(".test.ts")) { + files.push(res); + } + } + return files; + } + + it("asserts feature code has no direct process.env reads outside allowed config/entry files", () => { + const srcDir = path.resolve(__dirname, "../src"); + const allTsFiles = getFiles(srcDir); + + const violations: string[] = []; + + for (const file of allTsFiles) { + const relPath = path.relative(srcDir, file).replace(/\\/g, "/"); + if (allowedRelativePaths.has(relPath)) { + continue; + } + + const content = fs.readFileSync(file, "utf8"); + // Remove comments to avoid false positives in docstrings + const codeOnly = content.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ""); + + if (codeOnly.includes("process.env")) { + violations.push(relPath); + } + } + + expect(violations).toEqual([]); + }); +}); From de623ce542246ea1410dfca499ce0f97d4be2a33 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Tue, 29 Sep 2026 13:24:52 +0100 Subject: [PATCH 2/4] fix(frontend): repair App.tsx route imports and Suspense fallback (#575) --- frontend/src/App.tsx | 9 --------- .../src/components/auth/ProtectedRoute.test.tsx | 13 +++++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61c872c6..317301f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,15 +15,6 @@ import { CommandPalette } from './components/common/CommandPalette' import { useCommandPalette } from './hooks/useCommandPalette' import './components/common/Toast.css' -const RouteLoadingFallback: React.FC = () => ( -
Loading...
-) - -const DashboardPage = lazy(() => import('./pages/DashboardPage')) -const WalletPage = lazy(() => import('./pages/WalletPage')) -const AgentsPage = lazy(() => import('./pages/AgentsPage')) -const NewTaskPage = lazy(() => import('./pages/NewTaskPage')) -const TaskHistoryPage = lazy(() => import('./pages/TaskHistoryPage')) // Route-level code splitting: every page except LandingPage (kept eager so // the first paint on `/` is not delayed) is fetched on demand. Heavy // route-only libraries (reactflow, recharts, jspdf, react-syntax-highlighter) diff --git a/frontend/src/components/auth/ProtectedRoute.test.tsx b/frontend/src/components/auth/ProtectedRoute.test.tsx index 615f188e..eb1783da 100644 --- a/frontend/src/components/auth/ProtectedRoute.test.tsx +++ b/frontend/src/components/auth/ProtectedRoute.test.tsx @@ -103,6 +103,14 @@ function renderWithAuth({ } /> + +
Task History
+ + } + /> { expect(screen.getByTestId('new-task-content')).toBeInTheDocument(); }); + it('renders /tasks/history content when authenticated', () => { + renderWithAuth({ initialPath: '/tasks/history', walletConnected: true }); + expect(screen.getByTestId('task-history-content')).toBeInTheDocument(); + }); + it('renders /tasks/:id content when authenticated', () => { renderWithAuth({ initialPath: '/tasks/task-xyz-789', walletConnected: true }); expect(screen.getByTestId('task-detail-content')).toBeInTheDocument(); From 3af59dd1671a3c5610ece7877228465a50aeb731 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Tue, 29 Sep 2026 13:26:25 +0100 Subject: [PATCH 3/4] fix(frontend): repair TaskDetailPage imports and consolidate API types (#576) --- frontend/src/pages/TaskDetailPage.test.tsx | 104 +++++++++++++++++++++ frontend/src/types/api.ts | 17 ++-- 2 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/TaskDetailPage.test.tsx diff --git a/frontend/src/pages/TaskDetailPage.test.tsx b/frontend/src/pages/TaskDetailPage.test.tsx new file mode 100644 index 00000000..91c32c49 --- /dev/null +++ b/frontend/src/pages/TaskDetailPage.test.tsx @@ -0,0 +1,104 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import TaskDetailPage from './TaskDetailPage'; + +const mockTask = { + id: 'task-test-123', + prompt: 'Test prompt for task detail', + status: 'completed' as const, + walletPublicKey: 'GABC1234567890', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + dag: [], +}; + +const mockNodes = [ + { + nodeId: 'node_research', + agentType: 'research', + prompt: 'Research agent prompt', + dependsOn: [], + status: 'completed' as const, + result: { summary: 'Research complete' }, + }, + { + nodeId: 'node_report', + agentType: 'report', + prompt: 'Report agent prompt', + dependsOn: ['node_research'], + status: 'completed' as const, + result: { summary: 'Report complete' }, + }, +]; + +vi.mock('../hooks/useTaskMonitor', () => ({ + useTaskMonitor: (id?: string) => ({ + task: id === 'task-test-123' ? mockTask : null, + loading: false, + error: null, + wsStatus: 'connected', + nodes: id === 'task-test-123' ? mockNodes : [], + payments: [], + outputs: [], + refetch: vi.fn(), + }), +})); + +vi.mock('../services/api', () => ({ + getTaskCost: vi.fn().mockResolvedValue({ + taskId: 'task-test-123', + budgetTokens: 200000, + usedTokens: 1500, + remainingTokens: 198500, + costUsd: 0.05, + currency: 'USD', + exceeded: false, + calls: 2, + inProgress: false, + agents: [], + }), +})); + +function renderPage(taskId = 'task-test-123') { + return render( + + + } /> + + + ); +} + +describe('TaskDetailPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders task details and DAG preview section without crashing', async () => { + renderPage('task-test-123'); + + expect(screen.getByText(/Test prompt for task detail/i)).toBeInTheDocument(); + expect(screen.getByTestId('dag-preview')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText(/Task Completed Successfully/i)).toBeInTheDocument(); + }); + }); + + it('renders skeleton loader when task data is loading', () => { + vi.mocked(useTaskMonitor).mockReturnValueOnce({ + task: null, + loading: true, + error: null, + wsStatus: 'connecting', + nodes: [], + payments: [], + outputs: [], + refetch: vi.fn(), + }); + + renderPage('task-loading'); + expect(screen.getByTestId('task-detail-skeleton')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 47c77e0c..4b56c084 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -16,8 +16,12 @@ export interface DAGNode { status: NodeStatus; result?: unknown; error?: string; + id?: string; + label?: string; } +export type DagNode = DAGNode; + export interface TaskResponse { taskId: string; id?: string; @@ -29,11 +33,6 @@ export interface TaskResponse { updatedAt: string; } -export interface DagNode { - id: string; - label: string; -} - export interface DagEdge { source: string; target: string; @@ -195,13 +194,13 @@ export interface CursorPage { nextCursor: string | null; hasNextPage: boolean; }; -} - -export interface CursorPageEnvelope { - data: CursorPage; _links?: { self: string; next?: string; }; } +export type CursorPageEnvelope = CursorPage & { + data?: CursorPage; +}; + From c61c8182993d8f64248ecfc4821a22f0165aea56 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Tue, 29 Sep 2026 13:26:59 +0100 Subject: [PATCH 4/4] fix(backend): align coverage exclusions and teardown configuration (#574) --- backend/jest.config.js | 5 ----- backend/tests/globalTeardown.ts | 27 --------------------------- 2 files changed, 32 deletions(-) delete mode 100644 backend/tests/globalTeardown.ts diff --git a/backend/jest.config.js b/backend/jest.config.js index 14cae3e6..3db4da64 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -29,12 +29,7 @@ module.exports = { '!src/**/*.d.ts', '!src/**/*.test.ts', '!src/**/*.spec.ts', - '!src/**/index.ts', '!src/**/.gitkeep', - '!src/registry/sync.ts', - '!src/api/routes/stream.ts', - '!src/index.ts', - '!src/checkSpec.ts', ], coverageThreshold: { global: { diff --git a/backend/tests/globalTeardown.ts b/backend/tests/globalTeardown.ts deleted file mode 100644 index aba8a722..00000000 --- a/backend/tests/globalTeardown.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Jest global teardown — runs once after all test suites complete. - * - * Explicitly closes all SQLite database connections before the Node.js process - * exits. Without this, `better-sqlite3` triggers a SIGABRT / exit-134 crash - * on Node 24 when the garbage collector finalises native Database handles - * after Jest has already started tearing down the V8 isolate. - * - * This file is referenced by `globalTeardown` in jest.config.js. - */ - -import { closeAgentDb } from '../src/db/agents'; -import { closeTaskDb } from '../src/db/tasks'; - -export default async function globalTeardown(): Promise { - try { - closeAgentDb(); - } catch { - // Ignore — DB may never have been opened in this worker - } - - try { - closeTaskDb(); - } catch { - // Ignore — DB may never have been opened in this worker - } -}