# Extensions (Ramais) Primeira fase que dá dado real de negócio ao FreeSWITCH via `mod_xml_curl` (agente.md secao 39-40, 178). ## Modelo `extensions` (tenant-scoped, RLS — mesmo padrão de `tenant_memberships`): `number`, `name`, `domain`, `sip_password_enc`, `caller_id_name/number`, `context`, `sofia_profile`, `codecs`, `max_registrations`, `enabled`. `UNIQUE(tenant_id, number)` — números só precisam ser únicos dentro do tenant (secao 34). ## Senha SIP (secao 178) - Gerada com `generateStrongPassword()` (24 caracteres alfanuméricos, `crypto.randomBytes`). - Cifrada em repouso com AES-256-GCM (`packages/shared/src/crypto.ts`), chave em `ENCRYPTION_KEY` (.env, 32 bytes hex) — nunca no PostgreSQL. - **Só aparece em texto puro na resposta do `POST /extensions`, uma única vez.** `GET`/`list` nunca devolvem `sipPasswordEnc` nem a senha — a função `toPublicExtension()` faz destructuring explícito do campo (não spread) pra garantir isso é removido de fato, não só "esquecido" no tipo TypeScript. ## API (`apps/api/src/extensions`) ``` POST /extensions extensions.manage cria (gera+cifra senha, devolve 1x) GET /extensions extensions.view lista (sem senha) GET /extensions/:id extensions.view detalhe (sem senha) DELETE /extensions/:id extensions.manage soft delete (deletedAt + enabled=false) ``` Novo `PermissionGuard` genérico (`@RequirePermission('extensions.manage')`) — roda depois do `JwtAuthGuard`, exige tenant selecionado no JWT (nunca aceita tenant_id do client) e chama `userHasPermission()` de `packages/auth`. **Achado real durante os testes**: o `PermissionGuard` injeta `Reflector` via construtor — padrão documentado do NestJS. Rodando via `tsx` (esbuild), o `reflector` chegava `undefined` em runtime (`TypeError: Cannot read properties of undefined`), porque esbuild não faz emissão de `design:paramtypes` com checagem de tipos completa entre arquivos (limitação conhecida do esbuild, diferente do `tsc`). Resolvido com `@Inject(Reflector)` explícito no construtor. Isso é um risco real pra qualquer guard/serviço futuro que dependa de injeção implícita de tipo — **usar `@Inject()` explícito sempre que o dev/runtime for via `tsx`**, ou considerar migrar `apps/api` pra build real (`tsc`) mais adiante. **Atualização (fase Dialplan)**: esse "considerar migrar" virou obrigatório — o mesmo problema de metadata do esbuild também desativava silenciosamente o `ValidationPipe` inteiro (sem crash, sem log, só aceitando qualquer entrada). `apps/api` agora sempre builda com `tsc` antes de rodar. Ver docs/VALIDATION_PIPE_BUG.md. ## `b2bcall-fs-config` agora responde directory de verdade Fluxo `section === "directory"`: 1. `Tenant.findFirst({ telephonyDomain: domain, status: "ACTIVE" })` — tenants não são RLS-protected (é o registro da plataforma). 2. `withTenantContext(tenant.id) → Extension.findFirst({ number: user, enabled: true })`. 3. `buildDirectoryUserXml()` (`packages/telephony`) monta o XML, incluindo `b2bcall_tenant_id`/`b2bcall_extension_id` como channel variables (secao 81 — assim qualquer chamada desse ramal já carrega a origem). **Achado real**: a primeira versão do XML não incluía o bloco `` que a config vanilla tem em `directory/default.xml`. Sem isso, `originate user/1500 &park()` falhava com `MANDATORY_IE_MISSING` em vez do `USER_NOT_REGISTERED` esperado — o FreeSWITCH não sabia montar o dialstring pro endpoint `user/`. Corrigido copiando o mesmo template de `dial-string` da config vanilla. ## `$${domain}` fixo `Tenant.telephonyDomain` precisa bater com o que o FreeSWITCH manda como `domain` no POST do xml_curl. Por padrão, a config vanilla usa `domain=$${local_ip_v4}` — o IP do container, que muda a cada restart e nunca seria estável o suficiente pra configurar em um tenant. Corrigido no `Dockerfile` do FreeSWITCH com um `sed` fixando `$${domain}` pra `b2bcall.local` (configurável via `ARG DEFAULT_SIP_DOMAIN`). Multi-domínio real por tenant (múltiplos domínios simultâneos, um por tenant) ainda não está resolvido — hoje só suporta um domínio fixo pra todos; isso é uma limitação genuína a resolver quando existir gestão de domínio por tenant de verdade (fora do escopo desta fase). ## Verificado ponta a ponta ```bash POST /extensions {"number":"1500","name":"Ramal de Teste"} # 201, senha aparece 1x GET /extensions/:id # confirma senha nunca reaparece # fs-config resolve com a senha certa (decifrada corretamente): curl -d "section=directory&user=1500&domain=b2bcall.local" http://fs-config:8080/ # FreeSWITCH: originate user/1500 &park() # USER_NOT_REGISTERED (achou o ramal, sem telefone registrado) originate user/1501 &park() # SUBSCRIBER_ABSENT (nao existe) DELETE /extensions/:id originate user/1500 &park() # volta a SUBSCRIBER_ABSENT ``` ## Autenticação HTTP `fs-config` ↔ FreeSWITCH Adicionada nesta mesma fase (assim que o serviço passou a devolver dados reais, deixou de ser opcional): HTTP Basic, credenciais em `FS_CONFIG_USER`/ `FS_CONFIG_PASSWORD` (.env, geradas com `openssl rand`). FreeSWITCH manda via `gateway-credentials` em `xml_curl.conf.xml` (substituído em runtime pelo `entrypoint.sh`, mesmo padrão do `ESL_PASSWORD` — nunca fica na imagem). `fs-config` compara com `timingSafeEqual` (evita timing attack), libera só `/health` sem auth (usado pelo healthcheck do Docker). Verificado: requisição sem credenciais recebe 401; o FreeSWITCH (com `gateway-credentials` configurado) continua funcionando normalmente. ## O que falta - Quota de ramais (`max_extensions` do plano, secao 57) — depende da fase Plans/Entitlements, que ainda não existe. - Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante. - Multi-domínio real por tenant (ver acima).