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

@@ -0,0 +1,101 @@
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* 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 <condition> 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 ` <extension name="${xmlEscape(ext.name)}" continue="${ext.continueOnFalse ? "true" : "false"}">
<condition field="${xmlEscape(ext.conditionField)}" expression="${xmlEscape(ext.conditionExpr)}">
${actionsBlock}${antiActionsBlock ? `\n${antiActionsBlock}` : ""}
</condition>
</extension>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="dialplan">
<context name="${xmlEscape(context)}">
${extensionsXml}
</context>
</section>
</document>`;
}

View File

@@ -3,3 +3,4 @@ export * from "./normalize-event";
export * from "./freeswitch-provider";
export * from "./directory-xml";
export * from "./gateway-xml";
export * from "./dialplan-xml";