diff --git a/TODO.md b/TODO.md index 4ca5320..59e4ad3 100644 --- a/TODO.md +++ b/TODO.md @@ -140,7 +140,40 @@ produção. Ver docs/TRUNKS.md. - [ ] Quota de troncos — depende de Plans/Entitlements (não existe ainda) -## PHASE 10+ — ver `agente.md` seções 43 em diante (Dialplan, Call Center, +## PHASE 10 — Dialplan (agente.md secao 43-44) +- [x] `dialplan_extensions` (tenant-scoped, RLS) — editor estruturado: + context, condition field/expr, actions/anti-actions (JSON), continue, + order, enabled +- [x] `dialplan_versions` (tenant-scoped, RLS) — gerar/validar/versionar/ + ativar; reativar versão antiga = rollback (sem endpoint separado) +- [x] `apps/api/src/dialplan`: extensions CRUD + `versions/generate` + + `versions/:id/activate`, permissions `freeswitch.view`/`.configure` +- [x] Allowlist de applications seguras (`ALLOWED_DIALPLAN_APPLICATIONS`, + sem `system`/`exec`/etc — agente.md secao 180) +- [x] `b2bcall-fs-config` serve a versão ACTIVE dinamicamente por chamada + (resolve tenant via `variable_b2bcall_tenant_id`, não domain — não + sofre da limitação de multi-domínio do directory) +- [x] Testado ponta a ponta: criar extension → gerar v1 → ativar → originate + passando pelo dialplan de verdade → CALL_CREATED/ANSWERED/ENDED com + tenantId correto. Criar v2 → ativar (v1 vira SUPERSEDED) → reativar v1 + (rollback, v2 vira SUPERSEDED). Tudo confirmado via API. + +- [x] **ACHADO CRÍTICO, corrigido nesta fase**: testando a allowlist com + `application: "system"`, a API aceitou (201) — `ValidationPipe` do + Nest estava **completamente inoperante** em toda `apps/api` desde que + ela foi criada (todo `@Body()`, todos os controllers) porque `tsx` + (esbuild) não emite `design:paramtypes` corretamente pra tipos + importados de outro arquivo, e o Nest pula validação silenciosamente + quando não reconhece o tipo. Corrigido: `apps/api` agora builda com + `tsc` de verdade antes de rodar (`tsc && tsx dist/main.js`) — nunca + mais `tsx src/main.ts` direto. Ver docs/VALIDATION_PIPE_BUG.md. + Reverificado com 2 testes deliberados pós-correção, ambos + corretamente rejeitados com 400. +- [ ] Só 1 condition por extension (simplificação) — FreeSWITCH suporta + múltiplas em sequência, não implementado +- [ ] `dialplan.view`/`.manage` não existem — reusei `freeswitch.*` + +## PHASE 11+ — ver `agente.md` seções 37 em diante (Call Center/mod_callcenter, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) --- diff --git a/apps/api/package.json b/apps/api/package.json index 9bb9bcc..3b1ed7c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -3,15 +3,16 @@ "version": "0.0.1", "private": true, "scripts": { - "dev": "tsx watch src/main.ts", + "dev": "tsc -p tsconfig.json && tsx dist/main.js", "build": "tsc -p tsconfig.json", - "start": "node dist/main.js", + "start": "tsx dist/main.js", "typecheck": "tsc --noEmit" }, "dependencies": { "@b2bcall/auth": "workspace:*", "@b2bcall/database": "workspace:*", "@b2bcall/shared": "workspace:*", + "@b2bcall/telephony": "workspace:*", "@fastify/cors": "11.3.0", "@fastify/helmet": "13.1.1", "@fastify/rate-limit": "11.2.0", @@ -20,6 +21,7 @@ "@nestjs/platform-fastify": "^12.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "fast-xml-parser": "5.11.1", "fastify": "5.12.1", "ioredis": "^6.0.0", "reflect-metadata": "^0.2.2", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cbed7a9..b4ca535 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,8 +3,9 @@ import { HealthModule } from "./health/health.module"; import { AuthModule } from "./auth/auth.module"; import { ExtensionsModule } from "./extensions/extensions.module"; import { TrunksModule } from "./trunks/trunks.module"; +import { DialplanModule } from "./dialplan/dialplan.module"; @Module({ - imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule], + imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule], }) export class AppModule {} diff --git a/apps/api/src/dialplan/dialplan-extensions.controller.ts b/apps/api/src/dialplan/dialplan-extensions.controller.ts new file mode 100644 index 0000000..4f8ab3e --- /dev/null +++ b/apps/api/src/dialplan/dialplan-extensions.controller.ts @@ -0,0 +1,97 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; +import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { CreateDialplanExtensionDto } from "./dto/create-dialplan-extension.dto"; + +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("dialplan/extensions") +export class DialplanExtensionsController { + @RequirePermission("freeswitch.configure") + @Post() + async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateDialplanExtensionDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const extension = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanExtension.create({ + data: { + tenantId, + context: dto.context ?? "default", + name: dto.name, + conditionField: dto.conditionField, + conditionExpr: dto.conditionExpr, + actions: dto.actions as unknown as Prisma.InputJsonValue, + antiActions: dto.antiActions as unknown as Prisma.InputJsonValue | undefined, + continueOnFalse: dto.continueOnFalse ?? false, + order: dto.order ?? 0, + }, + }), + ); + + await recordAuditEvent(prisma, { + action: "DIALPLAN_EXTENSION_CREATE", + tenantId, + userId: user.sub, + entityType: "dialplan_extension", + entityId: extension.id, + after: { name: extension.name, context: extension.context }, + }); + + return extension; + } + + @RequirePermission("freeswitch.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims, @Query("context") context?: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + return withTenantContext(prisma, tenantId, (tx) => + tx.dialplanExtension.findMany({ + where: { deletedAt: null, ...(context ? { context } : {}) }, + orderBy: [{ context: "asc" }, { order: "asc" }], + }), + ); + } + + @RequirePermission("freeswitch.configure") + @Delete(":id") + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const result = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanExtension.updateMany({ + where: { id, deletedAt: null }, + data: { deletedAt: new Date(), enabled: false }, + }), + ); + if (result.count === 0) { + throw new NotFoundException(); + } + + await recordAuditEvent(prisma, { + action: "DIALPLAN_EXTENSION_DELETE", + tenantId, + userId: user.sub, + entityType: "dialplan_extension", + entityId: id, + }); + } +} diff --git a/apps/api/src/dialplan/dialplan-versions.controller.ts b/apps/api/src/dialplan/dialplan-versions.controller.ts new file mode 100644 index 0000000..30d78ee --- /dev/null +++ b/apps/api/src/dialplan/dialplan-versions.controller.ts @@ -0,0 +1,168 @@ +import { + BadRequestException, + Controller, + Get, + NotFoundException, + Param, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { XMLValidator } from "fast-xml-parser"; +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; +import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { + buildDialplanXml, + type AllowedConditionField, + type AllowedDialplanApplication, +} from "@b2bcall/telephony"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("dialplan/versions") +export class DialplanVersionsController { + /** + * "Gerar" + "validar" (agente.md secao 44): monta o XML a partir das + * linhas atuais de dialplan_extensions pro context, valida + * bem-formação, salva como nova versão DRAFT (não afeta chamadas até + * ser ativada). + */ + @RequirePermission("freeswitch.configure") + @Post("generate") + async generate(@CurrentUser() user: AccessTokenClaims, @Query("context") context = "default") { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const extensions = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanExtension.findMany({ + where: { context, enabled: true, deletedAt: null }, + orderBy: { order: "asc" }, + }), + ); + + const xml = buildDialplanXml( + context, + extensions.map((e) => ({ + name: e.name, + conditionField: e.conditionField as AllowedConditionField, + conditionExpr: e.conditionExpr, + actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[], + antiActions: e.antiActions as unknown as + | { application: AllowedDialplanApplication; data?: string }[] + | undefined, + continueOnFalse: e.continueOnFalse, + order: e.order, + })), + ); + + const validation = XMLValidator.validate(xml); + if (validation !== true) { + // Bug nosso (o XML é gerado por template escapado, nunca deveria + // acontecer) — melhor falhar alto do que salvar XML quebrado. + throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`); + } + + const last = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanVersion.findFirst({ + where: { tenantId, context }, + orderBy: { version: "desc" }, + }), + ); + const nextVersion = (last?.version ?? 0) + 1; + + const version = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanVersion.create({ + data: { + tenantId, + context, + version: nextVersion, + generatedXml: xml, + status: "DRAFT", + createdByUserId: user.sub, + }, + }), + ); + + await recordAuditEvent(prisma, { + action: "DIALPLAN_VERSION_GENERATE", + tenantId, + userId: user.sub, + entityType: "dialplan_version", + entityId: version.id, + after: { context, version: nextVersion, extensionCount: extensions.length }, + }); + + return version; + } + + @RequirePermission("freeswitch.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims, @Query("context") context?: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + return withTenantContext(prisma, tenantId, (tx) => + tx.dialplanVersion.findMany({ + where: context ? { context } : {}, + orderBy: [{ context: "asc" }, { version: "desc" }], + select: { + id: true, + context: true, + version: true, + status: true, + createdAt: true, + activatedAt: true, + createdByUserId: true, + }, + }), + ); + } + + /** + * "Ativar" (secao 44). Reativar uma versão antiga É o rollback — não + * existe endpoint separado. Como o dialplan é resolvido por chamada via + * mod_xml_curl (não é config estática lida uma vez no boot), não precisa + * de reloadxml/rescan: a próxima chamada já enxerga a versão ativa. + */ + @RequirePermission("freeswitch.configure") + @Post(":id/activate") + async activate(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const target = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanVersion.findFirst({ where: { id, tenantId } }), + ); + if (!target) { + throw new NotFoundException(); + } + + // Já roda dentro da transação de withTenantContext — as duas operações + // são atômicas sem precisar de um $transaction aninhado. + await withTenantContext(prisma, tenantId, async (tx) => { + await tx.dialplanVersion.updateMany({ + where: { tenantId, context: target.context, status: "ACTIVE" }, + data: { status: "SUPERSEDED" }, + }); + await tx.dialplanVersion.update({ + where: { id: target.id }, + data: { status: "ACTIVE", activatedAt: new Date() }, + }); + }); + + await recordAuditEvent(prisma, { + action: "DIALPLAN_VERSION_ACTIVATE", + tenantId, + userId: user.sub, + entityType: "dialplan_version", + entityId: target.id, + after: { context: target.context, version: target.version }, + }); + + return withTenantContext(prisma, tenantId, (tx) => + tx.dialplanVersion.findUniqueOrThrow({ where: { id: target.id } }), + ); + } +} diff --git a/apps/api/src/dialplan/dialplan.module.ts b/apps/api/src/dialplan/dialplan.module.ts new file mode 100644 index 0000000..7048c1d --- /dev/null +++ b/apps/api/src/dialplan/dialplan.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { DialplanExtensionsController } from "./dialplan-extensions.controller"; +import { DialplanVersionsController } from "./dialplan-versions.controller"; + +@Module({ + controllers: [DialplanExtensionsController, DialplanVersionsController], +}) +export class DialplanModule {} diff --git a/apps/api/src/dialplan/dto/action.dto.ts b/apps/api/src/dialplan/dto/action.dto.ts new file mode 100644 index 0000000..ab12441 --- /dev/null +++ b/apps/api/src/dialplan/dto/action.dto.ts @@ -0,0 +1,12 @@ +import { IsIn, IsOptional, IsString, MaxLength } from "class-validator"; +import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony"; + +export class ActionDto { + @IsIn(ALLOWED_DIALPLAN_APPLICATIONS) + application!: AllowedDialplanApplication; + + @IsOptional() + @IsString() + @MaxLength(500) + data?: string; +} diff --git a/apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts b/apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts new file mode 100644 index 0000000..045b4ca --- /dev/null +++ b/apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts @@ -0,0 +1,55 @@ +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, + IsIn, + IsInt, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; +import { ALLOWED_CONDITION_FIELDS, type AllowedConditionField } from "@b2bcall/telephony"; +import { ActionDto } from "./action.dto"; + +export class CreateDialplanExtensionDto { + @IsOptional() + @IsString() + @MaxLength(80) + context?: string; + + @IsString() + @MaxLength(120) + name!: string; + + @IsIn(ALLOWED_CONDITION_FIELDS) + conditionField!: AllowedConditionField; + + @IsString() + @MaxLength(255) + conditionExpr!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => ActionDto) + actions!: ActionDto[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => ActionDto) + antiActions?: ActionDto[]; + + @IsOptional() + @IsBoolean() + continueOnFalse?: boolean; + + @IsOptional() + @IsInt() + order?: number; +} diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 71f9e74..49593f6 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -32,6 +32,9 @@ interface XmlCurlBody { purpose?: string; user?: string; domain?: string; + context?: string; + "Caller-Context"?: string; + hostname?: string; [key: string]: unknown; } @@ -70,6 +73,38 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un }); } +/** + * Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em + * toda chamada originada de um ramal nosso (ver buildDirectoryUserXml). + * Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra + * todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o + * dialplan já tem essa variável disponível na própria chamada, então nem + * sofre da mesma ambiguidade. + * + * Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml) + * — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as + * linhas do editor estruturado não afeta chamadas até uma nova versão ser + * gerada e ativada (agente.md secao 44). + */ +async function resolveDialplanXml(body: XmlCurlBody): Promise { + const tenantId = body["variable_b2bcall_tenant_id"] as string | undefined; + const context = (body["Caller-Context"] ?? body.context ?? "default") as string; + + if (!tenantId) { + return NOT_FOUND_XML; + } + + const prisma = getPrismaClient(); + const version = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanVersion.findFirst({ where: { tenantId, context, status: "ACTIVE" } }), + ); + if (!version) { + return NOT_FOUND_XML; + } + + return version.generatedXml; +} + async function main() { const expectedUser = requireEnv("FS_CONFIG_USER"); const expectedPassword = requireEnv("FS_CONFIG_PASSWORD"); @@ -114,8 +149,15 @@ async function main() { } } - // dialplan dinamico ainda nao existe (fase Dialplan) — a config - // estatica vanilla continua respondendo por enquanto. + if (section === "dialplan") { + try { + return await resolveDialplanXml(request.body ?? {}); + } catch (err) { + logger.error("erro resolvendo dialplan", { error: String(err) }); + return NOT_FOUND_XML; + } + } + return NOT_FOUND_XML; }); diff --git a/docs/DIALPLAN.md b/docs/DIALPLAN.md new file mode 100644 index 0000000..852de6f --- /dev/null +++ b/docs/DIALPLAN.md @@ -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 + `` 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=}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 `` 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). diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 706787a..ab58c3a 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -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"`: diff --git a/docs/VALIDATION_PIPE_BUG.md b/docs/VALIDATION_PIPE_BUG.md new file mode 100644 index 0000000..e1aeab4 --- /dev/null +++ b/docs/VALIDATION_PIPE_BUG.md @@ -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. diff --git a/packages/database/prisma/migrations/20260828112646_dialplan/migration.sql b/packages/database/prisma/migrations/20260828112646_dialplan/migration.sql new file mode 100644 index 0000000..4a4dc82 --- /dev/null +++ b/packages/database/prisma/migrations/20260828112646_dialplan/migration.sql @@ -0,0 +1,63 @@ +-- CreateEnum +CREATE TYPE "dialplan_version_status" AS ENUM ('DRAFT', 'ACTIVE', 'SUPERSEDED'); + +-- CreateTable +CREATE TABLE "dialplan_extensions" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "context" TEXT NOT NULL DEFAULT 'default', + "name" TEXT NOT NULL, + "condition_field" TEXT NOT NULL, + "condition_expr" TEXT NOT NULL, + "actions" JSONB NOT NULL, + "anti_actions" JSONB, + "continue_on_false" BOOLEAN NOT NULL DEFAULT false, + "order" INTEGER NOT NULL DEFAULT 0, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "dialplan_extensions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dialplan_versions" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "context" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "generated_xml" TEXT NOT NULL, + "status" "dialplan_version_status" NOT NULL DEFAULT 'DRAFT', + "created_by_user_id" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "activated_at" TIMESTAMP(3), + + CONSTRAINT "dialplan_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "dialplan_extensions_tenant_id_context_idx" ON "dialplan_extensions"("tenant_id", "context"); + +-- CreateIndex +CREATE INDEX "dialplan_versions_tenant_id_context_status_idx" ON "dialplan_versions"("tenant_id", "context", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "dialplan_versions_tenant_id_context_version_key" ON "dialplan_versions"("tenant_id", "context", "version"); + +-- AddForeignKey +ALTER TABLE "dialplan_extensions" ADD CONSTRAINT "dialplan_extensions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dialplan_versions" ADD CONSTRAINT "dialplan_versions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Tabelas de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md). +ALTER TABLE "dialplan_extensions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "dialplan_extensions" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "dialplan_extensions" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); + +ALTER TABLE "dialplan_versions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "dialplan_versions" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "dialplan_versions" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index c963b0d..511168f 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -36,6 +36,8 @@ model Tenant { userRoles UserRole[] extensions Extension[] trunks Trunk[] + dialplanExtensions DialplanExtension[] + dialplanVersions DialplanVersion[] @@map("tenants") } @@ -311,3 +313,69 @@ model Trunk { @@index([tenantId]) @@map("trunks") } + +enum DialplanVersionStatus { + DRAFT + ACTIVE + SUPERSEDED + + @@map("dialplan_version_status") +} + +// Editor estruturado (agente.md secao 43) — fonte editável. As versões +// publicadas (DialplanVersion) são um snapshot gerado a partir destas +// linhas, não o que o FreeSWITCH consulta diretamente. +model DialplanExtension { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + context String @default("default") + name String + + conditionField String @map("condition_field") + conditionExpr String @map("condition_expr") + + // Array de { application, data } — validado contra uma allowlist de + // applications seguras na camada de API (agente.md secao 180: nunca + // deixar input de usuário virar comando arbitrário no FreeSWITCH). + actions Json + antiActions Json? @map("anti_actions") + + continueOnFalse Boolean @default(false) @map("continue_on_false") + order Int @default(0) + + enabled Boolean @default(true) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + + @@index([tenantId, context]) + @@map("dialplan_extensions") +} + +// Versionamento (agente.md secao 44): gerar -> validar -> versionar -> +// ativar -> reloadxml -> verificar -> rollback se necessário. "Ativar" uma +// versão anterior é o próprio mecanismo de rollback — não existe endpoint +// separado. +model DialplanVersion { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + context String + version Int + generatedXml String @map("generated_xml") + status DialplanVersionStatus @default(DRAFT) + + createdByUserId String? @map("created_by_user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + activatedAt DateTime? @map("activated_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + + @@unique([tenantId, context, version]) + @@index([tenantId, context, status]) + @@map("dialplan_versions") +} diff --git a/packages/telephony/src/dialplan-xml.ts b/packages/telephony/src/dialplan-xml.ts new file mode 100644 index 0000000..b9ce8b3 --- /dev/null +++ b/packages/telephony/src/dialplan-xml.ts @@ -0,0 +1,101 @@ +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Applications de dialplan permitidas (agente.md secao 180: nunca deixar + * input de tenant virar comando arbitrário no FreeSWITCH — "system", + * "exec", "socket" etc. ficam de fora de propósito, mesmo que existam + * módulos capazes de rodá-las). + */ +export const ALLOWED_DIALPLAN_APPLICATIONS = [ + "answer", + "pre_answer", + "bridge", + "hangup", + "park", + "playback", + "ring_ready", + "respond", + "set", + "export", + "transfer", + "sleep", + "record_session", +] as const; + +export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number]; + +export const ALLOWED_CONDITION_FIELDS = [ + "destination_number", + "caller_id_number", + "caller_id_name", + "context", + "network_addr", + "source", +] as const; + +export type AllowedConditionField = (typeof ALLOWED_CONDITION_FIELDS)[number]; + +export interface DialplanAction { + application: AllowedDialplanApplication; + data?: string; +} + +export interface DialplanExtensionInput { + name: string; + conditionField: AllowedConditionField; + conditionExpr: string; + actions: DialplanAction[]; + antiActions?: DialplanAction[]; + continueOnFalse: boolean; + order: number; +} + +function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | undefined): string { + if (!actions || actions.length === 0) return ""; + return actions + .map( + (a) => + ` <${tag} application="${xmlEscape(a.application)}"${ + a.data !== undefined ? ` data="${xmlEscape(a.data)}"` : "" + }/>`, + ) + .join("\n"); +} + +/** + * XML de dialplan (agente.md secao 43-44). Uma condição por extension — + * simplificação deliberada em relação ao FreeSWITCH puro (que permite + * múltiplas por extension); cobre o editor estruturado descrito + * na especificação sem a complexidade de encadeamento arbitrário. + */ +export function buildDialplanXml(context: string, extensions: DialplanExtensionInput[]): string { + const sorted = [...extensions].sort((a, b) => a.order - b.order); + + const extensionsXml = sorted + .map((ext) => { + const actionsBlock = actionsXml("action", ext.actions); + const antiActionsBlock = actionsXml("anti-action", ext.antiActions); + return ` + +${actionsBlock}${antiActionsBlock ? `\n${antiActionsBlock}` : ""} + + `; + }) + .join("\n"); + + return ` + +
+ +${extensionsXml} + +
+
`; +} diff --git a/packages/telephony/src/index.ts b/packages/telephony/src/index.ts index ef5f363..cc786d6 100644 --- a/packages/telephony/src/index.ts +++ b/packages/telephony/src/index.ts @@ -3,3 +3,4 @@ export * from "./normalize-event"; export * from "./freeswitch-provider"; export * from "./directory-xml"; export * from "./gateway-xml"; +export * from "./dialplan-xml"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d72a534..002ffa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@b2bcall/shared': specifier: workspace:* version: link:../../packages/shared + '@b2bcall/telephony': + specifier: workspace:* + version: link:../../packages/telephony '@fastify/cors': specifier: 11.3.0 version: 11.3.0 @@ -47,6 +50,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + fast-xml-parser: + specifier: 5.11.1 + version: 5.11.1 fastify: specifier: 5.12.1 version: 5.12.1 @@ -479,6 +485,9 @@ packages: '@fastify/view': optional: true + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@node-rs/argon2-android-arm-eabi@2.1.0': resolution: {integrity: sha512-hdWo5kb4eFRbHjdu4O6dVlRPI/CR1vbkJpe3Z9kF2s0Kp42428wg8AxI+8Cv4mygdW309BpUBf3sZaqXBNpdpw==} engines: {node: '>= 10'} @@ -815,6 +824,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1000,6 +1012,13 @@ packages: fast-uri@4.1.3: resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==} + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.1: + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==} + hasBin: true + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -1082,6 +1101,9 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1143,6 +1165,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1377,6 +1403,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -1434,6 +1463,10 @@ packages: engines: {node: '>= 8'} hasBin: true + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -1648,6 +1681,8 @@ snapshots: reusify: 1.1.0 tslib: 2.8.1 + '@nodable/entities@3.0.0': {} + '@node-rs/argon2-android-arm-eabi@2.1.0': optional: true @@ -2016,6 +2051,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + anynum@1.0.1: {} + atomic-sleep@1.0.0: {} avvio@9.3.0: @@ -2209,6 +2246,20 @@ snapshots: fast-uri@4.1.3: {} + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fastify-plugin@5.1.0: {} fastify-plugin@6.0.0: {} @@ -2305,6 +2356,8 @@ snapshots: is-property@1.0.2: {} + is-unsafe@2.0.2: {} + isexe@2.0.0: {} iterare@1.2.1: {} @@ -2364,6 +2417,8 @@ snapshots: on-exit-leak-free@2.1.2: {} + path-expression-matcher@1.6.2: {} + path-key@3.1.1: {} path-to-regexp@8.4.2: {} @@ -2561,6 +2616,10 @@ snapshots: std-env@3.10.0: {} + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -2605,6 +2664,8 @@ snapshots: dependencies: isexe: 2.0.0 + xml-naming@0.3.0: {} + xtend@4.0.2: {} zeptomatch@2.1.0: