feat: implement Dialplan with structured editor and versioning

- dialplan_extensions table (tenant-scoped, RLS): structured editor per
  agente.md secao 43 -- context, condition field/expr, actions/anti-actions
  (JSON), continue, order, enabled. One condition per extension (deliberate
  simplification vs raw FreeSWITCH's multi-condition extensions).
- dialplan_versions table (tenant-scoped, RLS): generate/validate/version/
  activate flow (secao 44). Reactivating an older version IS the rollback
  mechanism -- no separate endpoint needed.
- apps/api/src/dialplan: extensions CRUD + versions/generate (builds XML,
  validates well-formedness with fast-xml-parser, saves as DRAFT) +
  versions/:id/activate (atomically flips ACTIVE, supersedes the previous
  one). Reused freeswitch.view/.configure permissions rather than inventing
  new ones not in the agente.md permission list.
- packages/telephony: buildDialplanXml() plus ALLOWED_DIALPLAN_APPLICATIONS,
  an explicit allowlist (answer/bridge/playback/hangup/set/export/... --
  deliberately no system/exec/socket) guarding against a tenant configuring
  a dialplan action that runs arbitrary commands on the FreeSWITCH host
  (agente.md secao 180)
- b2bcall-fs-config resolves dialplan dynamically per call (unlike Trunks'
  file+rescan approach -- dialplan is fetched fresh via mod_xml_curl on
  every call anyway) by tenant id from the variable_b2bcall_tenant_id
  channel variable already injected at directory resolution, then serving
  whichever DialplanVersion is ACTIVE for that context
- verified end-to-end: created a rule for destination_number 7000, generated
  and activated v1, originated a call that actually routed through the
  dialplan (not bypassing it via &app()) -- CALL_CREATED -> CALL_ANSWERED ->
  CALL_ENDED with the correct tenantId throughout. Created and activated a
  v2, then rolled back to v1 by reactivating it; status transitions
  (ACTIVE/SUPERSEDED) all confirmed via the API.

CRITICAL FINDING, fixed in this same phase: deliberately testing that the
application allowlist rejects 'system' got back 201 instead of 400 --
NestJS's ValidationPipe had been silently inert across all of apps/api's
@Body() DTOs since the API was first created. Root cause: running via
 (esbuild) instead of a real  build -- esbuild doesn't always
resolve cross-file parameter types for design:paramtypes metadata, and Nest
skips validation without any error when it can't determine the DTO class.
Fixed by always building with tsc before running (tsc && tsx dist/main.js
-- still via tsx because internal workspace packages aren't built to JS
yet). Re-verified with two deliberate bad-input tests post-fix, both
correctly rejected with 400. A stray malicious test row (dialplan action
'system') created while the bug was live was deleted; it was never baked
into an activated version, so nothing could have executed it.
See docs/VALIDATION_PIPE_BUG.md for the full writeup.

docs/DIALPLAN.md, docs/VALIDATION_PIPE_BUG.md, docs/EXTENSIONS.md updated
This commit is contained in:
2026-08-28 08:41:45 -03:00
parent 4c638ad496
commit 0720a0efe3
17 changed files with 890 additions and 6 deletions

91
docs/DIALPLAN.md Normal file
View File

@@ -0,0 +1,91 @@
# Dialplan
Agente.md secao 43-44. Editor estruturado (não textarea) + versionamento com
ativação/rollback explícitos.
## Modelo
- `dialplan_extensions` (tenant-scoped, RLS): fonte editável — `context`,
`name`, `conditionField`/`conditionExpr` (uma condição por extension,
simplificação deliberada em relação ao FreeSWITCH puro, que permite várias
`<condition>` por extension), `actions`/`antiActions` (JSON, array de
`{application, data}`), `continueOnFalse`, `order`, `enabled`.
- `dialplan_versions` (tenant-scoped, RLS): snapshot gerado — `context`,
`version` (incremental por tenant+context), `generatedXml`, `status`
(`DRAFT`/`ACTIVE`/`SUPERSEDED`), `createdByUserId`, `activatedAt`.
## Fluxo (agente.md secao 44)
```
POST /dialplan/extensions -- edita as linhas (não afeta chamadas)
POST /dialplan/versions/generate -- gera XML a partir das linhas atuais,
valida bem-formação (fast-xml-parser),
salva como nova versão DRAFT
POST /dialplan/versions/:id/activate -- marca ACTIVE, a anterior vira
SUPERSEDED (transação atômica)
```
**Reativar uma versão antiga é o próprio rollback** — não existe endpoint
separado. `GET /dialplan/versions` mostra o histórico completo.
## Mecanismo: dinâmico por chamada, não arquivo+rescan
Ao contrário de Trunks (gateways carregados uma vez no boot/rescan),
dialplan é resolvido pelo FreeSWITCH **a cada chamada** via `mod_xml_curl`
— então não precisa de sincronização por arquivo nem de avisar o
FreeSWITCH quando uma versão é ativada. `b2bcall-fs-config` serve
`generatedXml` da versão `ACTIVE` diretamente na resposta HTTP; "ativar"
uma versão tem efeito imediato na próxima chamada.
Resolução de tenant é feita pelo channel variable
`variable_b2bcall_tenant_id` — que já vem setado desde o registro/
autenticação do ramal (`buildDirectoryUserXml`, fase Extensions). Diferente
do directory (resolvido por `domain`, hoje um valor fixo compartilhado por
todos os tenants — limitação conhecida), o dialplan não sofre dessa
ambiguidade porque a variável já está no contexto da própria chamada.
## Segurança: allowlist de applications (agente.md secao 180)
`ALLOWED_DIALPLAN_APPLICATIONS` (`packages/telephony/src/dialplan-xml.ts`)
restringe `action.application` a um conjunto seguro (`answer`, `bridge`,
`playback`, `hangup`, `set`, `export`, ...) — de propósito **sem** `system`,
`exec`, `socket`/Lua-eval ou qualquer application capaz de rodar comandos no
host. Sem isso, um tenant_admin malicioso ou comprometido poderia configurar
uma "rota de dialplan" que executa comandos arbitrários no servidor
FreeSWITCH.
## Achado crítico durante os testes desta fase
Ao testar deliberadamente que a allowlist rejeitava `application: "system"`,
a API **aceitou** com 201 — a validação inteira de `apps/api` (todo
`@Body()`, em todos os controllers, desde que a API foi criada) estava
silenciosamente inoperante rodando via `tsx`. Causa raiz, correção e o que
foi limpo: ver **docs/VALIDATION_PIPE_BUG.md** (achado grande o suficiente
pra merecer documento próprio). Resumo da correção: `apps/api` agora builda
com `tsc` de verdade antes de rodar — nunca mais `tsx src/main.ts` direto.
## Verificado ponta a ponta (já com a validação corrigida)
```
POST /dialplan/extensions {"conditionExpr":"^7000$","actions":[answer,playback,hangup]}
POST /dialplan/versions/generate?context=default → v1 DRAFT
POST /dialplan/versions/:id/activate → v1 ACTIVE
originate {b2bcall_tenant_id=<tenant>}null/_test_ 7000 XML default
→ fs-events: CALL_CREATED → CALL_ANSWERED → CALL_ENDED (tenantId correto)
```
Criei uma segunda versão (v2, rota diferente), ativei — v1 virou
`SUPERSEDED`. Reativei v1 (rollback) — v2 virou `SUPERSEDED`, v1 voltou pra
`ACTIVE`. Confirmado com `GET /dialplan/versions`.
## O que falta
- Só uma `<condition>` por extension (simplificação) — FreeSWITCH suporta
múltiplas em sequência; revisitar se algum caso de uso real precisar.
- `dialplan.view`/`dialplan.manage` não existem como permissions próprias —
reusei `freeswitch.view`/`freeswitch.configure` (já existentes na seção
145 do agente.md), que cobrem semanticamente "configuração do
FreeSWITCH". Se precisar de granularidade maior no futuro, criar
permissions dedicadas.
- Sem UI ainda (fase Frontend).

View File

@@ -46,6 +46,12 @@ 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"`:

View File

@@ -0,0 +1,75 @@
# Achado crítico: validação de entrada não funcionava sob `tsx`
**Resumo**: de quando `apps/api` foi criada (fase apps/api) até a fase Dialplan,
`ValidationPipe` global do NestJS **não validava nada**. Todo `@Body()`
passava batendo, incluindo campos não permitidos (`forbidNonWhitelisted`) e
valores fora de qualquer allowlist (`@IsIn`). Descoberto ao testar
deliberadamente que a allowlist de applications do dialplan (que existe
especificamente pra impedir uma tenant de configurar `application: "system"`
com dados de shell arbitrários — agente.md secao 180) **aceitou** a entrada
maliciosa com 201.
## Causa raiz
`apps/api` rodava via `tsx src/main.ts` (esbuild por baixo). O NestJS decide
qual classe usar pra instanciar/validar um `@Body()` lendo a metadata
`design:paramtypes` do método do controller (reflect-metadata,
`emitDecoratorMetadata` do TypeScript). **esbuild não faz checagem de tipos
completa entre arquivos** — pra parâmetros cujo tipo vem de um `import` de
outro arquivo, ele às vezes emite `Object` genérico em vez da classe real.
Quando `ValidationPipe` recebe `metatype === Object`, ele **pula a validação
silenciosamente** (comportamento documentado do Nest: tipos primitivos/
genéricos são considerados "nada pra validar").
Isso é a mesma classe de bug já encontrada na fase Extensions
(`PermissionGuard` injetando `Reflector` via construtor chegava `undefined`
em runtime) — mas ali o sintoma era um crash óbvio (`TypeError`), fácil de
notar. Aqui o sintoma é **ausência de erro**: a validação simplesmente não
roda, sem log, sem exceção, sem pista nenhuma a não ser testar
deliberadamente com entrada inválida.
## Correção
`tsc` de verdade faz checagem de tipos completa e emite `design:paramtypes`
corretamente (confirmado inspecionando o `dist/*.js` gerado: aparece a
referência real da classe, ex. `create_extension_dto_1.CreateExtensionDto`,
em vez de `Object`). A partir de agora `apps/api` **sempre** builda com
`tsc` antes de rodar:
```json
"build": "tsc -p tsconfig.json",
"start": "tsx dist/main.js",
"dev": "tsc -p tsconfig.json && tsx dist/main.js"
```
Importante: o `start`/`dev` rodam o JS compilado através do **`tsx`**, não
do `node` puro — porque os pacotes internos (`@b2bcall/shared`,
`@b2bcall/database`, ...) ainda não têm build próprio (`main` aponta pro
`.ts` fonte, não pra um `dist/`). `tsx` resolve isso via seu loader; `node`
sozinho quebraria tentando importar um arquivo `.ts` diretamente. Ver
docs/AUTHENTICATION.md pra mais contexto sobre essa limitação dos pacotes
internos.
**Nunca rode `apps/api` com `tsx src/main.ts` (ou `tsx watch`) direto — só
via `pnpm run build && pnpm run start`, ou `pnpm run dev`.**
## Limpeza feita
Uma extension de dialplan de teste com `application: "system"` foi criada
durante o teste que expôs o bug (nunca chegou a ser incluída numa versão
gerada/ativada — não havia como executar de verdade) e foi apagada
imediatamente após a correção.
## O que revisar
- `packages/telephony`, `packages/auth`, `packages/database`: não usam
`ValidationPipe`/decorators do Nest, não são afetados por esse bug
específico.
- `apps/freeswitch-events` e `apps/freeswitch-config`: não usam NestJS, não
são afetados.
- Todos os DTOs de `apps/api` (auth, extensions, trunks, dialplan) — a
correção é geral (troca de runtime, não patch por endpoint), então todos
passam a validar corretamente a partir desta fase. Reverificado com dois
testes deliberados pós-correção (campo não permitido em `/extensions`,
application fora da allowlist em `/dialplan/extensions`) — ambos
corretamente rejeitados com 400.