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

View File

@@ -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",

View File

@@ -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 {}

View File

@@ -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,
});
}
}

View File

@@ -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 } }),
);
}
}

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string> {
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;
});