mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
Merge pull request #24 from diegosouzapw/feature/batch-a-domain-infra
feat: domain layer, error codes, request ID, fetch timeout (Batch A)
This commit is contained in:
155
docs/TASKS.md
155
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 |
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Combo Resolver — FASE-09 Domain Extraction (T-46)
|
||||
*
|
||||
|
||||
157
src/domain/costRules.js
Normal file
157
src/domain/costRules.js
Normal file
@@ -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<string, BudgetConfig>} API key ID → budget config */
|
||||
const budgets = new Map();
|
||||
|
||||
/** @type {Map<string, CostEntry[]>} 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();
|
||||
}
|
||||
108
src/domain/fallbackPolicy.js
Normal file
108
src/domain/fallbackPolicy.js
Normal file
@@ -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<string, FallbackEntry[]>} 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<string, FallbackEntry[]>}
|
||||
*/
|
||||
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();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Lockout Policy — FASE-09 Domain Extraction (T-46)
|
||||
*
|
||||
|
||||
130
src/domain/modelAvailability.js
Normal file
130
src/domain/modelAvailability.js
Normal file
@@ -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<string, UnavailableEntry>} */
|
||||
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();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Policy Engine — FASE-08 LLM Proxy Advanced
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Call Logs — extracted from usageDb.js (T-15)
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Cost Calculator — extracted from usageDb.js (T-15)
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Usage History — extracted from usageDb.js (T-15)
|
||||
*
|
||||
|
||||
114
src/shared/constants/errorCodes.js
Normal file
114
src/shared/constants/errorCodes.js
Normal file
@@ -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<string, ErrorCodeDef>} */
|
||||
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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Circuit Breaker — FASE-04 Observability & Resilience
|
||||
*
|
||||
|
||||
81
src/shared/utils/fetchTimeout.js
Normal file
81
src/shared/utils/fetchTimeout.js
Normal file
@@ -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<Response>}
|
||||
* @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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Input Sanitizer — FASE-01 Security Hardening
|
||||
*
|
||||
|
||||
91
src/shared/utils/requestId.js
Normal file
91
src/shared/utils/requestId.js
Normal file
@@ -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<T>} handler - Handler to execute
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
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<string, string>} [headers={}] - Existing headers
|
||||
* @returns {Record<string, string>} 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();
|
||||
}
|
||||
288
tests/unit/batch-a-domain.test.mjs
Normal file
288
tests/unit/batch-a-domain.test.mjs
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user