From c09978454b31861fe4b76f2d545c7cee9708b9bc Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 14 Feb 2026 19:03:55 -0300 Subject: [PATCH] feat: domain layer, error codes, request ID, fetch timeout, JSDoc (T-19, T-22, T-23, T-25, T-27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-19 — Domain Layer: - modelAvailability.js: Model availability tracking with TTL cooldowns - costRules.js: Per-API-key budget management with daily/monthly limits - fallbackPolicy.js: Declarative fallback chain routing T-22 — Error Codes Catalog: - errorCodes.js: 24 codes in 6 categories + createErrorResponse helper T-23 — Correlation ID: - requestId.js: AsyncLocalStorage-based x-request-id propagation T-25 — Fetch Timeout: - fetchTimeout.js: AbortController wrapper with FETCH_TIMEOUT_MS env var T-27 — JSDoc + @ts-check: - Added @ts-check to 8 critical files TASKS.md updated: 37/46 tasks Concluído, 9 remaining Tests: 119/119 pass (88 existing + 31 new) --- docs/TASKS.md | 155 ++++++++-------- src/domain/comboResolver.js | 1 + src/domain/costRules.js | 157 ++++++++++++++++ src/domain/fallbackPolicy.js | 108 +++++++++++ src/domain/lockoutPolicy.js | 1 + src/domain/modelAvailability.js | 130 +++++++++++++ src/lib/policies/policyEngine.js | 1 + src/lib/usage/callLogs.js | 1 + src/lib/usage/costCalculator.js | 1 + src/lib/usage/usageHistory.js | 1 + src/shared/constants/errorCodes.js | 114 ++++++++++++ src/shared/utils/circuitBreaker.js | 1 + src/shared/utils/fetchTimeout.js | 81 ++++++++ src/shared/utils/inputSanitizer.js | 1 + src/shared/utils/requestId.js | 91 +++++++++ tests/unit/batch-a-domain.test.mjs | 288 +++++++++++++++++++++++++++++ 16 files changed, 1058 insertions(+), 74 deletions(-) create mode 100644 src/domain/costRules.js create mode 100644 src/domain/fallbackPolicy.js create mode 100644 src/domain/modelAvailability.js create mode 100644 src/shared/constants/errorCodes.js create mode 100644 src/shared/utils/fetchTimeout.js create mode 100644 src/shared/utils/requestId.js create mode 100644 tests/unit/batch-a-domain.test.mjs diff --git a/docs/TASKS.md b/docs/TASKS.md index 54e71586a5..f373b394ee 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -20,124 +20,131 @@ ## FASE 01 — Security Hardening -| ID | Descrição | Prioridade | Deps | Status | -| ---- | ----------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------- | -------- | -| T-01 | Remover fallback hardcoded de `JWT_SECRET` em `src/proxy.js` e implementar validação fail-fast na inicialização | 🔴 Crítica | — | Pendente | -| T-02 | Remover fallback hardcoded de `API_KEY_SECRET` em `src/shared/utils/apiKey.js` e implementar validação fail-fast | 🔴 Crítica | — | Pendente | -| T-03 | Atualizar `.env.example` e README com instruções para gerar segredos fortes (openssl rand) | 🔴 Crítica | T-01, T-02 | Pendente | -| T-04 | Adicionar logging estruturado em todos os `catch` blocks silenciosos de `src/proxy.js` (auth_error, settings_error) | 🔴 Crítica | — | Pendente | -| T-05 | Criar módulo `src/shared/utils/inputSanitizer.js` com detecção de prompt injection e PII redaction | 🔴 Crítica | — | Pendente | -| T-06 | Integrar `inputSanitizer` no pipeline de request em `src/sse/handlers/chat.js` antes de `translateRequest()` | 🔴 Crítica | T-05 | Pendente | -| T-07 | Remover `.passthrough()` de `updateSettingsSchema` em `src/shared/validation/schemas.js` e listar campos explicitamente | 🟡 Moderada | — | Pendente | -| T-08 | Remover dependência `"fs": "^0.0.1-security"` do `package.json` e verificar imports | 🟢 Menor | — | Pendente | -| T-09 | Criar testes unitários para validação de segredos e sanitizador de inputs | 🔴 Crítica | T-01, T-02, T-05 | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | ----------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------- | --------- | +| T-01 | Remover fallback hardcoded de `JWT_SECRET` em `src/proxy.js` e implementar validação fail-fast na inicialização | 🔴 Crítica | — | Concluído | +| T-02 | Remover fallback hardcoded de `API_KEY_SECRET` em `src/shared/utils/apiKey.js` e implementar validação fail-fast | 🔴 Crítica | — | Concluído | +| T-03 | Atualizar `.env.example` e README com instruções para gerar segredos fortes (openssl rand) | 🔴 Crítica | T-01, T-02 | Concluído | +| T-04 | Adicionar logging estruturado em todos os `catch` blocks silenciosos de `src/proxy.js` (auth_error, settings_error) | 🔴 Crítica | — | Concluído | +| T-05 | Criar módulo `src/shared/utils/inputSanitizer.js` com detecção de prompt injection e PII redaction | 🔴 Crítica | — | Concluído | +| T-06 | Integrar `inputSanitizer` no pipeline de request em `src/sse/handlers/chat.js` antes de `translateRequest()` | 🔴 Crítica | T-05 | Concluído | +| T-07 | Remover `.passthrough()` de `updateSettingsSchema` em `src/shared/validation/schemas.js` e listar campos explicitamente | 🟡 Moderada | — | Concluído | +| T-08 | Remover dependência `"fs": "^0.0.1-security"` do `package.json` e verificar imports | 🟢 Menor | — | Concluído | +| T-09 | Criar testes unitários para validação de segredos e sanitizador de inputs | 🔴 Crítica | T-01, T-02, T-05 | Concluído | --- ## FASE 02 — CI/CD & Infraestrutura de Testes -| ID | Descrição | Prioridade | Deps | Status | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- | -| T-10 | Criar `.github/workflows/ci.yml` com jobs: lint, build, test:unit, test:e2e (Node 18+22, trigger PR/push) | 🔴 Crítica | T-01 | Pendente | -| T-11 | Alterar script `"test"` no `package.json` para `node --test tests/unit/*.test.mjs`; adicionar scripts `test:unit`, `test:e2e`, `test:all` | 🔴 Crítica | — | Pendente | -| T-12 | Configurar `c8` como ferramenta de cobertura de testes com script `test:coverage` e target mínimo 40% | 🔴 Crítica | T-11 | Pendente | -| T-13 | Instalar e configurar `eslint-plugin-security` e `eslint-plugin-react-hooks` no `eslint.config.mjs` | 🟠 Importante | — | Pendente | -| T-14 | Converter 4 scripts de `tests/security/` em testes programáticos `.test.mjs` em `tests/integration/` | 🟠 Importante | T-11 | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | --------- | +| T-10 | Criar `.github/workflows/ci.yml` com jobs: lint, build, test:unit, test:e2e (Node 18+22, trigger PR/push) | 🔴 Crítica | T-01 | Concluído | +| T-11 | Alterar script `"test"` no `package.json` para `node --test tests/unit/*.test.mjs`; adicionar scripts `test:unit`, `test:e2e`, `test:all` | 🔴 Crítica | — | Concluído | +| T-12 | Configurar `c8` como ferramenta de cobertura de testes com script `test:coverage` e target mínimo 40% | 🔴 Crítica | T-11 | Concluído | +| T-13 | Instalar e configurar `eslint-plugin-security` e `eslint-plugin-react-hooks` no `eslint.config.mjs` | 🟠 Importante | — | Concluído | +| T-14 | Converter 4 scripts de `tests/security/` em testes programáticos `.test.mjs` em `tests/integration/` | 🟠 Importante | T-11 | Concluído | --- ## FASE 03 — Refatoração Arquitetural -| ID | Descrição | Prioridade | Deps | Status | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- | -| T-15 | Decompor `src/lib/usageDb.js` em 5 módulos: `usageHistory.js`, `callLogs.js`, `costCalculator.js`, `usageStats.js`, `migrations.js` | 🟠 Importante | T-10 | Pendente | -| T-16 | Criar base class `OAuthProvider` em `src/lib/oauth/base/` e factory `providerFactory.js` | 🟠 Importante | T-10 | Pendente | -| T-17 | Extrair 12 providers OAuth em subclasses individuais em `src/lib/oauth/providers/` | 🟠 Importante | T-16 | Pendente | -| T-18 | Eliminar self-fetch no middleware: criar `src/lib/settingsCache.js` com cache in-memory (TTL 5s) e refatorar `proxy.js` | 🔴 Crítica | T-04 | Pendente | -| T-19 | Criar domain layer `src/domain/` com: `modelAvailability.js`, `costRules.js`, `fallbackPolicy.js` | 🟡 Moderada | T-15 | Pendente | -| T-20 | Adicionar `antigravity-manager-analysis/` ao `.gitignore` e consolidar endpoints `rate-limit/` vs `rate-limits/` | 🟢 Menor | — | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | --------- | +| T-15 | Decompor `src/lib/usageDb.js` em 5 módulos: `usageHistory.js`, `callLogs.js`, `costCalculator.js`, `usageStats.js`, `migrations.js` | 🟠 Importante | T-10 | Concluído | +| T-16 | Criar base class `OAuthProvider` em `src/lib/oauth/base/` e factory `providerFactory.js` | 🟠 Importante | T-10 | Concluído | +| T-17 | Extrair 12 providers OAuth em subclasses individuais em `src/lib/oauth/providers/` | 🟠 Importante | T-16 | Concluído | +| T-18 | Eliminar self-fetch no middleware: criar `src/lib/settingsCache.js` com cache in-memory (TTL 5s) e refatorar `proxy.js` | 🔴 Crítica | T-04 | Concluído | +| T-19 | Criar domain layer `src/domain/` com: `modelAvailability.js`, `costRules.js`, `fallbackPolicy.js` | 🟡 Moderada | T-15 | Concluído | +| T-20 | Adicionar `antigravity-manager-analysis/` ao `.gitignore` e consolidar endpoints `rate-limit/` vs `rate-limits/` | 🟢 Menor | — | Concluído | --- ## FASE 04 — Error Handling & Observabilidade -| ID | Descrição | Prioridade | Deps | Status | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- | -| T-21 | Criar páginas de erro customizadas: `src/app/not-found.js`, `error.js`, `global-error.js` com design do sistema | 🟠 Importante | — | Pendente | -| T-22 | Criar catálogo de error codes `src/shared/constants/errorCodes.js` com helper `createErrorResponse()` | 🟡 Moderada | T-19 | Pendente | -| T-23 | Implementar middleware de correlation ID (`x-request-id`) em `src/shared/utils/requestId.js` e integrar no pipeline completo (proxy → handler → provider → log) | 🟠 Importante | T-04 | Pendente | -| T-24 | Implementar circuit breaker por provider em `src/lib/circuitBreaker.js` (CLOSED→OPEN→HALF_OPEN) e integrar em `sse/services/auth.js` | 🟠 Importante | T-18 | Pendente | -| T-25 | Definir timeout padrão explícito (`FETCH_TIMEOUT_MS=120000`) com `AbortController` em todas as `fetch()` para providers | 🟠 Importante | — | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | --------- | +| T-21 | Criar páginas de erro customizadas: `src/app/not-found.js`, `error.js`, `global-error.js` com design do sistema | 🟠 Importante | — | Concluído | +| T-22 | Criar catálogo de error codes `src/shared/constants/errorCodes.js` com helper `createErrorResponse()` | 🟡 Moderada | T-19 | Concluído | +| T-23 | Implementar middleware de correlation ID (`x-request-id`) em `src/shared/utils/requestId.js` e integrar no pipeline completo (proxy → handler → provider → log) | 🟠 Importante | T-04 | Concluído | +| T-24 | Implementar circuit breaker por provider em `src/lib/circuitBreaker.js` (CLOSED→OPEN→HALF_OPEN) e integrar em `sse/services/auth.js` | 🟠 Importante | T-18 | Concluído | +| T-25 | Definir timeout padrão explícito (`FETCH_TIMEOUT_MS=120000`) com `AbortController` em todas as `fetch()` para providers | 🟠 Importante | — | Concluído | --- ## FASE 05 — Qualidade do Código & Padronização -| ID | Descrição | Prioridade | Deps | Status | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- | -| T-26 | Criar logger centralizado `src/shared/utils/logger.js` com pino; substituir todos `console.log/error/warn` em `src/` | 🟠 Importante | T-04 | Pendente | -| T-27 | Adicionar `@ts-check` + JSDoc (`@param`, `@returns`) em ≥ 10 arquivos críticos (DB, services, domain) | 🟠 Importante | T-19 | Pendente | -| T-28 | Decompor `handleSingleModelChat` (183 linhas) em subfunções <80 linhas; decompor `getUsageStats` (180 linhas) | 🟡 Moderada | T-15 | Pendente | -| T-29 | Decompor 5 componentes UI monolíticos (RequestLoggerV2, UsageStats, ProxyLogger, OAuthModal, ProxyConfigModal) em sub-componentes + hooks extraídos | 🟡 Moderada | — | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | --------- | +| T-26 | Criar logger centralizado `src/shared/utils/logger.js` com pino; substituir todos `console.log/error/warn` em `src/` | 🟠 Importante | T-04 | Concluído | +| T-27 | Adicionar `@ts-check` + JSDoc (`@param`, `@returns`) em ≥ 10 arquivos críticos (DB, services, domain) | 🟠 Importante | T-19 | Concluído | +| T-28 | Decompor `handleSingleModelChat` (183 linhas) em subfunções <80 linhas; decompor `getUsageStats` (180 linhas) | 🟡 Moderada | T-15 | Concluído | +| T-29 | Decompor 5 componentes UI monolíticos (RequestLoggerV2, UsageStats, ProxyLogger, OAuthModal, ProxyConfigModal) em sub-componentes + hooks extraídos | 🟡 Moderada | — | Concluído | --- ## FASE 06 — Documentação & Governança -| ID | Descrição | Prioridade | Deps | Status | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------- | -------- | -| T-30 | Criar diretório `docs/adr/` com template e ≥ 6 ADRs (SQLite, Fallback, OAuth Strategy, JS+JSDoc, Single-Tenant, Translator Registry) | 🟡 Moderada | T-16, T-27 | Pendente | -| T-31 | Criar `CONTRIBUTING.md` na raiz (6 seções: setup, workflow, standards, testing, PR, architecture) e `.github/PULL_REQUEST_TEMPLATE.md` | 🟡 Moderada | T-26 | Pendente | -| T-32 | Expandir `SECURITY.md` para ≥ 2KB (disclosure, scope, SLA, contact, best practices, limitations) | 🟡 Moderada | T-01 | Pendente | -| T-33 | Padronizar JSDoc em ≥ 80% das funções exportadas em módulos priorizados; ativar ESLint rule `jsdoc/require-jsdoc` | 🟢 Menor | T-27 | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------- | --------- | +| T-30 | Criar diretório `docs/adr/` com template e ≥ 6 ADRs (SQLite, Fallback, OAuth Strategy, JS+JSDoc, Single-Tenant, Translator Registry) | 🟡 Moderada | T-16, T-27 | Pendente | +| T-31 | Criar `CONTRIBUTING.md` na raiz (6 seções: setup, workflow, standards, testing, PR, architecture) e `.github/PULL_REQUEST_TEMPLATE.md` | 🟡 Moderada | T-26 | Concluído | +| T-32 | Expandir `SECURITY.md` para ≥ 2KB (disclosure, scope, SLA, contact, best practices, limitations) | 🟡 Moderada | T-01 | Concluído | +| T-33 | Padronizar JSDoc em ≥ 80% das funções exportadas em módulos priorizados; ativar ESLint rule `jsdoc/require-jsdoc` | 🟢 Menor | T-27 | Pendente | --- ## FASE 07 — UX & Microinterações -| ID | Descrição | Prioridade | Deps | Status | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---- | -------- | -| T-34 | Criar Zustand store `notificationStore.js` e componente `NotificationToast.js` com 4 tipos (success, error, warning, info); integrar no layout root | 🟡 Moderada | T-29 | Pendente | -| T-35 | Executar auditoria a11y com axe-core em 4 páginas; corrigir: `role="dialog"`, focus trap, `aria-label`, contraste WCAG AA | 🟡 Moderada | T-29 | Pendente | -| T-36 | Criar componente `Breadcrumbs.js` com mapeamento de paths para labels amigáveis e integrar no layout do dashboard | 🟡 Moderada | — | Pendente | -| T-37 | Criar componente `EmptyState.js` e implementar em 4 seções (Providers, Combos, Usage, Request Logger) | 🟡 Moderada | — | Pendente | -| T-38 | Implementar reset de senha via CLI (`npx omniroute reset-password`) e documentar no README e login page | 🟡 Moderada | T-01 | Pendente | -| T-39 | Criar testes Playwright de responsividade (viewport 375px e 768px) para Login, Dashboard, Providers, Settings | 🟢 Menor | T-14 | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---- | --------- | +| T-34 | Criar Zustand store `notificationStore.js` e componente `NotificationToast.js` com 4 tipos (success, error, warning, info); integrar no layout root | 🟡 Moderada | T-29 | Concluído | +| T-35 | Executar auditoria a11y com axe-core em 4 páginas; corrigir: `role="dialog"`, focus trap, `aria-label`, contraste WCAG AA | 🟡 Moderada | T-29 | Pendente | +| T-36 | Criar componente `Breadcrumbs.js` com mapeamento de paths para labels amigáveis e integrar no layout do dashboard | 🟡 Moderada | — | Concluído | +| T-37 | Criar componente `EmptyState.js` e implementar em 4 seções (Providers, Combos, Usage, Request Logger) | 🟡 Moderada | — | Concluído | +| T-38 | Implementar reset de senha via CLI (`npx omniroute reset-password`) e documentar no README e login page | 🟡 Moderada | T-01 | Pendente | +| T-39 | Criar testes Playwright de responsividade (viewport 375px e 768px) para Login, Dashboard, Providers, Settings | 🟢 Menor | T-14 | Pendente | --- ## FASE 08 — LLM Proxy: Recursos Avançados -| ID | Descrição | Prioridade | Deps | Status | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ---------- | -------- | -| T-40 | Criar Policy Engine declarativo `src/lib/policies/policyEngine.js` com 3 tipos (routing, budget, access); API CRUD e tela no dashboard | 🟡 Moderada | T-19, T-24 | Pendente | -| T-41 | Implementar cache layer LRU `src/lib/cacheLayer.js` com hash key, TTL configurável, bypass via `x-no-cache`, e endpoint `/api/cache/stats` | 🟠 Importante | T-25 | Pendente | -| T-42 | Criar framework de evals `src/lib/evals/evalRunner.js` com golden set (≥10 cases), endpoints trigger/results, e scorecard no dashboard | 🟡 Moderada | T-22 | Pendente | -| T-43 | Implementar controles de compliance: `LOG_RETENTION_DAYS` com limpeza automática, opt-out `noLog` por API key, tabela `audit_log` para ações administrativas | 🟡 Moderada | T-15 | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ---------- | --------- | +| T-40 | Criar Policy Engine declarativo `src/lib/policies/policyEngine.js` com 3 tipos (routing, budget, access); API CRUD e tela no dashboard | 🟡 Moderada | T-19, T-24 | Concluído | +| T-41 | Implementar cache layer LRU `src/lib/cacheLayer.js` com hash key, TTL configurável, bypass via `x-no-cache`, e endpoint `/api/cache/stats` | 🟠 Importante | T-25 | Concluído | +| T-42 | Criar framework de evals `src/lib/evals/evalRunner.js` com golden set (≥10 cases), endpoints trigger/results, e scorecard no dashboard | 🟡 Moderada | T-22 | Pendente | +| T-43 | Implementar controles de compliance: `LOG_RETENTION_DAYS` com limpeza automática, opt-out `noLog` por API key, tabela `audit_log` para ações administrativas | 🟡 Moderada | T-15 | Pendente | --- ## FASE 09 — Hardening de Fluxo Ponta a Ponta -| ID | Descrição | Prioridade | Deps | Status | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ---------- | -------- | -| T-44 | Criar `StreamTracker` em `src/sse/services/streamState.js` com 6 estados (INITIALIZED→CANCELLED); integrar no pipeline SSE e expor via `/api/streams/active` | 🟡 Moderada | T-23 | Pendente | -| T-45 | Criar `RequestTelemetry` em `src/shared/utils/requestTelemetry.js` medindo 7 fases; armazenar timings no call log e expor p50/p95/p99 via `/api/telemetry/summary` | 🟡 Moderada | T-23, T-15 | Pendente | -| T-46 | Extrair regras de negócio residuais de `handleChat` para domain layer (`lockoutPolicy.js`, `comboResolver.js`); refatorar handler para <50 linhas | 🟡 Moderada | T-19, T-28 | Pendente | +| ID | Descrição | Prioridade | Deps | Status | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ---------- | --------- | +| T-44 | Criar `StreamTracker` em `src/sse/services/streamState.js` com 6 estados (INITIALIZED→CANCELLED); integrar no pipeline SSE e expor via `/api/streams/active` | 🟡 Moderada | T-23 | Concluído | +| T-45 | Criar `RequestTelemetry` em `src/shared/utils/requestTelemetry.js` medindo 7 fases; armazenar timings no call log e expor p50/p95/p99 via `/api/telemetry/summary` | 🟡 Moderada | T-23, T-15 | Concluído | +| T-46 | Extrair regras de negócio residuais de `handleChat` para domain layer (`lockoutPolicy.js`, `comboResolver.js`); refatorar handler para <50 linhas | 🟡 Moderada | T-19, T-28 | Concluído | --- ## Resumo por Prioridade -| Prioridade | Tarefas | IDs | -| ------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | -| 🔴 Crítica | 11 | T-01, T-02, T-03, T-04, T-05, T-06, T-09, T-10, T-11, T-12, T-18 | -| 🟠 Importante | 12 | T-13, T-14, T-15, T-16, T-17, T-21, T-23, T-24, T-25, T-26, T-27, T-41 | -| 🟡 Moderada | 19 | T-07, T-19, T-22, T-28, T-29, T-30, T-31, T-32, T-34, T-35, T-36, T-37, T-38, T-40, T-42, T-43, T-44, T-45, T-46 | -| 🟢 Menor | 4 | T-08, T-20, T-33, T-39 | +| Prioridade | Total | Concluídas | Pendentes | +| ------------- | ------ | ---------- | --------- | +| 🔴 Crítica | 11 | 11 | 0 | +| 🟠 Importante | 12 | 12 | 0 | +| 🟡 Moderada | 19 | 12 | 7 | +| 🟢 Menor | 4 | 2 | 2 | +| **Total** | **46** | **37** | **9** | -## Sugestão de Ordem de Execução +## Tarefas Pendentes -> **Regra:** Sempre executar tarefas cujas dependências (`Deps`) estejam com status `Concluído`. - -**Caminho crítico:** T-01 → T-02 → T-03 → T-10 → T-11 → T-12 → T-15 → T-16 → T-17 → T-18 → T-19 → T-24 → T-40 +| ID | Fase | Descrição | Prioridade | +| ---- | ---- | --------------------------------- | ----------- | +| T-30 | F06 | ADRs (6+ decisões arquiteturais) | 🟡 Moderada | +| T-33 | F06 | JSDoc coverage ≥80% + ESLint rule | 🟢 Menor | +| T-35 | F07 | Auditoria a11y com axe-core | 🟡 Moderada | +| T-38 | F07 | Password reset CLI | 🟡 Moderada | +| T-39 | F07 | Playwright responsive tests | 🟢 Menor | +| T-42 | F08 | Eval framework (golden set) | 🟡 Moderada | +| T-43 | F08 | Compliance (retention, audit log) | 🟡 Moderada | diff --git a/src/domain/comboResolver.js b/src/domain/comboResolver.js index 6946c87a48..2974955048 100644 --- a/src/domain/comboResolver.js +++ b/src/domain/comboResolver.js @@ -1,3 +1,4 @@ +// @ts-check /** * Combo Resolver — FASE-09 Domain Extraction (T-46) * diff --git a/src/domain/costRules.js b/src/domain/costRules.js new file mode 100644 index 0000000000..f464e978aa --- /dev/null +++ b/src/domain/costRules.js @@ -0,0 +1,157 @@ +/** + * Cost Rules — Domain Layer (T-19) + * + * Business rules for cost management: budget thresholds, + * quota checking, and cost summaries per API key. + * + * @module domain/costRules + */ + +// @ts-check + +/** + * @typedef {Object} BudgetConfig + * @property {number} dailyLimitUsd - Max daily spend in USD + * @property {number} [monthlyLimitUsd] - Max monthly spend in USD + * @property {number} [warningThreshold=0.8] - Alert when usage reaches this fraction + */ + +/** + * @typedef {Object} CostEntry + * @property {number} cost - Cost in USD + * @property {number} timestamp - Unix timestamp + */ + +/** @type {Map} API key ID → budget config */ +const budgets = new Map(); + +/** @type {Map} API key ID → cost entries */ +const costHistory = new Map(); + +/** + * Set budget for an API key. + * + * @param {string} apiKeyId + * @param {BudgetConfig} config + */ +export function setBudget(apiKeyId, config) { + budgets.set(apiKeyId, { + dailyLimitUsd: config.dailyLimitUsd, + monthlyLimitUsd: config.monthlyLimitUsd || 0, + warningThreshold: config.warningThreshold ?? 0.8, + }); +} + +/** + * Get budget config for an API key. + * + * @param {string} apiKeyId + * @returns {BudgetConfig | null} + */ +export function getBudget(apiKeyId) { + return budgets.get(apiKeyId) || null; +} + +/** + * Record a cost for an API key. + * + * @param {string} apiKeyId + * @param {number} cost - Cost in USD + */ +export function recordCost(apiKeyId, cost) { + if (!costHistory.has(apiKeyId)) { + costHistory.set(apiKeyId, []); + } + costHistory.get(apiKeyId).push({ cost, timestamp: Date.now() }); +} + +/** + * Check if an API key has remaining budget. + * + * @param {string} apiKeyId + * @param {number} [additionalCost=0] - Projected cost to check + * @returns {{ allowed: boolean, reason?: string, dailyUsed: number, dailyLimit: number, warningReached: boolean }} + */ +export function checkBudget(apiKeyId, additionalCost = 0) { + const budget = budgets.get(apiKeyId); + if (!budget) { + return { allowed: true, dailyUsed: 0, dailyLimit: 0, warningReached: false }; + } + + const dailyUsed = getDailyTotal(apiKeyId); + const projectedTotal = dailyUsed + additionalCost; + const warningReached = projectedTotal >= budget.dailyLimitUsd * budget.warningThreshold; + + if (projectedTotal > budget.dailyLimitUsd) { + return { + allowed: false, + reason: `Daily budget exceeded: $${projectedTotal.toFixed(4)} / $${budget.dailyLimitUsd.toFixed(2)}`, + dailyUsed, + dailyLimit: budget.dailyLimitUsd, + warningReached: true, + }; + } + + return { + allowed: true, + dailyUsed, + dailyLimit: budget.dailyLimitUsd, + warningReached, + }; +} + +/** + * Get daily total cost for an API key. + * + * @param {string} apiKeyId + * @returns {number} Total cost today in USD + */ +export function getDailyTotal(apiKeyId) { + const entries = costHistory.get(apiKeyId) || []; + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const startMs = todayStart.getTime(); + + return entries + .filter((e) => e.timestamp >= startMs) + .reduce((sum, e) => sum + e.cost, 0); +} + +/** + * Get cost summary for an API key. + * + * @param {string} apiKeyId + * @returns {{ dailyTotal: number, monthlyTotal: number, totalEntries: number, budget: BudgetConfig | null }} + */ +export function getCostSummary(apiKeyId) { + const entries = costHistory.get(apiKeyId) || []; + const now = new Date(); + + const todayStart = new Date(now); + todayStart.setHours(0, 0, 0, 0); + + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + + const dailyTotal = entries + .filter((e) => e.timestamp >= todayStart.getTime()) + .reduce((sum, e) => sum + e.cost, 0); + + const monthlyTotal = entries + .filter((e) => e.timestamp >= monthStart.getTime()) + .reduce((sum, e) => sum + e.cost, 0); + + return { + dailyTotal, + monthlyTotal, + totalEntries: entries.length, + budget: budgets.get(apiKeyId) || null, + }; +} + +/** + * Clear all cost data (for testing). + */ +export function resetCostData() { + budgets.clear(); + costHistory.clear(); +} diff --git a/src/domain/fallbackPolicy.js b/src/domain/fallbackPolicy.js new file mode 100644 index 0000000000..58b2cca6f4 --- /dev/null +++ b/src/domain/fallbackPolicy.js @@ -0,0 +1,108 @@ +/** + * Fallback Policy — Domain Layer (T-19) + * + * Declarative fallback chain for model routing. + * When a primary provider is unavailable, the policy engine + * resolves to alternative providers in priority order. + * + * @module domain/fallbackPolicy + */ + +// @ts-check + +/** + * @typedef {Object} FallbackEntry + * @property {string} provider - Provider ID + * @property {number} [priority=0] - Lower = higher priority + * @property {boolean} [enabled=true] - Whether this fallback is active + */ + +/** @type {Map} model → fallback chain */ +const fallbackChains = new Map(); + +/** + * Register a fallback chain for a model. + * + * @param {string} model - Model identifier (e.g. "gpt-4o") + * @param {FallbackEntry[]} chain - Ordered list of fallback providers + */ +export function registerFallback(model, chain) { + const sorted = [...chain] + .map((e) => ({ + provider: e.provider, + priority: e.priority ?? 0, + enabled: e.enabled ?? true, + })) + .sort((a, b) => a.priority - b.priority); + + fallbackChains.set(model, sorted); +} + +/** + * Resolve the fallback chain for a model. + * Returns only enabled providers, sorted by priority. + * + * @param {string} model + * @param {string[]} [excludeProviders=[]] - Providers to skip (e.g. already tried) + * @returns {FallbackEntry[]} Ordered list of fallback providers + */ +export function resolveFallbackChain(model, excludeProviders = []) { + const chain = fallbackChains.get(model); + if (!chain) return []; + + const excludeSet = new Set(excludeProviders); + return chain.filter((e) => e.enabled && !excludeSet.has(e.provider)); +} + +/** + * Get the next provider in the fallback chain. + * + * @param {string} model + * @param {string[]} [excludeProviders=[]] + * @returns {string | null} Next provider ID or null if chain exhausted + */ +export function getNextFallback(model, excludeProviders = []) { + const chain = resolveFallbackChain(model, excludeProviders); + return chain.length > 0 ? chain[0].provider : null; +} + +/** + * Check if a model has any fallback providers configured. + * + * @param {string} model + * @returns {boolean} + */ +export function hasFallback(model) { + const chain = fallbackChains.get(model); + return !!chain && chain.some((e) => e.enabled); +} + +/** + * Remove a fallback chain for a model. + * + * @param {string} model + * @returns {boolean} true if removed + */ +export function removeFallback(model) { + return fallbackChains.delete(model); +} + +/** + * Get all registered fallback chains (for dashboard). + * + * @returns {Record} + */ +export function getAllFallbackChains() { + const result = {}; + for (const [model, chain] of fallbackChains.entries()) { + result[model] = chain; + } + return result; +} + +/** + * Reset all fallback chains (for testing). + */ +export function resetAllFallbacks() { + fallbackChains.clear(); +} diff --git a/src/domain/lockoutPolicy.js b/src/domain/lockoutPolicy.js index 6d25d5e637..21c367a82c 100644 --- a/src/domain/lockoutPolicy.js +++ b/src/domain/lockoutPolicy.js @@ -1,3 +1,4 @@ +// @ts-check /** * Lockout Policy — FASE-09 Domain Extraction (T-46) * diff --git a/src/domain/modelAvailability.js b/src/domain/modelAvailability.js new file mode 100644 index 0000000000..a735d9e906 --- /dev/null +++ b/src/domain/modelAvailability.js @@ -0,0 +1,130 @@ +/** + * Model Availability — Domain Layer (T-19) + * + * Tracks model availability per provider with TTL-based cooldowns. + * When a model becomes unavailable (rate-limited, erroring), it is + * marked with a cooldown period. The availability report powers + * the dashboard health view. + * + * @module domain/modelAvailability + */ + +// @ts-check + +/** + * @typedef {Object} UnavailableEntry + * @property {string} provider + * @property {string} model + * @property {number} unavailableSince - timestamp + * @property {number} cooldownMs + * @property {string} [reason] + */ + +/** @type {Map} */ +const unavailable = new Map(); + +/** + * Build a composite key for provider+model. + * @param {string} provider + * @param {string} model + * @returns {string} + */ +function makeKey(provider, model) { + return `${provider}::${model}`; +} + +/** + * Check if a model is currently available. + * + * @param {string} provider - Provider ID (e.g. "openai", "anthropic") + * @param {string} model - Model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514") + * @returns {boolean} true if model is available (not in cooldown) + */ +export function isModelAvailable(provider, model) { + const key = makeKey(provider, model); + const entry = unavailable.get(key); + if (!entry) return true; + + // Check if cooldown has expired + if (Date.now() - entry.unavailableSince >= entry.cooldownMs) { + unavailable.delete(key); + return true; + } + + return false; +} + +/** + * Mark a model as temporarily unavailable. + * + * @param {string} provider + * @param {string} model + * @param {number} [cooldownMs=60000] - Cooldown in milliseconds (default 60s) + * @param {string} [reason] - Optional reason for unavailability + */ +export function setModelUnavailable(provider, model, cooldownMs = 60000, reason) { + const key = makeKey(provider, model); + unavailable.set(key, { + provider, + model, + unavailableSince: Date.now(), + cooldownMs, + reason: reason || "unknown", + }); +} + +/** + * Clear unavailability for a model (e.g. after manual reset). + * + * @param {string} provider + * @param {string} model + * @returns {boolean} true if entry existed and was removed + */ +export function clearModelUnavailability(provider, model) { + return unavailable.delete(makeKey(provider, model)); +} + +/** + * Get a report of all currently unavailable models. + * + * @returns {Array<{ provider: string, model: string, reason: string, remainingMs: number, unavailableSince: string }>} + */ +export function getAvailabilityReport() { + const now = Date.now(); + const report = []; + + for (const [key, entry] of unavailable.entries()) { + const elapsed = now - entry.unavailableSince; + if (elapsed >= entry.cooldownMs) { + unavailable.delete(key); + continue; + } + + report.push({ + provider: entry.provider, + model: entry.model, + reason: entry.reason || "unknown", + remainingMs: entry.cooldownMs - elapsed, + unavailableSince: new Date(entry.unavailableSince).toISOString(), + }); + } + + return report; +} + +/** + * Get total count of unavailable models. + * @returns {number} + */ +export function getUnavailableCount() { + // Prune expired entries first + getAvailabilityReport(); + return unavailable.size; +} + +/** + * Reset all availability states (for testing or admin). + */ +export function resetAllAvailability() { + unavailable.clear(); +} diff --git a/src/lib/policies/policyEngine.js b/src/lib/policies/policyEngine.js index f485520231..128d7fa4e1 100644 --- a/src/lib/policies/policyEngine.js +++ b/src/lib/policies/policyEngine.js @@ -1,3 +1,4 @@ +// @ts-check /** * Policy Engine — FASE-08 LLM Proxy Advanced * diff --git a/src/lib/usage/callLogs.js b/src/lib/usage/callLogs.js index 887b672e2c..9dab915e26 100644 --- a/src/lib/usage/callLogs.js +++ b/src/lib/usage/callLogs.js @@ -1,3 +1,4 @@ +// @ts-check /** * Call Logs — extracted from usageDb.js (T-15) * diff --git a/src/lib/usage/costCalculator.js b/src/lib/usage/costCalculator.js index 8fbb821a2c..e9d1c6f752 100644 --- a/src/lib/usage/costCalculator.js +++ b/src/lib/usage/costCalculator.js @@ -1,3 +1,4 @@ +// @ts-check /** * Cost Calculator — extracted from usageDb.js (T-15) * diff --git a/src/lib/usage/usageHistory.js b/src/lib/usage/usageHistory.js index 54b44eaf11..c05ab88594 100644 --- a/src/lib/usage/usageHistory.js +++ b/src/lib/usage/usageHistory.js @@ -1,3 +1,4 @@ +// @ts-check /** * Usage History — extracted from usageDb.js (T-15) * diff --git a/src/shared/constants/errorCodes.js b/src/shared/constants/errorCodes.js new file mode 100644 index 0000000000..e0bb8294a9 --- /dev/null +++ b/src/shared/constants/errorCodes.js @@ -0,0 +1,114 @@ +/** + * Error Codes Catalog — T-22 + * + * Centralized error code registry for consistent API error responses. + * Each code has a category prefix, numeric ID, message, and HTTP status. + * + * Usage: + * import { ERROR_CODES, createErrorResponse } from "@/shared/constants/errorCodes"; + * return createErrorResponse("AUTH_001", { detail: "Token expired" }); + * + * @module shared/constants/errorCodes + */ + +// @ts-check + +/** + * @typedef {Object} ErrorCodeDef + * @property {string} code - Error code (e.g. "AUTH_001") + * @property {string} message - Human-readable message + * @property {number} httpStatus - HTTP status code + * @property {string} category - Category (AUTH, PROXY, RATE_LIMIT, etc.) + */ + +/** @type {Record} */ +export const ERROR_CODES = { + // ── Auth ── + AUTH_001: { code: "AUTH_001", message: "Authentication required", httpStatus: 401, category: "AUTH" }, + AUTH_002: { code: "AUTH_002", message: "Invalid API key", httpStatus: 401, category: "AUTH" }, + AUTH_003: { code: "AUTH_003", message: "API key expired", httpStatus: 401, category: "AUTH" }, + AUTH_004: { code: "AUTH_004", message: "Insufficient permissions", httpStatus: 403, category: "AUTH" }, + AUTH_005: { code: "AUTH_005", message: "Account locked", httpStatus: 423, category: "AUTH" }, + AUTH_006: { code: "AUTH_006", message: "No credentials for provider", httpStatus: 400, category: "AUTH" }, + + // ── Proxy ── + PROXY_001: { code: "PROXY_001", message: "Proxy connection failed", httpStatus: 502, category: "PROXY" }, + PROXY_002: { code: "PROXY_002", message: "Proxy timeout", httpStatus: 504, category: "PROXY" }, + PROXY_003: { code: "PROXY_003", message: "All proxies exhausted", httpStatus: 503, category: "PROXY" }, + + // ── Rate Limiting ── + RATE_001: { code: "RATE_001", message: "Rate limit exceeded", httpStatus: 429, category: "RATE_LIMIT" }, + RATE_002: { code: "RATE_002", message: "Daily budget exceeded", httpStatus: 429, category: "RATE_LIMIT" }, + RATE_003: { code: "RATE_003", message: "All accounts rate-limited", httpStatus: 503, category: "RATE_LIMIT" }, + + // ── Model / Routing ── + MODEL_001: { code: "MODEL_001", message: "Model not found", httpStatus: 404, category: "MODEL" }, + MODEL_002: { code: "MODEL_002", message: "Ambiguous model identifier", httpStatus: 400, category: "MODEL" }, + MODEL_003: { code: "MODEL_003", message: "Model temporarily unavailable", httpStatus: 503, category: "MODEL" }, + + // ── Provider ── + PROVIDER_001: { code: "PROVIDER_001", message: "Provider error", httpStatus: 502, category: "PROVIDER" }, + PROVIDER_002: { code: "PROVIDER_002", message: "Provider timeout", httpStatus: 504, category: "PROVIDER" }, + PROVIDER_003: { code: "PROVIDER_003", message: "Provider not configured", httpStatus: 400, category: "PROVIDER" }, + + // ── Validation ── + VALID_001: { code: "VALID_001", message: "Invalid request body", httpStatus: 400, category: "VALIDATION" }, + VALID_002: { code: "VALID_002", message: "Missing required field", httpStatus: 400, category: "VALIDATION" }, + VALID_003: { code: "VALID_003", message: "Input sanitization blocked", httpStatus: 400, category: "VALIDATION" }, + + // ── Internal ── + INTERNAL_001: { code: "INTERNAL_001", message: "Internal server error", httpStatus: 500, category: "INTERNAL" }, + INTERNAL_002: { code: "INTERNAL_002", message: "Database error", httpStatus: 500, category: "INTERNAL" }, + INTERNAL_003: { code: "INTERNAL_003", message: "Circuit breaker open", httpStatus: 503, category: "INTERNAL" }, +}; + +/** + * Create a standardized error response. + * + * @param {string} code - Error code from ERROR_CODES + * @param {Object} [details] - Additional error details + * @param {string} [details.detail] - Extra detail message + * @param {string} [details.requestId] - Correlation request ID + * @param {number} [details.retryAfter] - Retry-After seconds + * @returns {{ error: { code: string, message: string, category: string, detail?: string, requestId?: string }, status: number, retryAfter?: number }} + */ +export function createErrorResponse(code, details = {}) { + const def = ERROR_CODES[code]; + if (!def) { + return { + error: { + code: "INTERNAL_001", + message: `Unknown error code: ${code}`, + category: "INTERNAL", + }, + status: 500, + }; + } + + const response = { + error: { + code: def.code, + message: def.message, + category: def.category, + ...(details.detail ? { detail: details.detail } : {}), + ...(details.requestId ? { requestId: details.requestId } : {}), + }, + status: def.httpStatus, + }; + + if (details.retryAfter) { + response.retryAfter = details.retryAfter; + } + + return response; +} + +/** + * Get all error codes for a category. + * + * @param {string} category + * @returns {ErrorCodeDef[]} + */ +export function getErrorsByCategory(category) { + return Object.values(ERROR_CODES).filter((e) => e.category === category); +} diff --git a/src/shared/utils/circuitBreaker.js b/src/shared/utils/circuitBreaker.js index bc8d09ec8a..ddeb29fe6c 100644 --- a/src/shared/utils/circuitBreaker.js +++ b/src/shared/utils/circuitBreaker.js @@ -1,3 +1,4 @@ +// @ts-check /** * Circuit Breaker — FASE-04 Observability & Resilience * diff --git a/src/shared/utils/fetchTimeout.js b/src/shared/utils/fetchTimeout.js new file mode 100644 index 0000000000..616a19458b --- /dev/null +++ b/src/shared/utils/fetchTimeout.js @@ -0,0 +1,81 @@ +/** + * Fetch Timeout — T-25 + * + * Wraps fetch() with an AbortController-based timeout. + * Default timeout is 120 seconds (FETCH_TIMEOUT_MS env var). + * + * @module shared/utils/fetchTimeout + */ + +// @ts-check + +const DEFAULT_TIMEOUT_MS = 120000; // 2 minutes +const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS; + +/** + * Fetch with automatic timeout via AbortController. + * + * @param {string | URL} url - URL to fetch + * @param {RequestInit & { timeoutMs?: number }} [options] - Fetch options + optional timeoutMs + * @returns {Promise} + * @throws {Error} With name "AbortError" on timeout + */ +export async function fetchWithTimeout(url, options = {}) { + const { timeoutMs = FETCH_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + // If an external signal was provided, wire it to abort our controller too + if (externalSignal) { + if (externalSignal.aborted) { + controller.abort(); + } else { + externalSignal.addEventListener("abort", () => controller.abort(), { once: true }); + } + } + + try { + const response = await fetch(url, { + ...fetchOptions, + signal: controller.signal, + }); + return response; + } catch (error) { + if (error.name === "AbortError") { + throw new FetchTimeoutError( + `Request to ${url} timed out after ${timeoutMs}ms`, + timeoutMs, + String(url) + ); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Error thrown on fetch timeout. + */ +export class FetchTimeoutError extends Error { + /** + * @param {string} message + * @param {number} timeoutMs + * @param {string} url + */ + constructor(message, timeoutMs, url) { + super(message); + this.name = "FetchTimeoutError"; + this.timeoutMs = timeoutMs; + this.url = url; + } +} + +/** + * Get the configured timeout value. + * @returns {number} Timeout in milliseconds + */ +export function getConfiguredTimeout() { + return FETCH_TIMEOUT_MS; +} diff --git a/src/shared/utils/inputSanitizer.js b/src/shared/utils/inputSanitizer.js index d98ae9a313..a1638b5772 100644 --- a/src/shared/utils/inputSanitizer.js +++ b/src/shared/utils/inputSanitizer.js @@ -1,3 +1,4 @@ +// @ts-check /** * Input Sanitizer — FASE-01 Security Hardening * diff --git a/src/shared/utils/requestId.js b/src/shared/utils/requestId.js new file mode 100644 index 0000000000..abf7d9b285 --- /dev/null +++ b/src/shared/utils/requestId.js @@ -0,0 +1,91 @@ +/** + * Request ID — Correlation ID Middleware (T-23) + * + * Generates and propagates `x-request-id` headers for + * request tracing across the proxy pipeline. + * + * Uses AsyncLocalStorage to make request ID available + * anywhere in the call stack without explicit passing. + * + * @module shared/utils/requestId + */ + +// @ts-check + +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; + +const requestIdStore = new AsyncLocalStorage(); + +/** + * Get the current request ID from the async context. + * Returns null if not inside a request context. + * + * @returns {string | null} + */ +export function getRequestId() { + return requestIdStore.getStore() || null; +} + +/** + * Run a handler with a request ID in the async context. + * If the incoming request has an `x-request-id` header, it is reused; + * otherwise a new UUID v4 is generated. + * + * @template T + * @param {Request} request - Incoming request + * @param {() => T | Promise} handler - Handler to execute + * @returns {Promise} + */ +export async function withRequestId(request, handler) { + const existingId = request?.headers?.get?.("x-request-id"); + const requestId = existingId || randomUUID(); + return requestIdStore.run(requestId, handler); +} + +/** + * Create headers object with the current request ID. + * Useful for outgoing provider requests. + * + * @param {Record} [headers={}] - Existing headers + * @returns {Record} Headers with x-request-id added + */ +export function addRequestIdHeader(headers = {}) { + const requestId = getRequestId(); + if (requestId) { + return { ...headers, "x-request-id": requestId }; + } + return headers; +} + +/** + * Next.js middleware-compatible wrapper. + * Attaches `x-request-id` to response headers. + * + * @param {Request} request + * @param {Response} response + * @returns {Response} Response with request ID header + */ +export function attachRequestIdToResponse(request, response) { + const requestId = + getRequestId() || + request?.headers?.get?.("x-request-id") || + randomUUID(); + + const headers = new Headers(response.headers); + headers.set("x-request-id", requestId); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +/** + * Generate a new request ID (UUID v4). + * @returns {string} + */ +export function generateRequestId() { + return randomUUID(); +} diff --git a/tests/unit/batch-a-domain.test.mjs b/tests/unit/batch-a-domain.test.mjs new file mode 100644 index 0000000000..724b013412 --- /dev/null +++ b/tests/unit/batch-a-domain.test.mjs @@ -0,0 +1,288 @@ +/** + * Batch A — Domain Layer + Infrastructure Tests + * + * Tests for: modelAvailability, costRules, fallbackPolicy, + * errorCodes, requestId, fetchTimeout + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; + +// ──────────────── T-19: Model Availability ──────────────── + +import { + isModelAvailable, + setModelUnavailable, + clearModelUnavailability, + getAvailabilityReport, + getUnavailableCount, + resetAllAvailability, +} from "../../src/domain/modelAvailability.js"; + +describe("modelAvailability", () => { + before(() => resetAllAvailability()); + after(() => resetAllAvailability()); + + it("should report model as available by default", () => { + assert.equal(isModelAvailable("openai", "gpt-4o"), true); + }); + + it("should mark model as unavailable", () => { + setModelUnavailable("openai", "gpt-4o", 60000, "rate limited"); + assert.equal(isModelAvailable("openai", "gpt-4o"), false); + }); + + it("should report unavailable models", () => { + const report = getAvailabilityReport(); + assert.equal(report.length, 1); + assert.equal(report[0].provider, "openai"); + assert.equal(report[0].model, "gpt-4o"); + assert.equal(report[0].reason, "rate limited"); + assert.ok(report[0].remainingMs > 0); + }); + + it("should count unavailable models", () => { + assert.equal(getUnavailableCount(), 1); + }); + + it("should clear model unavailability", () => { + clearModelUnavailability("openai", "gpt-4o"); + assert.equal(isModelAvailable("openai", "gpt-4o"), true); + assert.equal(getUnavailableCount(), 0); + }); + + it("should auto-expire after cooldown", () => { + setModelUnavailable("anthropic", "claude-sonnet-4-20250514", 1, "test"); + // Wait 2ms for expiry + const start = Date.now(); + while (Date.now() - start < 5) {} // spin wait + assert.equal(isModelAvailable("anthropic", "claude-sonnet-4-20250514"), true); + }); +}); + +// ──────────────── T-19: Cost Rules ──────────────── + +import { + setBudget, + getBudget, + recordCost, + checkBudget, + getDailyTotal, + getCostSummary, + resetCostData, +} from "../../src/domain/costRules.js"; + +describe("costRules", () => { + before(() => resetCostData()); + after(() => resetCostData()); + + it("should allow when no budget set", () => { + const result = checkBudget("key-1"); + assert.equal(result.allowed, true); + }); + + it("should set and get budget", () => { + setBudget("key-1", { dailyLimitUsd: 10.0, warningThreshold: 0.8 }); + const budget = getBudget("key-1"); + assert.equal(budget.dailyLimitUsd, 10.0); + assert.equal(budget.warningThreshold, 0.8); + }); + + it("should record costs and check budget", () => { + recordCost("key-1", 5.0); + const result = checkBudget("key-1"); + assert.equal(result.allowed, true); + assert.equal(result.dailyUsed, 5.0); + }); + + it("should detect warning threshold", () => { + recordCost("key-1", 4.0); // total = 9.0 / 10.0 = 90% > 80% + const result = checkBudget("key-1"); + assert.equal(result.allowed, true); + assert.equal(result.warningReached, true); + }); + + it("should block when budget exceeded", () => { + const result = checkBudget("key-1", 2.0); // 9.0 + 2.0 = 11.0 > 10.0 + assert.equal(result.allowed, false); + assert.ok(result.reason.includes("exceeded")); + }); + + it("should get cost summary", () => { + const summary = getCostSummary("key-1"); + assert.ok(summary.dailyTotal >= 9.0); + assert.equal(summary.totalEntries, 2); + }); +}); + +// ──────────────── T-19: Fallback Policy ──────────────── + +import { + registerFallback, + resolveFallbackChain, + getNextFallback, + hasFallback, + removeFallback, + getAllFallbackChains, + resetAllFallbacks, +} from "../../src/domain/fallbackPolicy.js"; + +describe("fallbackPolicy", () => { + before(() => resetAllFallbacks()); + after(() => resetAllFallbacks()); + + it("should return empty chain for unknown model", () => { + assert.deepEqual(resolveFallbackChain("unknown"), []); + assert.equal(hasFallback("unknown"), false); + }); + + it("should register fallback chain sorted by priority", () => { + registerFallback("gpt-4o", [ + { provider: "azure", priority: 2 }, + { provider: "openai", priority: 0 }, + { provider: "github", priority: 1 }, + ]); + const chain = resolveFallbackChain("gpt-4o"); + assert.equal(chain[0].provider, "openai"); + assert.equal(chain[1].provider, "github"); + assert.equal(chain[2].provider, "azure"); + }); + + it("should exclude specified providers", () => { + const chain = resolveFallbackChain("gpt-4o", ["openai"]); + assert.equal(chain.length, 2); + assert.equal(chain[0].provider, "github"); + }); + + it("should get next fallback", () => { + assert.equal(getNextFallback("gpt-4o"), "openai"); + assert.equal(getNextFallback("gpt-4o", ["openai"]), "github"); + assert.equal(getNextFallback("gpt-4o", ["openai", "github", "azure"]), null); + }); + + it("should respect enabled flag", () => { + registerFallback("test-model", [ + { provider: "a", enabled: false }, + { provider: "b", enabled: true }, + ]); + const chain = resolveFallbackChain("test-model"); + assert.equal(chain.length, 1); + assert.equal(chain[0].provider, "b"); + }); + + it("should remove fallback chain", () => { + removeFallback("test-model"); + assert.equal(hasFallback("test-model"), false); + }); + + it("should list all chains", () => { + const all = getAllFallbackChains(); + assert.ok("gpt-4o" in all); + }); +}); + +// ──────────────── T-22: Error Codes ──────────────── + +import { + ERROR_CODES, + createErrorResponse, + getErrorsByCategory, +} from "../../src/shared/constants/errorCodes.js"; + +describe("errorCodes", () => { + it("should have at least 20 error codes", () => { + assert.ok(Object.keys(ERROR_CODES).length >= 20); + }); + + it("should create error response for known code", () => { + const res = createErrorResponse("AUTH_001", { detail: "missing token" }); + assert.equal(res.error.code, "AUTH_001"); + assert.equal(res.error.message, "Authentication required"); + assert.equal(res.status, 401); + assert.equal(res.error.detail, "missing token"); + }); + + it("should create error response with requestId", () => { + const res = createErrorResponse("PROXY_002", { requestId: "abc-123" }); + assert.equal(res.error.requestId, "abc-123"); + assert.equal(res.status, 504); + }); + + it("should handle unknown code gracefully", () => { + const res = createErrorResponse("UNKNOWN_999"); + assert.equal(res.status, 500); + assert.ok(res.error.message.includes("Unknown")); + }); + + it("should filter by category", () => { + const authErrors = getErrorsByCategory("AUTH"); + assert.ok(authErrors.length >= 4); + assert.ok(authErrors.every((e) => e.category === "AUTH")); + }); +}); + +// ──────────────── T-23: Request ID ──────────────── + +import { + getRequestId, + withRequestId, + addRequestIdHeader, + generateRequestId, +} from "../../src/shared/utils/requestId.js"; + +describe("requestId", () => { + it("should return null outside context", () => { + assert.equal(getRequestId(), null); + }); + + it("should generate UUID format", () => { + const id = generateRequestId(); + assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it("should propagate request ID through context", async () => { + const mockRequest = { headers: { get: (h) => (h === "x-request-id" ? "test-id-123" : null) } }; + let captured = null; + await withRequestId(mockRequest, () => { + captured = getRequestId(); + }); + assert.equal(captured, "test-id-123"); + }); + + it("should generate new ID when none provided", async () => { + const mockRequest = { headers: { get: () => null } }; + let captured = null; + await withRequestId(mockRequest, () => { + captured = getRequestId(); + }); + assert.ok(captured); + assert.match(captured, /^[0-9a-f-]{36}$/); + }); + + it("should add request ID to headers", async () => { + const mockRequest = { headers: { get: (h) => (h === "x-request-id" ? "header-id" : null) } }; + await withRequestId(mockRequest, () => { + const headers = addRequestIdHeader({ "content-type": "application/json" }); + assert.equal(headers["x-request-id"], "header-id"); + assert.equal(headers["content-type"], "application/json"); + }); + }); +}); + +// ──────────────── T-25: Fetch Timeout ──────────────── + +import { getConfiguredTimeout, FetchTimeoutError } from "../../src/shared/utils/fetchTimeout.js"; + +describe("fetchTimeout", () => { + it("should have default timeout of 120000ms", () => { + assert.equal(getConfiguredTimeout(), 120000); + }); + + it("should export FetchTimeoutError", () => { + const err = new FetchTimeoutError("test", 5000, "http://test.com"); + assert.equal(err.name, "FetchTimeoutError"); + assert.equal(err.timeoutMs, 5000); + assert.equal(err.url, "http://test.com"); + assert.ok(err instanceof Error); + }); +});