diff --git a/TODO.md b/TODO.md
index f217e4d..737cc11 100644
--- a/TODO.md
+++ b/TODO.md
@@ -42,6 +42,18 @@
- [ ] Reconciliação pós-reconexão (calls/agents/queues/registrations/gateways)
— não é possível ainda, sem essas tabelas persistidas
+## PHASE 07 — XML Curl (agente.md secao 26)
+- [x] `apps/freeswitch-config` (b2bcall-fs-config): responde ao protocolo
+ XML Curl do FreeSWITCH (POST form-encoded → XML), containerizado
+- [x] `mod_xml_curl` reativado, binding restrito a `directory|dialplan`
+ (não `configuration` — evita chamadas HTTP desnecessárias no boot)
+- [x] Por enquanto sempre "not found" (sem tabela extensions/dialplan ainda);
+ verificado que a config estática vanilla continua funcionando como
+ fallback (`user/8888` → SUBSCRIBER_ABSENT via fs-config,
+ `user/1000` → USER_NOT_REGISTERED via config estática — achou o usuário)
+- [ ] Sem autenticação HTTP ainda — ok enquanto só responde "not found";
+ adicionar `gateway-credentials` antes de servir directory/dialplan reais
+
## PHASE 02 — SaaS Core
- [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base)
- [x] Node 22 LTS + pnpm instalados no host
@@ -73,8 +85,8 @@
— testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429)
- [ ] Password reset por e-mail — depende de SMTP configurado
-## PHASE 07+ — ver `agente.md` seções 26 em diante (XML Curl, Extensions, Trunks, Dialplan,
-Call Center, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
+## PHASE 08+ — ver `agente.md` seções 39 em diante (Extensions, Trunks, Dialplan, Call Center,
+Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
---
diff --git a/apps/freeswitch-config/Dockerfile b/apps/freeswitch-config/Dockerfile
new file mode 100644
index 0000000..6b3924c
--- /dev/null
+++ b/apps/freeswitch-config/Dockerfile
@@ -0,0 +1,17 @@
+# syntax=docker/dockerfile:1.7
+FROM node:22-slim
+
+RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
+
+WORKDIR /repo
+
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
+COPY packages/types packages/types
+COPY packages/shared packages/shared
+COPY apps/freeswitch-config apps/freeswitch-config
+
+RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-config...
+
+WORKDIR /repo/apps/freeswitch-config
+
+CMD ["pnpm", "exec", "tsx", "src/main.ts"]
diff --git a/apps/freeswitch-config/package.json b/apps/freeswitch-config/package.json
new file mode 100644
index 0000000..c14e91e
--- /dev/null
+++ b/apps/freeswitch-config/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@b2bcall/freeswitch-config",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "tsx watch src/main.ts",
+ "build": "tsc -p tsconfig.json",
+ "start": "node dist/main.js",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@b2bcall/shared": "workspace:*",
+ "@fastify/formbody": "^8.0.1",
+ "fastify": "5.12.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "tsx": "^4.23.12",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts
new file mode 100644
index 0000000..56a8d2f
--- /dev/null
+++ b/apps/freeswitch-config/src/main.ts
@@ -0,0 +1,58 @@
+import Fastify from "fastify";
+import formbody from "@fastify/formbody";
+import { createLogger } from "@b2bcall/shared";
+
+const logger = createLogger("b2bcall-fs-config");
+
+/**
+ * Resposta padrão do protocolo XML Curl pra "não achei nada aqui" — o
+ * FreeSWITCH cai de volta pras outras fontes XML registradas (a config
+ * estática vanilla continua funcionando normalmente).
+ */
+const NOT_FOUND_XML = `
+
+
+`;
+
+interface XmlCurlBody {
+ section?: string;
+ tag_name?: string;
+ key_name?: string;
+ key_value?: string;
+ purpose?: string;
+ user?: string;
+ domain?: string;
+ [key: string]: unknown;
+}
+
+async function main() {
+ const app = Fastify({ logger: false });
+ await app.register(formbody);
+
+ app.post<{ Body: XmlCurlBody }>("/", async (request, reply) => {
+ const { section, purpose, user, domain } = request.body ?? {};
+
+ logger.info("requisicao xml_curl recebida", { section, purpose, user, domain });
+
+ // Nenhuma tabela de extensions/dialplan existe ainda (fases Extensions/
+ // Trunks/Dialplan, agente.md secao 232). Por enquanto respondemos
+ // sempre "not found" — prova o encanamento (FreeSWITCH -> mod_xml_curl
+ // -> este servico -> XML valido) sem afetar a config estatica vanilla,
+ // que continua sendo consultada como fallback.
+ reply.header("Content-Type", "text/xml");
+ return NOT_FOUND_XML;
+ });
+
+ app.get("/health", async () => ({ status: "ok" }));
+
+ const port = Number(process.env.PORT ?? 8080);
+ await app.listen({ port, host: "0.0.0.0" });
+ logger.info(`b2bcall-fs-config ouvindo na porta ${port}`);
+}
+
+main().catch((err) => {
+ logger.error("falha fatal ao iniciar b2bcall-fs-config", { error: String(err) });
+ process.exit(1);
+});
diff --git a/apps/freeswitch-config/tsconfig.json b/apps/freeswitch-config/tsconfig.json
new file mode 100644
index 0000000..3fbff51
--- /dev/null
+++ b/apps/freeswitch-config/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index aebde38..49c0ca9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,20 @@ services:
timeout: 5s
retries: 10
+ fs-config:
+ build:
+ context: .
+ dockerfile: apps/freeswitch-config/Dockerfile
+ container_name: b2bcall-fs-config
+ restart: unless-stopped
+ # Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+
freeswitch:
build:
context: ./infrastructure/freeswitch
@@ -39,6 +53,8 @@ services:
- freeswitch_pat
container_name: b2bcall-freeswitch
restart: unless-stopped
+ depends_on:
+ - fs-config
environment:
ESL_PASSWORD: ${ESL_PASSWORD}
# Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais
diff --git a/docs/XML_CURL.md b/docs/XML_CURL.md
new file mode 100644
index 0000000..e703dd1
--- /dev/null
+++ b/docs/XML_CURL.md
@@ -0,0 +1,57 @@
+# XML Curl
+
+`b2bcall-fs-config` (`apps/freeswitch-config`) responde às consultas HTTP que
+`mod_xml_curl` faz ao FreeSWITCH pra Directory e Dialplan (agente.md secao 26).
+
+## Fluxo
+
+```
+FreeSWITCH → mod_xml_curl → http://fs-config:8080/ → (nesta fase: sempre "not found")
+```
+
+O binding cobre só `directory|dialplan` (não `configuration`) — inicialmente
+incluí `configuration` também, mas isso disparava várias chamadas HTTP
+desnecessárias no boot do FreeSWITCH pra configs de módulo que não precisamos
+controlar dinamicamente (contraria a seção 26: "Não colocar todas as
+configurações críticas via XML Curl").
+
+## Por que "not found" pra tudo, por enquanto
+
+Não existe tabela `extensions` nem `dialplans` ainda — essas são as próximas
+fases (Extensions, Trunks, Dialplan). O objetivo desta fase era só provar o
+encanamento: FreeSWITCH consegue mesmo chamar nosso serviço e interpretar a
+resposta corretamente, **sem quebrar a config estática vanilla que continua
+valendo como fallback**.
+
+Isso foi verificado assim:
+
+```bash
+# Usuario inexistente em qualquer lugar -> vai ate o fs-config, recebe "not
+# found", FreeSWITCH reporta erro correto:
+fs_cli -x "originate user/8888 &park()" # -ERR SUBSCRIBER_ABSENT
+
+# Usuario 1000 (extensao estatica da config vanilla) -> mod_xml_curl retorna
+# "not found", FreeSWITCH cai pro XML estatico e ACHA o usuario (so nao tem
+# telefone registrado, que e' o esperado sem SIP real ainda):
+fs_cli -x "originate user/1000 &park()" # -ERR USER_NOT_REGISTERED (nao SUBSCRIBER_ABSENT)
+```
+
+A diferença entre as duas mensagens de erro é a prova de que o fallback pra
+config estática funciona corretamente.
+
+## Segurança
+
+Sem autenticação no endpoint HTTP por enquanto — a única proteção é isolamento
+de rede (porta não publicada no host, só o FreeSWITCH na mesma rede do
+Docker Compose alcança). Isso é aceitável **enquanto o serviço só responde
+"not found"** (não há nada de valor pra proteger ainda). `mod_xml_curl`
+suporta `gateway-credentials` (HTTP Basic) nativamente — adicionar isso
+**antes** deste serviço passar a devolver directory/dialplan reais (fase
+Extensions/Trunks/Dialplan), já que a partir daí ele vira fonte de verdade
+para autenticação SIP.
+
+## Próxima fase (Extensions)
+
+Quando a tabela `extensions` existir, o handler `section === "directory"`
+passa a consultar o Postgres e devolver o XML real de usuário (senha SIP,
+context, etc.) em vez de "not found" fixo.
diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile
index c798614..0246e0e 100644
--- a/infrastructure/freeswitch/Dockerfile
+++ b/infrastructure/freeswitch/Dockerfile
@@ -45,6 +45,7 @@ RUN --mount=type=secret,id=freeswitch_pat,required=true \
COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_configs/modules.conf.xml
COPY overrides/autoload_configs/event_socket.conf.xml /etc/freeswitch/autoload_configs/event_socket.conf.xml
COPY overrides/autoload_configs/acl.conf.xml /etc/freeswitch/autoload_configs/acl.conf.xml
+COPY overrides/autoload_configs/xml_curl.conf.xml /etc/freeswitch/autoload_configs/xml_curl.conf.xml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
diff --git a/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml
index 9e96e06..b8e356c 100644
--- a/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml
+++ b/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml
@@ -8,11 +8,7 @@
-
-
+
diff --git a/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml
new file mode 100644
index 0000000..b6ad04e
--- /dev/null
+++ b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd42e71..2c75620 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -70,6 +70,28 @@ importers:
specifier: ^5.7.0
version: 5.9.3
+ apps/freeswitch-config:
+ dependencies:
+ '@b2bcall/shared':
+ specifier: workspace:*
+ version: link:../../packages/shared
+ '@fastify/formbody':
+ specifier: ^8.0.1
+ version: 8.0.2
+ fastify:
+ specifier: 5.12.1
+ version: 5.12.1
+ devDependencies:
+ '@types/node':
+ specifier: ^22.0.0
+ version: 22.20.1
+ tsx:
+ specifier: ^4.23.12
+ version: 4.23.12
+ typescript:
+ specifier: ^5.7.0
+ version: 5.9.3
+
apps/freeswitch-events:
dependencies:
'@b2bcall/shared':
@@ -369,6 +391,9 @@ packages:
'@fastify/fast-json-stringify-compiler@5.1.0':
resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==}
+ '@fastify/formbody@8.0.2':
+ resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==}
+
'@fastify/formbody@9.0.0':
resolution: {integrity: sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==}
@@ -963,6 +988,9 @@ packages:
fast-uri@4.1.3:
resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==}
+ fastify-plugin@5.1.0:
+ resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
+
fastify-plugin@6.0.0:
resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==}
@@ -1527,6 +1555,11 @@ snapshots:
dependencies:
fast-json-stringify: 7.0.1
+ '@fastify/formbody@8.0.2':
+ dependencies:
+ fast-querystring: 1.1.2
+ fastify-plugin: 5.1.0
+
'@fastify/formbody@9.0.0':
dependencies:
fast-querystring: 1.1.2
@@ -2164,6 +2197,8 @@ snapshots:
fast-uri@4.1.3: {}
+ fastify-plugin@5.1.0: {}
+
fastify-plugin@6.0.0: {}
fastify@5.12.1: